Repository navigation
Remove libuv on Windows - #42819
Remove libuv on Windows#42819dylan-conway wants to merge 104 commits into
Conversation
…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.
There was a problem hiding this comment.
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.
|
Updated 1:53 PM PT - Oct 1st, 2026
❌ @dylan-conway, your commit 783350b has 2 failures in
🧪 To try this PR locally: bunx bun-pr 42819That installs a local version of the PR into your 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.
There was a problem hiding this comment.
Still open from earlier reviews (2):
- 🔴
packages/bun-usockets/src/eventing/iocp.c:626—On Windows machines with a non-IFS Winsock LSP installed, pausing or shutting down a TCP socket now drives a busy retry… - 🔴
src/jsc/bindings/OsBinding.cpp:214—On Windows machines whose registry has no ProcessorNameString value (Wine, some ARM64 devices, minimal server/container…
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.
…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.
…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.
…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.
…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.
…one that failed; readvSync pipe test is Windows-only
There was a problem hiding this comment.
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.
|
This note is for the follow-up that this thread leaves open. That follow-up is the No PR changes What relies on each rule (main at 179664e)
Where the Windows rule shows: a file stdin that was read in part
A script for the follow-up, and related PRsimport { 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 }));
I did not run the script on Windows. The
|
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>
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>
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>
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
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.IOCTL_AFD_POLL(as wepoll, mio,uv_poll_t); one overlappedAcceptExper listener. Non-IFS LSP:select()on a thread, as libuv.RegisterWaitForSingleObject.src/io/windows/(see its README), overlapped on the port; a synchronous handle is read by a thread of its own.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'sDatabase#eachand@datadog/pprofnow end the process as on POSIX.uv_version*,uv_strerror,uv_err_nameare stubs too.bun.exeexports the 316 names POSIX does (main: 314; 9 libuv internals are gone, 11uv.hnames are new, as stubs). No platform's stubs have prototypes any more: the copied headers (butuv/errno.h), the tree-sitter generator andnapi_body.rs's name lists are gone, about 6,000 lines.napi_get_uv_event_loopandnode::GetCurrentEventLoop()return what POSIX's do.Bugs on main that this fixes
With a test (
xisx.test.ts, with the number of tests;worker-late-completionand Node's own are main's). "On main": a Windows build of the base commit; blank: all fail.process.stdout.columnsafter a resize is not Node's (60 for 120)fs.watch: deleting the watched directory emitsrenamefor everfs.watch(dir)andfs.watch(dir, {recursive})share one non-recursive handleMAX_PATH, andmkdtempSyncpast 260 characters:ENOENTBun.write(<absolute path of 260+ characters>, data):ENOENTC:zzwrites a fileCwith a streamzzstatfsandappendFileerrors carry\\?\C:\…2>&1/1>&2lose an external command's output when stdout/stderr are filesBun.spawn(["./nope"], {stdout: Bun.file(p)})leaves an empty fileBun.write(Bun.file(readOnlyFd), request)resolves (small) or rejects with a bare number (large)SCHED_NONE) can freeze a worker insideaccept()test-cluster-shared-leak.jscat file > outcan finish before its queued writes: truncated output, a paniccaton a stdin at its end panics (POSIX: hangs);cat dir > filehangsls -R,rm -v,cp -v,mkdir -v,touchhang when their reader goes with chunks queuedprocess.titleis capped at 1 KiB and cachedprocess.reportsharedObjectscuts or drops a path ofMAX_PATHor morestdin.pause()freezes the loop while another process reads the same synchronous stdinnet.connectthat waited for a busy pipe still takes the next instancesetRawMode(true)fails on a read-only console stdin (fixed while a line read is pending)tty.ReadStream(fd).setRawMode()fails for every console fd but 0'error'fs.watchreports 8.3 aliases after the watched directory is renamedBun.write,fs.write,fs.writev,FileHandle.write,writeFile(fd),appendFile(fd)Bun.write(Bun.stdout, "")rejects (#13477)Bun.stdin.text()(#41850) or rejectingBun.file()read (#39787) does not keep the process upFileSink.flush()returns with the write still in flight (#42435)socket.end()on a named pipe closes at once: the last reply is lost (#39723)test-net-pingpong.jsfs.close(fd)overtakes earlier operations:EBADF, or a write into the next filefs.rmSync("./dir", {recursive, force})removes nothing and succeeds; so any path with.or..exit 256is success, a crash reads 5){...process.env, PATH: x}: which spelling the child sees is unspecifiedBun.write(fd, Bun.file(path), {mode}):EINVALfor a pipe (macOS, FreeBSD), chmods the fd (Linux)rmspins for ever on an open file, cannot remove read-only entries;mvisEINVALfs.rmSync("", {recursive})empties the cwd;readdirSync("")lists itrmof\\.\C:\…isEFAULTModule._stat()misses relative paths with.or..and finds a directory at""mvcannot move a read-only fileechoandwhichexit with an errno when they cannot write (every platform); now 1ENOSYS;killSignalis ignored); nowSIGKILL, as NodePATHthat ends in a lone quote crashesBun.spawn(["name"], {env: {}})timeoutkill is lost if unreadfs.watchof a directory that replaced a watched file hears nothing (Linux too)Bun.write(path, data)or the lastfs.write(fd)is missing whenprocess.exit()followsmv x dir\nulremovesx(a name without a dot) and exits 0terminate()of a Worker whose connect waits for a busy named pipe never endsWithout a test (by construction, or by hand on both builds):
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.caton the same stdin:Bad file descriptor.i32::MAXbytes panicked (every platform, by reading).SetFileCompletionNotificationModeson foreign handles could hang a sibling'sWriteFile.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.isattywas true forNUL(JS saw false).tick_with_timeoutignored its timeout; JSC's own timers fired only when something else woke the loop.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.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.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.uv_timer_t+uv_idle_tpair that wokeuv_runfor Bun's timers, the "forever timer", the Windows-onlyus_timer_t.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).open_handlesregistry keyed byuv_handle_t*and five libuv-only Worker teardown steps.is_writing/is_reading,NUL→uv_tty_init→EBADF→ "restart as file", fds set toINVALIDonce libuv had them.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.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.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.fcntlper spawned pipe.Performance
Release builds (ThinLTO, no PGO) of
a9786b28899and maindc3660dea66, 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
fs.watch: reported of 5,000 files made while JS is busy, flat / recursive / 16 Workerscat big > out/cat < big | cat > out/prog | cat > out, 1 GiB (MiB/s)fs.watch: create 1,000 watchers (ms)Bun.file().writer(), awaited 64 B writes (/s)dns.lookupat once (s, 1 run; Node: 20.2); a thread per CPU, here 24, libuv 4setTimeout(16 ms)overshoot p50 / p99 (ms)fs.watch: create → event p50 / p99, flat; recursive (ms)cat bigto the caller's pipe, 64 MiB (MiB/s) †echo hi | cat(/s)Bun.write(path, string)1 KiB, 1 / 64 at once (files/s)Bun.file().stream(), 256 MiB (MiB/s) †Bun.file().text(), small files, 64 at once (files/s)node:child_process(MiB/s)fs.realpath.native, 64 at once;realpathSync.native† (/s)setTimeout+clearTimeoutpairs (/s)Response(Bun.file), 1 MiB, 16 at once (req/s; p50 / p99 ms)process.sendround trips, 1 KiB JSON (/s)Bun.listen/node:net(/s)node:netTCP ping-pong, 1 / 32 connections; UDP (/s)node:tlsping-pong, 1 / 32 connections (/s)echo line > new file/>> file(/s)bun a | bun b/ 16 MiB payloads / echo through a child /write+'drain'(MiB/s)setTimeout(0..3 ms), half cleared (/s)writeFileSync1 KiB over one file /appendFileSync(/s) †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.logto a pseudoconsole,Response(Bun.file)1 KiB,Bun.write(path, Blob),unlink,fs.promises.stat,mkdirchains, waking an idle loop, pipe ping-pong ×32,Bun.serveandnode:httpthroughput, a connection per request,fetch, anhttpsdownload, start-up,spawn, onedns.lookup, other synchronousfs, a 6,000-modulebun build.Slower
end()s: connect to'close'(ms; Node: 63)eof_timeout, as Node. Main closes at once and loses the last replyBun.write(path, string)1 KiB, p50 / p99 (µs)Bun.serve, 64 connections, p50 (ms): handler / static route †fs.writecallbacks /FileHandle.read, 64 at once (/s)fs.read/FileHandle.write(/s);createReadStream(MiB/s)fs.watch(ms)Behaviour changes
Besides the tables above.
Windows
lastModified,Last-Modified,If-Modified-Sincedo not wrap in 2106;fs.Statskeeps Node's wrap.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.\\?\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.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).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).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.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).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).ConnectNamedPipeis a connection (Node: dropped).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.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).--watch/--hotis parked; that park is bounded at 1 s, as on POSIX.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.tty_wrap.TTY(fd)of a non-console throws POSIX's text.process.reportformats addresses asos.networkInterfaces().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.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_DIRECTORYENOENT(main:ENOTDIR),ERROR_NOACCESSEFAULT(EACCES),ERROR_BUFFER_OVERFLOWENAMETOOLONG(EFAULT),ERROR_BROKEN_PIPEEOF(EPIPE),ERROR_NO_DATAEAGAIN(EPIPE).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.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).(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.spawnSync): collects every operation; a thread that cannot be stopped frees what it has (main: 64 tries ofuv_loop_close, the rest leaked).bun installand archives:/in a target is stored as\.Every platform
code: "EUNKNOWN"(main: nocode).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).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).ETIMEOUT).disconnect(), messages from a later read are dropped and their descriptor closed (POSIX: what the same read brought is delivered, as Node).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.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
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.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.node-net,filesink,process-stdin,run_command,bun-write.flaky-tests.txtlosesnode-netandtest-cluster-shared-leak.js.Verification
isWindowsand that this PR does not touch (284 today), onlyfetch.test.ts(below) passed on the base and failed here.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.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).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.select()for a non-IFS LSP, the thread-pool wait where ntdll has no wait packets (Wine), a listener whoseAcceptExsocket cannot be made.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.accept()returns a handle that is inheritable for a moment, outside the lock.EPERMfrom the loop (Node:ENOTCONNat the call); without write access too (main:EPIPE). Console reads ignore a reader's byte limit.uv_*names are libuv 1.51's:uv_tcp_keepalive_exanduv_udp_open_exare not stubbed.process.exit()waits for an owed write: 6 s measured (main: 0.1 s).fetch.test.tsfails 27 in a whole-file run, 17 on the base; all pass alone.Bun.spawn: acwdpastMAX_PATHisENOENT; a relative one is applied twice to.\namewsock32.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 cwdawait 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)Bun.writeof 64–256 KiB to a non-blocking pipe that fills sends its first 64 KiB twicemv d e; mkdir d,fs.watch("d")hearse; builtincat(experimental flag) cannot read a regular file, and two in a pipeline panicfs.writeFile("../x")andreaddir("..")take..off its real path,readFileandstatoff its spellingBun.listen({unix: pipe}).stop(true)(accepted pipes stay open), nor afterstdin.on("data", f)anddestroy()in one tickBun.whichand spawn (bun <path>: 2,000; shellcp: 4,100)process.kill(pid, "SIGABRT" | "SIGFPE" | "SIGILL" | "SIGSEGV"):ERR_UNKNOWN_SIGNAL(Node:ENOSYS)setRawModeafter the exit restored the console leaves it changed; a cancelledBun.stdin.stream()drops its read-aheadworker-terminate-lifetime"…into a FileSink" (EBUSYon few CPUs),cluster"RST-while-queued" (hangs 1 in 100), the lone-surrogatefstests (leave their directory)