Remove libuv on Windows
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.creplaceslibuv.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 overlappedAcceptExper 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.lookupeach, thenterminate(): 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
caton the same stdin:Bad file descriptor. - An IPC message over
i32::MAXbytes panicked (every platform, by reading). - An inherited overlapped pipe joined Bun's port, so a sibling's completions could arrive here.
SetFileCompletionNotificationModeson foreign handles could hang a sibling'sWriteFile.- A
uv_loop_twas 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/writevtotals over 4 GiB wrapped; a 4 GiB buffer's length was cut to 32 bits.Bun.file(p).lastModifiedbetween 2038 and 2106 was garbage.process.cpuUsagewrapped every 24 hours, in whole milliseconds; peak RSS was rounded through KiB.- Bun's own
isattywas true forNUL(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_timeoutignored its timeout; JSC's own timers fired only when something else woke the loop.- Shell
cathung on a read error with output queued; the writer could credit a dead command's bytes to the next. process.stdout.writewas garbled once another process restored the code page (console.logstill 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.connectto an inbound-only pipe server:ECONNREFUSED. A client that only writes now works, as Node.fs.promises.writeFile(pipe, data):EINVAL.- Async
fs.openandfs.statfsof a malformedBufferpath never settled. fs.statfscounted clusters in 32 bits andbavailignored the quota.
Workarounds deleted
us_loop_pump'sactive_handles++; uv_run(NOWAIT); active_handles--, writes touv_loop_t.active_handles, 12us_socket_ref/unrefcalls in uWS.- The
uv_timer_t+uv_idle_tpair that wokeuv_runfor Bun's timers, the "forever timer", the Windows-onlyus_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.Fdjuggling:make_libuv_owned, HANDLE→CRT-fd round trips,.uv()panicking on HANDLE fds (what remains iscrt(),FdKind::Crt).- The
open_handlesregistry keyed byuv_handle_t*and five libuv-only Worker teardown steps. - Shell:
is_writing/is_reading,NUL→uv_tty_init→EBADF→ "restart as file", fds set toINVALIDonce libuv had them. - A zero-byte
recvafter every connect and a secondrecvafter every readable event; re-arming the AFD poll before the callback. <uv.h>inZigGlobalObject.h:windows.h/winsock2.hin nearly every C++ TU.- Numbers in libuv's shape: Windows
E, a secondSystemErrnowith 61 aliases, and 86UV_*variants on both; errno ↔UV_E*as two 100-line tables; open flags going MSVC → Linux octal → MSVC; WSA codes going throughUV_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-1703CreateSymbolicLinkWretry and latch;statfs'sGetDiskFreeSpaceW+GetFullPathNameWladder; an unreachable\\.\branch;Exited.rawbeside a truncatedExited.code.- POSIX: poll flags set on readers' polls that only writers read, and a second
fcntlper 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-Sincedo not wrap in 2106;fs.Statskeeps Node's wrap.- fs errors: a path too long for any buffer:
ENAMETOOLONGfrommkdtemp,chmod,mkdir,rename,link,symlink(main, Node:ENOENT;EINVALfor a link's target).rmSync(".", {recursive})empties the directory, thenEBUSY(main:EINVAL, nothing touched).rmdirof a file open without delete sharing:ENOENT(main, Node:EBUSY). As Node now:writeFileSync(dir)isEISDIR,open, with the path; errors from files Bun opened itself carry nofd;chdir(file)isENOENT. Asyncreadv/writeverrors sayread/write. - fs paths:
\\?\C:/forward/slashesopens.realpath.nativeworks in an AppContainer. ABufferpath that is not UTF-8 names no file (ENOENT, as Node;mkdirrecursiveEPERM,rmrecursiveEINVALeven withforce,chownsucceeds), where main wrote U+FFFD. - fs files:
writeFilewith anaflag appends atomically across processes (to a pipe:EBADF, as Node). Created files honour the umask. NumericO_DIRECT/O_SYNCare honoured;O_WRONLY|O_RDWRisEINVAL.UV_FS_O_FILEMAPwithO_APPENDcannot be truncated.fs.readSyncrestarts an aborted read (main:ECANCELED; by reading). - fs, other codes: as Node now:
mkdirof a 256-character nameEINVAL;copyFilefrom\\host\share,fs.watch("dir/ ")ENOENT. Not as Node:statfsof a dangling linkENOENT(main, Node: succeeds);chmod("NUL")EISDIR,unlink("NUL")EINVAL(main: the reverse);writeFileSyncof\\host\shareENOENT(main:EISDIR). - Async
node:fson an fd runs on Bun's pool, which starts jobs in no order (but forfs.closeand 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:
renamefor ever) the path can be watched again; the watcher stays open untilclose(). 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")throwsENOENT. 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 reportschangeof 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
WSAStartupgivesWSANOTINITIALISED(libuv aborted). A listener that cannot startAcceptExis retried. One received from the cluster primary is not inherited by the worker's children. WithSCHED_NONEa connection is the worker's whoseAcceptExcompletes, busy or not (as Node). - Named pipes: a server whose instance cannot be made retries. A client that connected, wrote and closed before
ConnectNamedPipeis 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'seof_timeout; a later reply is lost, as in Node); withallowHalfOpena 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:httpclients andBun.connect/Bun.listenend()skip the 50 ms; theirshutdown()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'ssyscallisread, 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/--hotis parked; that park is bounded at 1 s, as on POSIX. - Console:
SIGWINCHcomes 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 aftersetRawMode(false). Two Ctrl+C in one tick are two events. Text typed beforepause()goes into the next line read (Node, main: dropped). Ctrl+] is not delivered in line mode. On a read-only console stdinsetRawMode(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.reportformats addresses asos.networkInterfaces(). - Spawn:
stdio: "overlapped"at 0–2 is an overlapped handle, as Node (was"pipe")."socket-fd"follows the POSIX contract.spawnSyncwithout pipe, timeout,maxBufferor 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.exitedcan settle beforeonDisconnect. A private loop that cannot be made is blamed onCreateIoCompletionPort. 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, UDPconnectreport a failed lookup by its own code (WSATRY_AGAIN:EAI_AGAIN; main: alwaysENOTFOUND; no test). Non-ASCII names are IDNA-encoded as byurl.domainToASCII(libuv:xn--MNCHEN-psa.de). Lookups in flight are not cancelled at teardown. - Error codes, as libuv 1.52.1:
ERROR_DIRECTORYENOENT(main:ENOTDIR),ERROR_NOACCESSEFAULT(EACCES),ERROR_BUFFER_OVERFLOWENAMETOOLONG(EFAULT),ERROR_BROKEN_PIPEEOF(EPIPE),ERROR_NO_DATAEAGAIN(EPIPE). - Bun.write / Bun.file: the parents
Bun.write(path, data)makes have the names Win32 then opens (main: literal, thenENOENT). A copy onto a directory or read-only file isEPERM, notENOENT; a directory as source isEISDIR(main:ENOENT),Bun.write(dir, "")too (EINVAL); an unreadable source is blamed on the source.Bun.write(readOnlyFile, "")fails inopenwithEPERM. - 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 unawaitedBun.write(Bun.stdout, 256 KiB or more)can be overtaken byconsole.log, as on POSIX (main: never). - Shell:
(echo a; external) | catpipes the external's output. Paths pastMAX_PATHwork in redirects,cat,ls,touch.rmandmvtake.and..(main: fail), off the directory's real path, ascat ../fdoes, and bash. - Loop teardown (Workers,
spawnSync): collects every operation; a thread that cannot be stopped frees what it has (main: 64 tries ofuv_loop_close, the rest leaked). - Symlinks from
bun installand archives:/in a target is stored as\.
Every platform
- Errors: an errno outside Bun's table keeps its number and gets
code: "EUNKNOWN"(main: nocode). - fs:
utimes/lutimes/futimes: an invalidDateleaves that time alone,"Infinity"means now (as Node; both threw).fsyncandunlinkretryEINTR. fs.close(fd)waits for the async operations given the fd before it; what comes meanwhile getsEBADF. Not on a pipe, console or device.fs.rmrecursive 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
pathorfd. POSIX: whenopensaysENOENTandmkdirfails, themkdirerror is reported; a truncate that fails withEPERMrejects with that.{mode}applies only to a fileBun.writeopens. Aslice(0, n)of what is not a regular file stops atn(POSIX main: up to2n − 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, printscat: x: <reason>, exits 1.yesyields 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 installgives bins0777 & ~umaskwith every linker.process.versions.uvis1.52.1(was1.51.1-dev/1.48.0).spawnSync: a deadline in the past ticks with a zero timeout. Linux waiter thread:SIGCHLDhasSA_RESTART.- Smaller: the "unsupported
uv_*" text drops "for POSIX systems"; macOS clamps a long__libcpp_verbose_abortmessage; 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-stdinpipe backpressure,spawnSyncoptimizations,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.nativeworks),23316-long-path-spawn*(the write succeeds),fs-path-length(malformedBufferpaths: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.txtlosesnode-netandtest-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
isWindowsand that this PR does not touch (284 today), onlyfetch.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.filecalls, 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.tsdid 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 whoseAcceptExsocket cannot be made. - The spawn lock serialises spawns, so a stalled
CreateProcessWstalls them all.PROC_THREAD_ATTRIBUTE_HANDLE_LISTwould 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
EPERMfrom the loop (Node:ENOTCONNat 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_exanduv_udp_open_exare 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.tsfails 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: acwdpastMAX_PATHisENOENT; 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.liblinked ahead ofws2_32.lib(setMulticastInterfacesets Don't-Fragment)process.chdirto 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 writtenwriter()is dropped "pipe"at stdio 3+ gives the child an overlapped end (Node: synchronous)fs.readorBun.writeon an overlapped pipe ends early (0 bytes, no error) when another operation on the fd completessocket.destroy()on a named pipe with an unread write that does not fit the pipe leaves it openprocess.exit(256)exits with 0;Bun.spawndoes not knowSIGBREAKbun test --paralleldrops results reported just beforeprocess.exit()Bun.listen,Bun.serve,net.Server#listenandBun.udpSocketresolve a host name on the JS thread (every platform)- POSIX:
Bun.writeof 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")hearse; builtincat(experimental flag) cannot read a regular file, and two in a pipeline panic - from a cwd reached through a link,
fs.writeFile("../x")andreaddir("..")take..off its real path,readFileandstatoff its spelling - no exit after
Bun.listen({unix: pipe}).stop(true)(accepted pipes stay open), nor afterstdin.on("data", f)anddestroy()in one tick - a path of 33,000 characters panics in
Bun.whichand spawn (bun <path>: 2,000; shellcp: 4,100) process.kill(pid, "SIGABRT" | "SIGFPE" | "SIGILL" | "SIGSEGV"):ERR_UNKNOWN_SIGNAL(Node:ENOSYS)- a Worker's
setRawModeafter the exit restored the console leaves it changed; a cancelledBun.stdin.stream()drops its read-ahead - tests:
worker-terminate-lifetime"…into a FileSink" (EBUSYon few CPUs),cluster"RST-while-queued" (hangs 1 in 100), the lone-surrogatefstests (leave their directory)