Skip to content

Remove libuv on Windows - #42819

Open
dylan-conway wants to merge 104 commits into
mainfrom
claude/remove-libuv-windows-2d63e8
Open

dylan-conway wants to merge 104 commits into
mainfrom
claude/remove-libuv-windows-2d63e8

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

What this does

Bun no longer links libuv. Windows gets its own event loop on an I/O completion port, and every uv_* code path calls Win32/NT itself. The dependency, its fork and its two patches are gone. POSIX keeps its own arms; what changed in shared code is under "Every platform".

How Windows works now

  • Loop: packages/bun-usockets/src/eventing/iocp.c replaces libuv.c: one batch of up to 128 packets a tick, the port read only before the first callback; Bun's timer heap sets the wait.
  • Sockets: IOCTL_AFD_POLL (as wepoll, mio, uv_poll_t); one overlapped AcceptEx per listener. Non-IFS LSP: select() on a thread, as libuv.
  • Exit, events, console input: wait completion packets, else RegisterWaitForSingleObject.
  • Pipes, console, files: src/io/windows/ (see its README), overlapped on the port; a synchronous handle is read by a thread of its own.
  • Spawn: src/spawn_sys/windows/. Handles are inherited as on main and in Node, under a process-wide lock.
  • node:fs, fs.watch, dns, os, tty, signals: direct calls on Bun's work pool; jobs that wait on something outside have a second one.

N-API

Windows now matches POSIX: 11 uv_* functions are implemented, every other export aborts, naming the function. On main all worked on Windows: sqlite3's Database#each and @datadog/pprof now end the process as on POSIX. uv_version*, uv_strerror, uv_err_name are stubs too. bun.exe exports the 316 names POSIX does (main: 314; 9 libuv internals are gone, 11 uv.h names are new, as stubs). No platform's stubs have prototypes any more: the copied headers (but uv/errno.h), the tree-sitter generator and napi_body.rs's name lists are gone, about 6,000 lines. napi_get_uv_event_loop and node::GetCurrentEventLoop() return what POSIX's do.

Bugs on main that this fixes

With a test (x is x.test.ts, with the number of tests; worker-late-completion and Node's own are main's). "On main": a Windows build of the base commit; blank: all fail.

Bug Test On main
Threads spawning at once inherit each other's pipe ends: no EOF spawn (2) times out
process.stdout.columns after a resize is not Node's (60 for 120) process-signal-windows
fs.watch: deleting the watched directory emits rename for ever fs.watch (4)
fs.watch(dir) and fs.watch(dir, {recursive}) share one non-recursive handle fs.watch (2)
Relative paths past MAX_PATH, and mkdtempSync past 260 characters: ENOENT fs (15) 13 fail
Bun.write(<absolute path of 260+ characters>, data): ENOENT 23316-long-path-spawn* expected it
C:zz writes a file C with a stream zz fs (2) 1 fails
statfs and appendFile errors carry \\?\C:\… fs
Shell 2>&1 / 1>&2 lose an external command's output when stdout/stderr are files bunshell (5) 4 fail
Bun.spawn(["./nope"], {stdout: Bun.file(p)}) leaves an empty file spawn (8)
Bun.write(Bun.file(readOnlyFd), request) resolves (small) or rejects with a bare number (large) bun-write (6) 2 fail
A shared listener (cluster SCHED_NONE) can freeze a worker inside accept() cluster, test-cluster-shared-leak.js 1 run in 5
Shell cat file > out can finish before its queued writes: truncated output, a panic bunshell (3) 2 fail
A second cat on a stdin at its end panics (POSIX: hangs); cat dir > file hangs bunshell (24) 17 fail
ls -R, rm -v, cp -v, mkdir -v, touch hang when their reader goes with chunks queued bunshell (3) 7 runs of 11
process.title is capped at 1 KiB and cached process (4)
process.report sharedObjects cuts or drops a path of MAX_PATH or more process (4) 3 fail
A UTF-8 sequence split between two console writes is not joined terminal-platform-gaps
Work that completes after its Worker began teardown is invisible to it worker-late-completion (debug builds) 4 of 28 fail
stdin.pause() freezes the loop while another process reads the same synchronous stdin process-stdin times out
A destroyed net.connect that waited for a busy pipe still takes the next instance node-net 1 of 7 to 4 of 6
setRawMode(true) fails on a read-only console stdin (fixed while a line read is pending) process-stdin throws
tty.ReadStream(fd).setRawMode() fails for every console fd but 0 terminal-platform-gaps 'error'
fs.watch reports 8.3 aliases after the watched directory is renamed fs.watch
Unawaited writes at an fd's position land out of order, every platform, as in Node (#11117): Bun.write, fs.write, fs.writev, FileHandle.write, writeFile(fd), appendFile(fd) bun-write (11), fs (8) 4–10, 8 fail
Bun.write(Bun.stdout, "") rejects (#13477) bun-write
A pending Bun.stdin.text() (#41850) or rejecting Bun.file() read (#39787) does not keep the process up bun-file 3 fail
FileSink.flush() returns with the write still in flight (#42435) filesink 1 in 4 to 8 in 11
A shell redirect target is still open when the command settles bunshell (8) 2 fail
socket.end() on a named pipe closes at once: the last reply is lost (#39723) node-net, test-net-pingpong.js
A message-type pipe server (Docker, Podman, .NET) cannot be told the client is done writing node-net (11) 9 fail
An unawaited fs.close(fd) overtakes earlier operations: EBADF, or a write into the next file fs (36) 26 fail
As many reads of a silent pipe as the pool has threads stop all async jobs fs (8) 7 fail
fs.rmSync("./dir", {recursive, force}) removes nothing and succeeds; so any path with . or .. fs
An exit code above 255 is cut to a byte (exit 256 is success, a crash reads 5) spawn (10) 9 fail
{...process.env, PATH: x}: which spelling the child sees is unspecified spawn-env (18) 10 fail
Bun.write(fd, Bun.file(path), {mode}): EINVAL for a pipe (macOS, FreeBSD), chmods the fd (Linux) bun-write POSIX only
FAT32/exFAT: recursive rm spins for ever on an open file, cannot remove read-only entries; mv is EINVAL fs (8), mv (4) 10 fail
A console key record with repeat count 0 gives the key 65,535 times terminal-platform-gaps (2) 1 fails
fs.rmSync("", {recursive}) empties the cwd; readdirSync("") lists it fs
A recursive rm of \\.\C:\… is EFAULT fs
Module._stat() misses relative paths with . or .. and finds a directory at "" node-module-module
Shell mv cannot move a read-only file mv (2)
Shell echo and which exit with an errno when they cannot write (every platform); now 1 bunshell (2)
A signal Windows cannot send does not end a child (ENOSYS; killSignal is ignored); now SIGKILL, as Node spawn-kill-signal (17), child_process (12) hangs; 12 fail
A PATH that ends in a lone quote crashes Bun.spawn(["name"], {env: {}}) spawn-path (20) 3 crash
What a child wrote before its timeout kill is lost if unread spawn
fs.watch of a directory that replaced a watched file hears nothing (Linux too) fs.watch times out
A handler that waits synchronously for the next connection waits for ever socket (2) times out
Terminating a Worker with a shell builtin's pipe write out hangs worker-terminate-lifetime (4) 1 times out
Bun.write(path, data) or the last fs.write(fd) is missing when process.exit() follows fs (8) 2 fail
A Node peer that sends a socket over IPC gets a NACK and the socket leaks child_process_ipc_handle times out
Shell mv x dir\nul removes x (a name without a dot) and exits 0 mv (5) 1 fails
A writer to a synchronous pipe loses the rest of a write that came back short filesink
terminate() of a Worker whose connect waits for a busy named pipe never ends worker times out

Without a test (by construction, or by hand on both builds):

  • Four Workers with 20 dns.lookup each, then terminate(): main panics 3 of 3.
  • fs.readv(fd, []): a panic (debug) or a promise that never settles. Resolves 0.
  • worker.terminate() left a running shell command's child alive.
  • A subprocess after a builtin cat on the same stdin: Bad file descriptor.
  • An IPC message over i32::MAX bytes panicked (every platform, by reading).
  • An inherited overlapped pipe joined Bun's port, so a sibling's completions could arrive here.
  • SetFileCompletionNotificationModes on foreign handles could hang a sibling's WriteFile.
  • A uv_loop_t was created, and never run, on every thread that touched the filesystem.
  • net.connect(pipe).destroy() before the connect leaked the handle.
  • Bun.file() reads over 4 GiB: ENOMEM. readv/writev totals over 4 GiB wrapped; a 4 GiB buffer's length was cut to 32 bits.
  • Bun.file(p).lastModified between 2038 and 2106 was garbage.
  • process.cpuUsage wrapped every 24 hours, in whole milliseconds; peak RSS was rounded through KiB.
  • Bun's own isatty was true for NUL (JS saw false).
  • Internal opens counted against the CRT's 8,192 fds.
  • The socket sweep timer never stopped: a wake-up every 4 s for life.
  • tick_with_timeout ignored its timeout; JSC's own timers fired only when something else woke the loop.
  • Shell cat hung on a read error with output queued; the writer could credit a dead command's bytes to the next.
  • process.stdout.write was garbled once another process restored the code page (console.log still is).
  • Bun.stdin.text() on an overlapped stdin: EUNKNOWN.
  • A client that end()s at once received nothing from an outbound-only pipe server. Now the first write (Node: all).
  • net.connect to an inbound-only pipe server: ECONNREFUSED. A client that only writes now works, as Node.
  • fs.promises.writeFile(pipe, data): EINVAL.
  • Async fs.open and fs.statfs of a malformed Buffer path never settled.
  • fs.statfs counted clusters in 32 bits and bavail ignored the quota.

Workarounds deleted

  • us_loop_pump's active_handles++; uv_run(NOWAIT); active_handles--, writes to uv_loop_t.active_handles, 12 us_socket_ref/unref calls in uWS.
  • The uv_timer_t + uv_idle_t pair that woke uv_run for Bun's timers, the "forever timer", the Windows-only us_timer_t.
  • libuv's thread pool.
  • sys_uv, FsReq/UvFsReq, the Windows twins of POSIX state machines: ReadFileUV, WriteFileWindows, CopyFileWindows, AsyncMkdirp, win_watcher.rs, the libuv DNS backend, uv_signal.
  • Fd juggling: make_libuv_owned, HANDLE→CRT-fd round trips, .uv() panicking on HANDLE fds (what remains is crt(), FdKind::Crt).
  • The open_handles registry keyed by uv_handle_t* and five libuv-only Worker teardown steps.
  • Shell: is_writing/is_reading, NUL → uv_tty_init → EBADF → "restart as file", fds set to INVALID once libuv had them.
  • A zero-byte recv after every connect and a second recv after every readable event; re-arming the AFD poll before the callback.
  • <uv.h> in ZigGlobalObject.h: windows.h/winsock2.h in nearly every C++ TU.
  • Numbers in libuv's shape: Windows E, a second SystemErrno with 61 aliases, and 86 UV_* variants on both; errno ↔ UV_E* as two 100-line tables; open flags going MSVC → Linux octal → MSVC; WSA codes going through UV_EAI_* to c-ares.
  • Parking with JSC heap access held during an idle collection; the cfg!(windows) arm of the GC controller.
  • fchmod's set, toggle, clear of ARCHIVE; the pre-1703 CreateSymbolicLinkW retry and latch; statfs's GetDiskFreeSpaceW + GetFullPathNameW ladder; an unreachable \\.\ branch; Exited.raw beside a truncated Exited.code.
  • POSIX: poll flags set on readers' polls that only writers read, and a second fcntl per spawned pipe.

Performance

Release builds (ThinLTO, no PGO) of a9786b28899 and main dc3660dea66, Windows 11, Ryzen AI 9 HX 370 (24 CPUs), order ABBA, median of 6 (1 GiB rows: 2). 70 cases, 110 metrics; † is from a wider sweep on a build just before. Listed: what differs by more than 7 % with ranges apart. Above 1: the branch is better. The last four commits against the one before them, 32 metrics: none differs.

Faster

branch main
fs.watch: reported of 5,000 files made while JS is busy, flat / recursive / 16 Workers 5,000 / 5,000 / 5,000 1 / 1 / 961
Shell cat big > out / cat < big | cat > out / prog | cat > out, 1 GiB (MiB/s) 1,539 / 1,537 / 1,377 20.4 / 24.9 / 14.4 76× / 62× / 95×
… 64 MiB 1,348 / 1,667 / 474 331 / 442 / 182 4.1× / 3.8× / 2.6×
fs.watch: create 1,000 watchers (ms) 70 498 7.1×
Bun.file().writer(), awaited 64 B writes (/s) 357,865 51,953 6.9×
32 slow dns.lookup at once (s, 1 run; Node: 20.2); a thread per CPU, here 24, libuv 4 4.0 20.2 5.0×
setTimeout(16 ms) overshoot p50 / p99 (ms) 0.23 / 0.85 0.71 / 1.30 3.1× / 1.5×
200 named-pipe clients connecting at once (ms) 27.1 79.0 2.9×
fs.watch: create → event p50 / p99, flat; recursive (ms) 0.39 / 1.10; 0.39 / 1.12 0.82 / 2.23; 0.81 / 1.81 2.1× / 2.0×; 2.1× / 1.6×
Shell cat big to the caller's pipe, 64 MiB (MiB/s) † 1,441 577 2.5×
Shell echo hi | cat (/s) 10,865 5,557 1.96×
Bun.write(path, string) 1 KiB, 1 / 64 at once (files/s) 5,620 / 5,696 3,502 / 4,004 1.60× / 1.42×
Bun.file().stream(), 256 MiB (MiB/s) † 2,827 1,464 1.93×
Bun.file().text(), small files, 64 at once (files/s) 178,838 96,355 1.86×
A child's stdout: 64 B / 64 KiB / 64 KiB via node:child_process (MiB/s) 57.8 / 2,364 / 2,151 32.5 / 2,132 / 1,976 1.78× / 1.11× / 1.09×
fs.realpath.native, 64 at once; realpathSync.native † (/s) 67,248; 20,163 38,622; 12,403 1.74×; 1.63×
setTimeout + clearTimeout pairs (/s) 11.0 M 6.7 M 1.65×
Response(Bun.file), 1 MiB, 16 at once (req/s; p50 / p99 ms) 2,845; 5.54 / 9.71 2,073; 7.58 / 12.0 1.37×; 1.37× / 1.24×
process.send round trips, 1 KiB JSON (/s) 39,474 30,092 1.31×
Named-pipe ping-pong 64 B: Bun.listen / node:net (/s) 182,359 / 155,125 140,672 / 125,727 1.30× / 1.23×
node:net TCP ping-pong, 1 / 32 connections; UDP (/s) 60,148 / 61,762; 69,228 47,009 / 49,623; 53,982 1.28× / 1.24×; 1.28×
node:tls ping-pong, 1 / 32 connections (/s) 55,917 / 57,435 44,062 / 47,472 1.27× / 1.21×
Shell echo line > new file / >> file (/s) 2,840 / 4,473 2,255 / 3,712 1.26× / 1.21×
A child's stdin: bun a | bun b / 16 MiB payloads / echo through a child / write + 'drain' (MiB/s) 3,640 / 2,029 / 1,508 / 2,123 2,905 / 1,570 / 1,338 / 1,961 1.25× / 1.29× / 1.13× / 1.08×
setTimeout(0..3 ms), half cleared (/s) 3.76 M 3.09 M 1.22×
writeFileSync 1 KiB over one file / appendFileSync (/s) † 7,980 / 5,022 6,702 / 4,345 1.19× / 1.16×
TLS handshakes, 16 at once (/s) † 944 839 1.13×
RSS per idle TCP connection, both ends (KiB) 3.73 4.16 1.12×

Level: stdin on a synchronous pipe (0.96–1.20×; on 1, 2 or 4 CPUs 0.96–1.29×), setRawMode, echo > same file, console.log to a pseudoconsole, Response(Bun.file) 1 KiB, Bun.write(path, Blob), unlink, fs.promises.stat, mkdir chains, waking an idle loop, pipe ping-pong ×32, Bun.serve and node:http throughput, a connection per request, fetch, an https download, start-up, spawn, one dns.lookup, other synchronous fs, a 6,000-module bun build.

Slower

branch main Why
Named pipe, one side end()s: connect to 'close' (ms; Node: 63) 51 0.2 libuv's 50 ms eof_timeout, as Node. Main closes at once and loses the last reply
Time inside the call of Bun.write(path, string) 1 KiB, p50 / p99 (µs) 171 / 290 4.9 / 20 On the calling thread, as on POSIX. The whole write is 1.87× faster
Bun.serve, 64 connections, p50 (ms): handler / static route † 1.04 / 0.78 0.53 / 0.00 Same throughput; the handler's p99 is 1.3 against 4.4–7.2
fs.write callbacks / FileHandle.read, 64 at once (/s) 567,247 / 668,652 659,239 / 713,076 0.86× / 0.94× Bun's pool wakes a second thread per task; libuv's woke one
… 1 at a time (ranges overlap): fs.read / FileHandle.write (/s); createReadStream (MiB/s) 40,569 / 56,082; 541 48,662 / 61,217; 645 0.83× / 0.92×; 0.84× as above
Creating 5,000 files under a recursive fs.watch (ms) 979 882 0.90× Their events are read meanwhile; main reports one

Behaviour changes

Besides the tables above.

Windows

  • Bun.file(fd): reads are at offsets and leave the position alone (POSIX reads at it; on main a sliced read moved it).
  • lastModified, Last-Modified, If-Modified-Since do not wrap in 2106; fs.Stats keeps Node's wrap.
  • fs errors: a path too long for any buffer: ENAMETOOLONG from mkdtemp, chmod, mkdir, rename, link, symlink (main, Node: ENOENT; EINVAL for a link's target). rmSync(".", {recursive}) empties the directory, then EBUSY (main: EINVAL, nothing touched). rmdir of a file open without delete sharing: ENOENT (main, Node: EBUSY). As Node now: writeFileSync(dir) is EISDIR, open, with the path; errors from files Bun opened itself carry no fd; chdir(file) is ENOENT. Async readv/writev errors say read/write.
  • fs paths: \\?\C:/forward/slashes opens. realpath.native works in an AppContainer. A Buffer path that is not UTF-8 names no file (ENOENT, as Node; mkdir recursive EPERM, rm recursive EINVAL even with force, chown succeeds), where main wrote U+FFFD.
  • fs files: writeFile with an a flag appends atomically across processes (to a pipe: EBADF, as Node). Created files honour the umask. Numeric O_DIRECT/O_SYNC are honoured; O_WRONLY|O_RDWR is EINVAL. UV_FS_O_FILEMAP with O_APPEND cannot be truncated. fs.readSync restarts an aborted read (main: ECANCELED; by reading).
  • fs, other codes: as Node now: mkdir of a 256-character name EINVAL; copyFile from \\host\share, fs.watch("dir/ ") ENOENT. Not as Node: statfs of a dangling link ENOENT (main, Node: succeeds); chmod("NUL") EISDIR, unlink("NUL") EINVAL (main: the reverse); writeFileSync of \\host\share ENOENT (main: EISDIR).
  • Async node:fs on an fd runs on Bun's pool, which starts jobs in no order (but for fs.close and writes at the position). process.exit() waits for its writes: to a disk, all; to a pipe, while one of them ends within 1 s.
  • fs.watch: once the watched directory is deleted (Node too: rename for ever) the path can be watched again; the watcher stays open until close(). Lost changes are ('change', null), as Node. File watches report the real path's basename; relative link targets resolve against the link. fs.watch("NUL") throws ENOENT. 8.3 aliases are resolved by handle, and only names with a ~: an alias set by hand (fsutil file setshortname) is reported as it is, and a recursive watch no longer reports change of the subdirectory a file of 8.3 shape was made in (on main and Node a side effect of the lookup; for longer names nobody reports it).
  • Sockets: a failed WSAStartup gives WSANOTINITIALISED (libuv aborted). A listener that cannot start AcceptEx is retried. One received from the cluster primary is not inherited by the worker's children. With SCHED_NONE a connection is the worker's whose AcceptEx completes, busy or not (as Node).
  • Named pipes: a server whose instance cannot be made retries. A client that connected, wrote and closed before ConnectNamedPipe is a connection (Node: dropped).
  • Named pipe end(): waits until the other end has read (FSCTL_PIPE_FLUSH), sends a zero-length message on a message-type pipe, and closes once nothing has arrived for 50 ms (libuv's eof_timeout; a later reply is lost, as in Node); with allowHalfOpen a message-type pipe stays open. A zero-length message received is 'end', and what the server writes after it is lost. Bun's servers stay byte-type. node:http clients and Bun.connect/Bun.listen end() skip the 50 ms; their shutdown() does not.
  • Pipes: a handle Bun is given is not asked its kind on the JS thread (but by Bun.write(fd) and the first synchronous stdout/stderr write): what is not a pipe fails at open, and an unref'd overlapped stdin at its end exits without 'end' (main, Node: emit it). Chunks are up to 256 KiB, as on POSIX (main: 64 KiB). A read error's syscall is read, a console's too (main: recv).
  • Timers are not rounded to milliseconds (3 ms fires at 3.15, not 4.12). Unref'd ones do not fire while an idle --watch/--hot is parked; that park is bounded at 1 s, as on POSIX.
  • Console: SIGWINCH comes from the console's WinEvent and raw-mode resize records; under a pseudoconsole only the latter (as Node, main). An unhandled Ctrl+Break or close restores the modes; a handled Ctrl+C leaves them after setRawMode(false). Two Ctrl+C in one tick are two events. Text typed before pause() goes into the next line read (Node, main: dropped). Ctrl+] is not delivered in line mode. On a read-only console stdin setRawMode(true) works while a line read is pending (the console's own mode is unchanged: Ctrl+C stays a signal, no VT input, no resize records), else errno 1 as on main.
  • Process: tty_wrap.TTY(fd) of a non-console throws POSIX's text. process.report formats addresses as os.networkInterfaces().
  • Spawn: stdio: "overlapped" at 0–2 is an overlapped handle, as Node (was "pipe"). "socket-fd" follows the POSIX contract. spawnSync without pipe, timeout, maxBuffer or signal blocks in a wait, and checks the stack first, as on POSIX. Internal spawns (bun upgrade, create, pm version, test --changed, openInEditor) pass the live environment. exited can settle before onDisconnect. A private loop that cannot be made is blamed on CreateIoCompletionPort. Nothing Bun creates is inheritable outside the spawn lock, CreatePseudoConsole's handles included (c-ares's sockets are, as on main and Node). A job that cannot nest stops asking for kill-on-close.
  • dns: Bun.connect, fetch, UDP connect report a failed lookup by its own code (WSATRY_AGAIN: EAI_AGAIN; main: always ENOTFOUND; no test). Non-ASCII names are IDNA-encoded as by url.domainToASCII (libuv: xn--MNCHEN-psa.de). Lookups in flight are not cancelled at teardown.
  • Error codes, as libuv 1.52.1: ERROR_DIRECTORY ENOENT (main: ENOTDIR), ERROR_NOACCESS EFAULT (EACCES), ERROR_BUFFER_OVERFLOW ENAMETOOLONG (EFAULT), ERROR_BROKEN_PIPE EOF (EPIPE), ERROR_NO_DATA EAGAIN (EPIPE).
  • Bun.write / Bun.file: the parents Bun.write(path, data) makes have the names Win32 then opens (main: literal, then ENOENT). A copy onto a directory or read-only file is EPERM, not ENOENT; a directory as source is EISDIR (main: ENOENT), Bun.write(dir, "") too (EINVAL); an unreadable source is blamed on the source. Bun.write(readOnlyFile, "") fails in open with EPERM.
  • Bun.write on the calling thread: under 256 KiB to a disk file or the process's own stdout/stderr (an fd only when synchronous); what can wait on its other end goes to the pool. An unreachable share holds up the loop for the open (21 s; main: 1 ms). writer() writes up to 64 KiB to a disk file so too, its result still from the loop. FileSink.flush() returns a promise while a write is out (main: 0). An unawaited Bun.write(Bun.stdout, 256 KiB or more) can be overtaken by console.log, as on POSIX (main: never).
  • Shell: (echo a; external) | cat pipes the external's output. Paths past MAX_PATH work in redirects, cat, ls, touch. rm and mv take . and .. (main: fail), off the directory's real path, as cat ../f does, and bash.
  • Loop teardown (Workers, spawnSync): collects every operation; a thread that cannot be stopped frees what it has (main: 64 tries of uv_loop_close, the rest leaked).
  • Symlinks from bun install and archives: / in a target is stored as \.

Every platform

  • Errors: an errno outside Bun's table keeps its number and gets code: "EUNKNOWN" (main: no code).
  • fs: utimes/lutimes/futimes: an invalid Date leaves that time alone, "Infinity" means now (as Node; both threw). fsync and unlink retry EINTR.
  • fs.close(fd) waits for the async operations given the fd before it; what comes meanwhile gets EBADF. Not on a pipe, console or device.
  • fs.rm recursive gives up (ENOTEMPTY) after 50 passes over a directory that keeps refilling (main: never).
  • fs.watch, Linux and FreeBSD: a watch whose root was deleted leaves the shared table: the path can be watched again.
  • node:child_process: 'close' waits for the pipes above stderr too, and lets go of what nobody read (as Node).
  • Bun.write / Bun.file: an async write error carries path or fd. POSIX: when open says ENOENT and mkdir fails, the mkdir error is reported; a truncate that fails with EPERM rejects with that. {mode} applies only to a file Bun.write opens. A slice(0, n) of what is not a regular file stops at n (POSIX main: up to 2n − 1).
  • dns: a c-ares socket with an error or hang-up is processed as readable and writable, as Node (POSIX main spun on it and gave ETIMEOUT).
  • IPC: after disconnect(), messages from a later read are dropped and their descriptor closed (POSIX: what the same read brought is delivered, as Node).
  • Shell: cat (POSIX: behind the experimental-builtins flag) carries on after an input it cannot read, prints cat: x: <reason>, exits 1. yes yields every four chunks whatever it writes to.
  • process.umask() reads a value Bun keeps: the getter no longer zeroes the umask for an instant (one changed behind Bun's back is not seen). bun install gives bins 0777 & ~umask with every linker.
  • process.versions.uv is 1.52.1 (was 1.51.1-dev / 1.48.0).
  • spawnSync: a deadline in the past ticks with a zero timeout. Linux waiter thread: SIGCHLD has SA_RESTART.
  • Smaller: the "unsupported uv_*" text drops "for POSIX systems"; macOS clamps a long __libcpp_verbose_abort message; with fd 0 closed the shell's placeholder stdin is read-write; fs.watch's repeat window is on the monotonic clock.

Test changes

  • 500+ new tests, most Windows-only. About 45 outside the table pin a change above and fail on main; the rest guard new code. No new Rust unit tests or fault-injection points.
  • Enabled on Windows: process-stdin pipe backpressure, spawnSync optimizations, napi/uv, napi/uv_stub (305 stubs; main skipped 11), child_process_ipc_handle (10 pass, 4 todo, 1 skip), gc-controller-cadence, test-net-pingpong.js, 24 more, and 4 stale skips.
  • Expectations changed: appcontainer (realpathSync.native works), 23316-long-path-spawn* (the write succeeds), fs-path-length (malformed Buffer paths: ENOENT), two Ctrl+C exit codes (0xC000013A, not 58), the dev-server harness (0xC0000409, not 9), bun-write (a directory as source). napi/uv "uv_tty_reset_mode…" is POSIX-only.
  • Races removed in node-net, filesink, process-stdin, run_command, bun-write. flaky-tests.txt loses node-net and test-cluster-shared-leak.js.

Verification

  • CI: Windows Server 2019 x64, Windows 11 arm64, Debian 13, Ubuntu 25.04, Alpine 3.23, Linux ASAN, macOS x64 and arm64.
  • Linux, main beside this branch: 244 test files and Node's 3,744. No test passes on main and fails here.
  • Windows, 26 September, an older base, debug builds: of the 131 test files that mention libuv or isWindows and that this PR does not touch (284 today), only fetch.test.ts (below) passed on the base and failed here.
  • The last commits: Windows release (62 files) and debug; six timing tests, 144 runs on 1, 2 and 4 CPUs; a child inheriting a paused stdin, 3,600 rounds likewise; Linux release and ASAN, 20 files. No new failure.
  • Against main and Node 25: 703,000 calls on 887 shapes of name, 1,375 Bun.file calls, 445 quoted arguments, 88 operations on FAT32 and exFAT, files over 4 GiB, 1,651 pairs of a handle and a way to leave it. Tables (errno, NTSTATUS, open flags, exports): 180,000 rows. The legacy-console emulator against libuv's: 20,300 cases. 397 externs, struct sizes and constants against the SDK.
  • The socket fault-injection tests pass where it is built in (21 of 21); no CI lane.
  • Unexplained: bundler_edgecase.test.ts did not finish once, on a build with a thread-pool change not in this PR (22 other runs and 48,000 spawns did).
  • Not verified: a legacy console window, a non-IFS LSP, safe mode without networking, windowsHide, timers across sleep, a real Ctrl+C against a shell builtin, a full disk. Performance is x64.

Known gaps and follow-ups

  • terminal.test.ts "terminal is released after a write()…" failed once in CI on Server 2019; 55 of 55 locally.
  • No test: select() for a non-IFS LSP, the thread-pool wait where ntdll has no wait packets (Wine), a listener whose AcceptEx socket cannot be made.
  • The spawn lock serialises spawns, so a stalled CreateProcessW stalls them all. PROC_THREAD_ATTRIBUTE_HANDLE_LIST would remove it, but drops handles an addon marked inheritable. A socket received over IPC is inheritable until it is imported.
  • A non-AFD listener's accept() returns a handle that is inheritable for a moment, outside the lock.
  • I/O on a synchronous pipe queued behind another process's cannot be cancelled; its thread keeps a duplicate handle until the pipe's next event. A Worker with such a pool read (a console's or an overlapped pipe's too) cannot finish terminating, as on main.
  • A pipe end opened without read access reports EPERM from the loop (Node: ENOTCONN at the call); without write access too (main: EPIPE). Console reads ignore a reader's byte limit.
  • The uv_* names are libuv 1.51's: uv_tcp_keepalive_ex and uv_udp_open_ex are not stubbed.
  • With every pool thread busy, process.exit() waits for an owed write: 6 s measured (main: 0.1 s).
  • Debug builds: fetch.test.ts fails 27 in a whole-file run, 17 on the base; all pass alone.
  • On main too, not fixed here:
    • an assertion when an HTTP/3 listener fails to bind
    • no write backpressure on named-pipe sockets
    • Bun.spawn: a cwd past MAX_PATH is ENOENT; a relative one is applied twice to .\name
    • passing a socket from Bun to a Node child over IPC; IPC messages lost at exit
    • wsock32.lib linked ahead of ws2_32.lib (setMulticastInterface sets Don't-Fragment)
    • process.chdir to another drive does not keep =X:; fs.writeFile("X:a.txt") from another drive writes into the cwd
    • a file is still open after await writer.end(), and for ever when a written writer() is dropped
    • "pipe" at stdio 3+ gives the child an overlapped end (Node: synchronous)
    • fs.read or Bun.write on an overlapped pipe ends early (0 bytes, no error) when another operation on the fd completes
    • socket.destroy() on a named pipe with an unread write that does not fit the pipe leaves it open
    • process.exit(256) exits with 0; Bun.spawn does not know SIGBREAK
    • bun test --parallel drops results reported just before process.exit()
    • Bun.listen, Bun.serve, net.Server#listen and Bun.udpSocket resolve a host name on the JS thread (every platform)
    • POSIX: Bun.write of 64–256 KiB to a non-blocking pipe that fills sends its first 64 KiB twice
    • Linux: after mv d e; mkdir d, fs.watch("d") hears e; builtin cat (experimental flag) cannot read a regular file, and two in a pipeline panic
    • from a cwd reached through a link, fs.writeFile("../x") and readdir("..") take .. off its real path, readFile and stat off its spelling
    • no exit after Bun.listen({unix: pipe}).stop(true) (accepted pipes stay open), nor after stdin.on("data", f) and destroy() in one tick
    • a path of 33,000 characters panics in Bun.which and spawn (bun <path>: 2,000; shell cp: 4,100)
    • process.kill(pid, "SIGABRT" | "SIGFPE" | "SIGILL" | "SIGSEGV"): ERR_UNKNOWN_SIGNAL (Node: ENOSYS)
    • a Worker's setRawMode after the exit restored the console leaves it changed; a cancelled Bun.stdin.stream() drops its read-ahead
    • tests: worker-terminate-lifetime "…into a FileSink" (EBUSY on few CPUs), cluster "RST-while-queued" (hangs 1 in 100), the lone-surrogate fs tests (leave their directory)

…on port

Bun no longer links libuv. On Windows the event loop is a uSockets backend
(packages/bun-usockets/src/eventing/iocp.c) built on a completion port:
sockets are polled through AFD, listening sockets accept with overlapped
AcceptEx, timers use a high-resolution waitable timer, and process exit,
events and console input arrive through wait completion packets.

Pipes, the console and files (src/io/windows) use overlapped I/O on the same
port. A pipe handle that cannot do overlapped I/O (an inherited synchronous
stdin) is read on a thread of its own. Process spawning (src/spawn_sys/windows)
calls CreateProcessW directly, with job objects and pseudoconsoles. node:fs,
fs.watch, dns, os, tty and signal handling call Win32/NT directly.

For N-API modules Windows now behaves like POSIX: the same small set of uv_*
functions is polyfilled and every other uv_* export aborts with a message.
Comment thread src/io/windows/tty_input.rs
Comment thread packages/bun-usockets/src/eventing/iocp.c Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline findings, I also checked whether 'inherit' at an extra stdio index (>2) on Windows now throws EBADF instead of spawning with that slot closed — make_slot's Inherit arm falls back correctly for indices past 2, so Node compat is preserved there.

Extended reasoning...

This run surfaced three new confirmed findings (Dup2 handle aliasing in spawn_sys/windows/mod.rs, the sync_write_thread spin on a PIPE_NOWAIT flip in io/windows/pipe.rs, and the missing DELETE access on the minidump handle in spawn_sys/windows/kill.rs), all distinct from the two inline comments posted on the previous push. While examining the Windows spawn stdio setup for the Dup2 issue, a separate candidate — that 'inherit' at extra stdio slots would now hard-fail with EBADF and break Node compatibility — was investigated and ruled out: the Inherit arm only requires a live parent fd for slots 0-2 and leaves higher slots closed as before. Noting that here so the author knows that adjacent path in the same file was checked and is not a concern.

Comment thread src/spawn_sys/windows/mod.rs Outdated
Comment thread src/io/windows/pipe.rs Outdated
Comment thread src/spawn_sys/windows/kill.rs
@robobun

robobun commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 1:53 PM PT - Oct 1st, 2026

❌ @dylan-conway, your commit 783350b has 2 failures in Build #122514 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42819

That installs a local version of the PR into your bun-42819 executable, so you can run:

bun-42819 --bun

Regressions against the base commit:
- A child's output that arrived before `proc.stdout`/`proc.stderr` was first
  read was held back until the child's next write or exit. The reader's
  "is its buffer lent to a read" question is now separate from "is a read
  outstanding".
- The reader thread of a synchronous stdin took input while the loop thread
  was blocked (a synchronous spawn that inherits stdin). It now reports that
  the pipe is readable and takes the bytes only when the loop asks again.
- The named-pipe server dropped a client that connected between
  CreateNamedPipeW and ConnectNamedPipe. Completions that Bun queues itself
  carry their result explicitly, in the pipe server and in pipes.
- An IPC channel kept delivering messages after disconnect() while a write
  was in flight.
- `fs.fsync` reported no error code for an errno outside the table on POSIX;
  sys::Error stores such a code as EUNKNOWN when it is constructed.
- A child given `2>&1` or two ignored stdio slots got one HANDLE in two fd
  slots; every slot has its own now.
- `cat; cat` on the shell's stdin hung on Windows; a finished reader can be
  started again.

Also: process.title no longer reads past its buffer for a console title
longer than it; listening sockets that cannot start AcceptEx are polled until a
connection waits instead of retrying in a loop; a descriptor handed to
listen() is not bound to the loop's completion port; the completion batch is
128 entries; a tick that finds packets waiting skips the park sequence; the
wait timer is reused by callers that pass no clock reading; extra stdio pipes
and the IPC channel are opened as the handles Bun knows them to be; console
line reads address their wake key to the reader that is in the call.

Tests for each, and expectations that pinned libuv's limits (AppContainer
native realpath, writes to paths past MAX_PATH) updated.
…items

make_crt_owned had no caller left outside make_crt_owned_for_syscall, so its
error type and the variants wrapping it go with it. ParentRef::from_nullable
lost its last user. The shell interpreter's teardown takes its box on purpose
(readers and writers point back at the interpreter where it is), so the
boxed_local lint is allowed there.
Comment thread packages/bun-usockets/src/eventing/iocp.c
Comment thread src/jsc/bindings/OsBinding.cpp

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open from earlier reviews (2):

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

Comment thread src/jsc/bindings/OsBinding.cpp
…view found

Event loop (Windows):
- A tick takes another batch of packets after an accept only when the accept
  it just started is over already. Taking one after every accept made a tick
  several batches long, which delayed everything that runs between ticks
  (pub/sub delivery, timers) under load.
- After a system resume the armed wait timer is not reused: a relative timer
  does not count suspended time.
- The garbage collector's before-wait hook runs on every tick that may block,
  also when packets were already waiting.
- A listener for which neither AcceptEx nor a poll could be started is tried
  again from every tick instead of staying deaf.
- The select() fallback (sockets behind a non-IFS layered service provider)
  submits nothing for a socket that waits for neither readable nor writable;
  select() fails at once when given no socket, which closed a paused socket
  that had shut down its write side with ECONNRESET. New fault rule
  `poll_slow` forces the fallback so it can be tested; the `poll_start` rule
  covers a listener's registration again.

IPC: disconnect() from a message handler stops later reads only. Messages the
same read brought in are delivered, as Node does.

Filesystem (Windows): a drive-relative name (`C:name`) is resolved instead of
being handed to NtCreateFile, where it names a stream of a file `C`. Every
path that goes to a kernel32 call is converted by one helper that also puts it
in the form past MAX_PATH; Bun.write(file, file) had been missed. Stats report
file times as Node does on Windows (seconds as a 32-bit unsigned value).
Linux statx and mkdtemp store an errno outside the table as EUNKNOWN.

node:os (Windows): os.cpus() reports an empty model when the registry has no
ProcessorNameString; os.networkInterfaces() clamps a prefix length of 255 to
the width of the address.

node:cluster: a failed bind that left no error code reports UNKNOWN, not 0.

Tests for each. The filesink "unref'd child" test spawns its reader detached:
on Windows a child that is not detached is killed when the parent exits, which
could be before it printed. Comments across the change state what the code
does now.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 optional suggestions (nits or notes on pre-existing code) were found and not posted.

One verified lower-impact observation (a convention, logging or cleanup point) was not posted.

Comment thread packages/bun-usockets/src/eventing/iocp.c Outdated
…mitted is retried

A listener that cannot start AcceptEx waits for a connection with a poll. When
that poll already existed and handing it back to the kernel failed, it was
left idle and unqueued, the listener was not marked for retry, and connections
stayed in the backlog for good. accept_slot_arm now replaces a poll that is
neither pending, queued nor with a helper thread, so the create path and its
retry accounting run in every case where nothing is armed.

Fault injection: the `socket` rule makes the socket AcceptEx accepts into
unavailable, and `poll_start` is checked wherever a poll is handed to the
kernel, not only the first time. The test drives a listener through a normal
accept, the poll fallback, a poll that cannot go back, and recovery.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new blocking issues. 2 optional suggestions (nits or notes on pre-existing code) were found and not posted. Nothing in this review needs a push before merging.

…n Windows only

Their hooks are in the Windows event loop backend. Elsewhere such a rule would
arm and never fire, which the control-surface test rejects for every rule
name that nothing checks.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new blocking issues. 1 optional suggestion (a nit or a note on pre-existing code) was found and not posted. Nothing in this review needs a push before merging.

dylan-conway and others added 4 commits September 15, 2026 20:35
…cept() drains the backlog

Loop
- A tick dispatches one batch of completion packets, so timers and the
  loop's post handlers wait for no more than that.
- An operation whose outcome is known at the call (a write that finished at
  once, a connect that succeeded synchronously, a held read) is completed
  from a per-loop list at the start of the next tick, before the port is
  polled, instead of through a posted packet. Queuing cannot fail, and a
  pipe client's connect is reported before the server's connection again.
- A listener takes AcceptEx's connection, then accept()s until the backlog
  is empty, then arms AcceptEx again. A connection that was accepted and
  never taken is reset with the rest of the backlog.
- The wakeup counter is not used on IOCP.
- The idle park is bounded at one second on every platform.
- WSAStartup is tried once.

Spawn
- Children inherit every inheritable handle, as on main and in Node. The
  handles made for one child are inheritable only while a process-wide lock
  keeps other threads from spawning, so two threads spawning at once do not
  share their children's pipe ends.

Files and paths
- Every Bun.file API and static routes open a path as it was written, so
  Win32 name rules apply (a device name is the device, trailing dots and
  spaces are dropped). node:fs is unchanged.
- Bun.file(fd) reads of a regular file are positioned on Windows and leave
  the descriptor's position alone.
- Path conversion refuses bytes that are not WTF-8: ENOENT, as libuv.
- readv reads into every buffer in turn; mkdtemp takes its characters from
  the OS; a size-limited read of a pipe stops at its limit.
- Errors keep the kernel's errno; an unknown one is named EUNKNOWN.

Pipes, console, IPC
- write() fails for a write the kernel refuses at once.
- setRawMode on a handle that is not console input is EINVAL.
- The console control handler is installed at startup.
- A shell builtin reading the console ends at a line that starts with
  Ctrl-Z.
- IPC delivers nothing after a message that cannot be decoded.

Shell
- The writer reports a failure once per queued chunk, so ls -R, rm -v and
  cp -v end when the reader of their pipe has gone.

Other
- dns: non-ASCII host names are IDNA-encoded before GetAddrInfoW.
- process.report prints interface addresses as os.networkInterfaces() does.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 optional suggestion (a nit or a note on pre-existing code) was found and not posted.

Comment thread test/js/node/fs/fs.test.ts
Comment thread src/runtime/shell/IOWriter.rs
Comment thread src/runtime/webcore/Blob.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

Still open from earlier reviews (1):

  • 🔴 src/runtime/webcore/Blob.rs:5535 — On Windows a Bun.file with an absolute path Win32 treats specially (trailing dot or space, NUL) can now be written and…

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

… stat and copy name files as its opens do

Fixes
- A pipe pair's child end is created non-inheritable and marked inheritable
  only by the spawn it is for, under the spawn lock. A pair made outside a
  spawn (the Chrome transport, the install security scanner) was inherited a
  second time by the child, so closing its descriptor gave the parent no EOF.
- Bun.file(path).delete(), .stat() and Bun.write(Bun.file(a), Bun.file(b))
  name the file by Win32's rules, as every other Bun.file call does.
- A Buffer path whose bytes are not WTF-8 names no file for writeFile too;
  the existing test for such paths pins that for every call.
- The pipe mode probe fails for an invalid handle instead of asserting.

Simplification (no behaviour change)
- usockets: us_loop_run and what only existed to reach it are gone; the
  accept slot is part of the acceptor; parameters only the libuv backend used
  are dropped; small duplicates merged.
- io: one BufferedReader for both platforms; Pipe::open takes a PipeOrigin;
  one parent trait for buffered writers; one ReadCallback type; helper-thread
  results travel in `posted`.
- sys/errno: the UV_* and errno tables are one table; read/pread and
  write/pwrite are one function each; dead open flags removed; the Windows
  iovec has the POSIX shape (a buffer of 4 GiB or more keeps its length).
- node/webcore: the async_ alias table, an ignored Binding parameter and the
  Syscall alias are gone; path_watcher cfg lists collapsed.
- spawn/shell/ipc: aliases and wrappers with one implementation removed;
  Windows-only IPC socket handlers gated; create_pty_windows straight-line.
- C++/build/tests: dead kill() shim, hand-declared SDK struct and a wrapper
  type removed; link libraries named on the link line.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 optional suggestion (a nit or a note on pre-existing code) was found and not posted.

One verified lower-impact observation (a convention, logging or cleanup point) was not posted.

Comment thread src/runtime/webcore/blob/copy_file.rs Outdated
This was referenced Oct 18, 2026
@robobun

robobun commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator

This note is for the follow-up that this thread leaves open. That follow-up is the TODO in do_read about a Bun.file(fd) read on Windows. The note asks for no change in this PR.

No PR changes ReadFileUV on main for this. Such a PR would conflict with this one and with the tests that pin the rule here. So the change waits for this PR.

What relies on each rule (main at 179664e)

  • Tests: I found none that tells the two rules apart. The direct reads of a regular-file descriptor are the six in test/js/bun/util/bun-file-fd-read.test.ts. Each uses a fresh descriptor at byte 0. Each one-shot read of Bun.stdin in test/ has a pipe or a file at byte 0.
  • Native code: Response.clone() assumes the POSIX rule on every OS. store_reads_repeatably and store_yields_bytes_once (Blob.rs:5755) treat each descriptor as a source that gives its bytes once, so the body is teed.
  • Built-in modules: none. src/js reads stdin only through Bun.stdin.stream().
  • Tracker: I found no issue that reports either behaviour.

Where the Windows rule shows: a file stdin that was read in part

A script for the follow-up, and related PRs
import { closeSync, openSync, readSync, writeFileSync, writeSync } from "node:fs";

writeFileSync("in.txt", "HEADER\nbody\n");
const fd = openSync("in.txt", "r");
readSync(fd, Buffer.alloc(7), 0, 7, null); // this process takes the first line

// 1. A child that inherits the descriptor as stdin.
const child = Bun.spawn({
  cmd: [process.execPath, "-e", "process.stdout.write(await Bun.stdin.text())"],
  stdin: fd,
  stdout: "pipe",
});
const stdin = await child.stdout.text();
await child.exited;
closeSync(fd);

// 2. The same read in one process, twice.
const fd2 = openSync("in.txt", "r");
readSync(fd2, Buffer.alloc(7), 0, 7, null);
const twice = [await Bun.file(fd2).text(), await Bun.file(fd2).text()];
closeSync(fd2);

// 3. A descriptor that was just written.
const fd3 = openSync("out.txt", "w+");
writeSync(fd3, "written");
const written = await Bun.file(fd3).text();
closeSync(fd3);

console.log(JSON.stringify({ stdin, twice, written }));
Platform Output
Linux x64, 1.4.3-canary.1 (367d939) {"stdin":"body\n","twice":["body\n",""],"written":""}
Windows, from the code, not run {"stdin":"HEADER\nbody\n","twice":["HEADER\nbody\n","HEADER\nbody\n"],"written":"written"}

I did not run the script on Windows. The twice field is the measurement in the thread above. The stdin field is the CI result above. The written field is from the code only. With the POSIX rule, all three fields move on Windows. A Blob with an offset above 0 and a path-backed Bun.file(path) are not part of this.

robobun added a commit that referenced this pull request Oct 26, 2026
A query to a UDP port with no listener gets an ICMP port unreachable.
Linux reports it on the connected socket as EPOLLERR with no readable or
writable bit. on_dns_poll passed that wakeup to ares_process_fd with no
fd, so c-ares never read the error, epoll reported it again on every
loop iteration, and the query ended with ETIMEOUT after its full timeout.

on_dns_poll now gives c-ares the socket as readable and writable when
the poll failed or hung up. This is the hunk that #42819 has there.

The tests get a dead nameserver from a port that a socket holds. That
socket is connected elsewhere, so the port refuses every other sender.
A port that is only closed again can become the source port of the
query socket, which then receives its own query.

Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
robobun added a commit that referenced this pull request Oct 28, 2026
A query to a UDP port with no listener gets an ICMP port unreachable.
Linux reports it on the connected socket as EPOLLERR with no readable or
writable bit. on_dns_poll passed that wakeup to ares_process_fd with no
fd, so c-ares never read the error, epoll reported it again on every
loop iteration, and the query ended with ETIMEOUT after its full timeout.

on_dns_poll now gives c-ares the socket as readable and writable when
the poll failed or hung up. This is the hunk that #42819 has there.

The tests get a dead nameserver from a port that a socket holds. That
socket is connected elsewhere, so the port refuses every other sender.
A port that is only closed again can become the source port of the
query socket, which then receives its own query.

Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
robobun added a commit that referenced this pull request Oct 28, 2026
A query to a UDP port with no listener gets an ICMP port unreachable.
Linux reports it on the connected socket as EPOLLERR with no readable or
writable bit. on_dns_poll passed that wakeup to ares_process_fd with no
fd, so c-ares never read the error, epoll reported it again on every
loop iteration, and the query ended with ETIMEOUT after its full timeout.

on_dns_poll now gives c-ares the socket as readable and writable when
the poll failed or hung up. This is the hunk that #42819 has there.

The tests get a dead nameserver from a port that a socket holds. That
socket is connected elsewhere, so the port refuses every other sender.
A port that is only closed again can become the source port of the
query socket, which then receives its own query.

Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants