Remove libuv on Windows

Closed
DY
7d ago
+38936-36852

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.

BugTestOn main
Threads spawning at once inherit each other's pipe ends: no EOFspawn (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 everfs.watch (4)
fs.watch(dir) and fs.watch(dir, {recursive}) share one non-recursive handlefs.watch (2)
Relative paths past MAX_PATH, and mkdtempSync past 260 characters: ENOENTfs (15)13 fail
Bun.write(<absolute path of 260+ characters>, data): ENOENT23316-long-path-spawn*expected it
C:zz writes a file C with a stream zzfs (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 filesbunshell (5)4 fail
Bun.spawn(["./nope"], {stdout: Bun.file(p)}) leaves an empty filespawn (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.js1 run in 5
Shell cat file > out can finish before its queued writes: truncated output, a panicbunshell (3)2 fail
A second cat on a stdin at its end panics (POSIX: hangs); cat dir > file hangsbunshell (24)17 fail
ls -R, rm -v, cp -v, mkdir -v, touch hang when their reader goes with chunks queuedbunshell (3)7 runs of 11
process.title is capped at 1 KiB and cachedprocess (4)
process.report sharedObjects cuts or drops a path of MAX_PATH or moreprocess (4)3 fail
A UTF-8 sequence split between two console writes is not joinedterminal-platform-gaps
Work that completes after its Worker began teardown is invisible to itworker-late-completion (debug builds)4 of 28 fail
stdin.pause() freezes the loop while another process reads the same synchronous stdinprocess-stdintimes out
A destroyed net.connect that waited for a busy pipe still takes the next instancenode-net1 of 7 to 4 of 6
setRawMode(true) fails on a read-only console stdin (fixed while a line read is pending)process-stdinthrows
tty.ReadStream(fd).setRawMode() fails for every console fd but 0terminal-platform-gaps'error'
fs.watch reports 8.3 aliases after the watched directory is renamedfs.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 upbun-file3 fail
FileSink.flush() returns with the write still in flight (#42435)filesink1 in 4 to 8 in 11
A shell redirect target is still open when the command settlesbunshell (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 writingnode-net (11)9 fail
An unawaited fs.close(fd) overtakes earlier operations: EBADF, or a write into the next filefs (36)26 fail
As many reads of a silent pipe as the pool has threads stop all async jobsfs (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 unspecifiedspawn-env (18)10 fail
Bun.write(fd, Bun.file(path), {mode}): EINVAL for a pipe (macOS, FreeBSD), chmods the fd (Linux)bun-writePOSIX only
FAT32/exFAT: recursive rm spins for ever on an open file, cannot remove read-only entries; mv is EINVALfs (8), mv (4)10 fail
A console key record with repeat count 0 gives the key 65,535 timesterminal-platform-gaps (2)1 fails
fs.rmSync("", {recursive}) empties the cwd; readdirSync("") lists itfs
A recursive rm of \\.\C:\… is EFAULTfs
Module._stat() misses relative paths with . or .. and finds a directory at ""node-module-module
Shell mv cannot move a read-only filemv (2)
Shell echo and which exit with an errno when they cannot write (every platform); now 1bunshell (2)
A signal Windows cannot send does not end a child (ENOSYS; killSignal is ignored); now SIGKILL, as Nodespawn-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 unreadspawn
fs.watch of a directory that replaced a watched file hears nothing (Linux too)fs.watchtimes out
A handler that waits synchronously for the next connection waits for eversocket (2)times out
Terminating a Worker with a shell builtin's pipe write out hangsworker-terminate-lifetime (4)1 times out
Bun.write(path, data) or the last fs.write(fd) is missing when process.exit() followsfs (8)2 fail
A Node peer that sends a socket over IPC gets a NACK and the socket leakschild_process_ipc_handletimes out
Shell mv x dir\nul removes x (a name without a dot) and exits 0mv (5)1 fails
A writer to a synchronous pipe loses the rest of a write that came back shortfilesink
terminate() of a Worker whose connect waits for a busy named pipe never endsworkertimes 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

branchmain
fs.watch: reported of 5,000 files made while JS is busy, flat / recursive / 16 Workers5,000 / 5,000 / 5,0001 / 1 / 961
Shell cat big > out / cat < big | cat > out / prog | cat > out, 1 GiB (MiB/s)1,539 / 1,537 / 1,37720.4 / 24.9 / 14.476× / 62× / 95×
… 64 MiB1,348 / 1,667 / 474331 / 442 / 1824.1× / 3.8× / 2.6×
fs.watch: create 1,000 watchers (ms)704987.1×
Bun.file().writer(), awaited 64 B writes (/s)357,86551,9536.9×
32 slow dns.lookup at once (s, 1 run; Node: 20.2); a thread per CPU, here 24, libuv 44.020.25.0×
setTimeout(16 ms) overshoot p50 / p99 (ms)0.23 / 0.850.71 / 1.303.1× / 1.5×
200 named-pipe clients connecting at once (ms)27.179.02.9×
fs.watch: create → event p50 / p99, flat; recursive (ms)0.39 / 1.10; 0.39 / 1.120.82 / 2.23; 0.81 / 1.812.1× / 2.0×; 2.1× / 1.6×
Shell cat big to the caller's pipe, 64 MiB (MiB/s) †1,4415772.5×
Shell echo hi | cat (/s)10,8655,5571.96×
Bun.write(path, string) 1 KiB, 1 / 64 at once (files/s)5,620 / 5,6963,502 / 4,0041.60× / 1.42×
Bun.file().stream(), 256 MiB (MiB/s) †2,8271,4641.93×
Bun.file().text(), small files, 64 at once (files/s)178,83896,3551.86×
A child's stdout: 64 B / 64 KiB / 64 KiB via node:child_process (MiB/s)57.8 / 2,364 / 2,15132.5 / 2,132 / 1,9761.78× / 1.11× / 1.09×
fs.realpath.native, 64 at once; realpathSync.native † (/s)67,248; 20,16338,622; 12,4031.74×; 1.63×
setTimeout + clearTimeout pairs (/s)11.0 M6.7 M1.65×
Response(Bun.file), 1 MiB, 16 at once (req/s; p50 / p99 ms)2,845; 5.54 / 9.712,073; 7.58 / 12.01.37×; 1.37× / 1.24×
process.send round trips, 1 KiB JSON (/s)39,47430,0921.31×
Named-pipe ping-pong 64 B: Bun.listen / node:net (/s)182,359 / 155,125140,672 / 125,7271.30× / 1.23×
node:net TCP ping-pong, 1 / 32 connections; UDP (/s)60,148 / 61,762; 69,22847,009 / 49,623; 53,9821.28× / 1.24×; 1.28×
node:tls ping-pong, 1 / 32 connections (/s)55,917 / 57,43544,062 / 47,4721.27× / 1.21×
Shell echo line > new file / >> file (/s)2,840 / 4,4732,255 / 3,7121.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,1232,905 / 1,570 / 1,338 / 1,9611.25× / 1.29× / 1.13× / 1.08×
setTimeout(0..3 ms), half cleared (/s)3.76 M3.09 M1.22×
writeFileSync 1 KiB over one file / appendFileSync (/s) †7,980 / 5,0226,702 / 4,3451.19× / 1.16×
TLS handshakes, 16 at once (/s) †9448391.13×
RSS per idle TCP connection, both ends (KiB)3.734.161.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

branchmainWhy
Named pipe, one side end()s: connect to 'close' (ms; Node: 63)510.2libuv'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 / 2904.9 / 20On 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.780.53 / 0.00Same 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,652659,239 / 713,0760.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; 54148,662 / 61,217; 6450.83× / 0.92×; 0.84×as above
Creating 5,000 files under a recursive fs.watch (ms)9798820.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)