Remove libuv on Windows - #42819
Remove libuv on Windows#42819dylan-conway wants to merge 74 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 2:34 PM PT - Sep 27th, 2026
✅ @dylan-conway, your commit 4a7a6627b4a4319e145c5c94748c280b52a9ad87 passed 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.
There was a problem hiding this comment.
Code review found no new issues
No new issues were found in this update; 4 findings from earlier reviews are still open above.
Still open from earlier reviews (4):
- 🔴
src/runtime/webcore/blob/copy_file.rs:169—macOS test lanes fail the new un-gated test at test/js/bun/io/bun-write.test.js:1872, and a macOS user copying a file t… - Also unresolved: 2 minor or pre-existing, 1 blocking on lines changed since (possibly already fixed).
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.
…gs, dead code
Blockers
- shell: `yes` no longer hangs at 32 KiB outside the JS event loop
- fs.close() waits for what was given the descriptor before it and runs
before what is given it afterwards, so a write cannot land in the next
file that gets the number
Regressions of this branch
- jobs that wait on something outside the process (dns.lookup, reads of a
pipe or console) get threads of their own
- fs.watch: a directory deleted between two requests is a rename, not EPERM
- Bun.write into a missing directory from a source spelled with `..`
- realpath.native and readlink give WTF-8 back for unpaired surrogates
- rmdir resolves `.` and `..` under \.\
- a refused connect on the select() fallback is an error
- DisconnectNamedPipe after end() ends the stream
- disconnect() from a message handler ends delivery on Windows
Bugs main has too
- rm of a relative path with a `.` or `..` component removed nothing
- exit codes above 255 were cut to their low byte
- the last spelling of an environment variable's name wins
- Bun.write(fd, file, { mode }) leaves the caller's descriptor alone
Removed
- parking with JSC heap access held on Windows, the fchmod archive dance,
the pre-1703 symlink retry, the statfs ladder, an unreachable device-path
branch, `Exited.raw`, dead code the mordant lint flags
Tests and CI
- the Windows-only Rust unit tests run under Miri
- suites that pass on Windows are no longer skipped there
- exact error codes, stale flaky entries and comments
…indows-2d63e8 # Conflicts: # src/install/PackageManager.rs # src/io/PipeReader.rs # src/jsc/PosixSignalHandle.rs # src/libuv_sys/lib.rs # src/runtime/api/bun/spawn/stdio.rs # src/runtime/api/bun/subprocess/Readable.rs # src/runtime/api/bun/subprocess/Writable.rs # src/runtime/cli/create_command.rs # src/runtime/cli/test/parallel/Worker.rs # src/runtime/cli/upgrade_command.rs # src/runtime/dns_jsc/dns.rs # src/runtime/ipc.rs # src/runtime/jsc_hooks.rs # src/runtime/node/node_fs.rs # src/runtime/node/node_fs_watcher.rs # src/runtime/node/path_watcher.rs # src/runtime/node/uv_signal_handle_windows.rs # src/runtime/node/win_watcher.rs # src/runtime/shell/IOReader.rs # src/runtime/shell/IOWriter.rs # src/runtime/shell/subproc.rs # src/runtime/socket/WindowsNamedPipe.rs # src/runtime/socket/WindowsNamedPipeContext.rs # src/runtime/timer/mod.rs # src/runtime/webcore/Blob.rs # src/runtime/webcore/blob/copy_file.rs # src/runtime/webcore/blob/read_file.rs # src/runtime/webcore/blob/write_file.rs # src/spawn/lib.rs # src/spawn/process.rs # src/spawn_sys/lib.rs # src/spawn_sys/posix_spawn.rs # src/spawn_sys/spawn_process.rs # src/sys/lib.rs # test/internal/source-lints/dead-code-escape-limits.json
There was a problem hiding this comment.
Findings marked 🟡 are optional suggestions and need no follow-up push.
Still open from earlier reviews (3):
- Unresolved: 2 minor or pre-existing, 1 blocking on lines changed since (possibly already fixed).
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.
There was a problem hiding this comment.
Code review found no new issues
No new issues were found in this update; 4 findings from earlier reviews are still open above.
Still open from earlier reviews (4):
- Unresolved: 3 minor or pre-existing, 1 blocking on lines changed since (possibly already fixed).
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.
… wait behind a blocked read - A Worker that ended while a write to a disk file was on the work pool leaked the FileSink, its buffer and the descriptor: the writer's ref on its parent is released by the write's callback, which does not run once the loop is torn down. The writer can now abandon a write that is out (the source keeps the bytes and never calls back), and FileSink releases that ref with the others that shutdown strands. The Windows exclusion in worker-terminate-lifetime.test.ts goes: that cell no longer leaks either. - fs.close(fd, cb) waited for every earlier job on the descriptor, so behind a read of a silent pipe it never ran. It waits on regular files only, whose jobs come back by themselves. - setRawMode(false) on a console that is not raw leaves its input mode alone instead of clearing QuickEdit, insert mode and mouse input. - clippy: two u32::from(u32). - bunshell.test.ts: the `yes > out.txt` test no longer has a timeout of its own.
…t code - bake-harness.ts forgives Node aborting in its own teardown when the client exits with 3 or 9. The 9 was STATUS_STACK_BUFFER_OVERRUN (0xC0000409) cut to eight bits; Windows exit codes are reported whole now, as in Node. - terminal-platform-gaps.test.ts: the new console mode test printed 81 characters, which the console of Windows Server 2019 wraps at column 80.
There was a problem hiding this comment.
Still open from earlier reviews (1):
- Unresolved: 1 blocking on lines changed since (possibly already fixed).
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.
… it is to stay half-open - After the zero-length message that says "no more writes", end() waited for the server to close, without limit. The message is a convention of go-winio and .NET. A server that does not know it (mpv's IPC server) waits for the client to close, so neither 'end' nor 'close' came and the socket kept the process alive, where main and Node close. The message is still sent; the pipe is then closed once nothing has arrived for 50 ms, like a byte-type pipe and like every pipe in Node. With `allowHalfOpen: true` (net.connect, Bun.connect) the server has as long as it likes. - Whether a pipe is message-type was decided by comparing NamedPipeType for equality. It is a bit field that also carries FILE_PIPE_REJECT_REMOTE_CLIENTS, which go-winio (the Docker engine's and Podman's pipes) and mpv set on every pipe, so none of them was recognised. The test fixture now sets that flag as well.
…en the reader says it is done File systems without POSIX delete and rename (FAT32, exFAT): - fs.rm with `recursive` spun for ever when a file below was open: a deleted file stays listed until its last handle closes. The three tree walks give up with ENOTEMPTY after 50 rescans of a directory, as Node reports at once. - One `delete_by_handle` for `unlink`, `rmdir` and `DeleteFileBun`. Removing a tree that holds something read-only failed with EPERM, and so did rmdir of a read-only directory (`ReOpenFile` refuses directories). - `move_opened_file_at` falls back to FileRenameInformation, so the shell's `mv` and everything else that goes through `renameat` work. A directory does not replace a file there. - harness: `tempVolume()` makes a throwaway volume with diskpart, for a process that is elevated (`canCreateVolumes()`). A file read through `bun_io::windows::File` was still open when its reader reported the end: after `await $`cat f`` or a finished, abandoned or cancelled `Bun.file().stream()`, renaming the directory failed with EPERM. A handle that was only read from is closed as soon as nothing is out on it, and the shell's reader gives a file it opened to the source instead of duplicating it. Simplifications: `write_string_to_file_fast` was `write_bytes_to_file_fast` plus `to_utf8()`; `Closer::close` took a `()`; `stop_reading` was `pause`; nobody read what the Windows reader's `on_read_chunk` returned.
Letting a close go ahead of a read that is blocked on a pipe left that read counted against the descriptor's number. It outlives the descriptor, so the next file to get the number found a job out on it, and its close, and every job after that, waited for ever. Jobs on anything but a regular file no longer enter FdJobs at all: they go straight to the pool, as on main. What the line is for, keeping a close behind the writes that came before it, is a matter of regular files, whose jobs come back by themselves. The lookup is 177 ns on Windows against 25 us for an awaited FileHandle.read. The tests let the blocked read end (the parent closes the pipe) instead of exiting under it: a VM that is torn down at exit, as on the ASAN lane, waits for the read. They also close the next file to be opened, which on POSIX gets the same number.
…constants nothing uses - On a file system without POSIX delete, the read-only attribute is cleared before the second try. When that fails too (a directory that is not empty), the attribute is put back. - mordant: FILE_ATTRIBUTE_ARCHIVE and ntstatus::INVALID_PARAMETER lost their last use when the two delete ladders became one.
The POSIX builtin cat cannot read a regular file: its reader polls the descriptor, and kqueue has no event for the end of one, so on macOS the commands never finished. What the tests look at, a directory that cannot be renamed while a file in it is open, is Windows' behaviour to begin with.
…as to wait To keep jobs on pipes out of a descriptor's line, every asynchronous fs call on a descriptor did an fstat on the JS thread first. On a network file system that can take as long as the server likes, and it held up the event loop. Every job takes its place in the line again, with no system call. When a close finds jobs out on its descriptor, a job of its own asks the pool what the descriptor is. If it is not a regular file, the close goes ahead. The jobs it went ahead of are no longer counted: each job remembers which stretch of the line it went out in, and one from an earlier stretch leaves nothing behind for the next file that gets the number. What is given a pipe's descriptor after a close now waits for the close, as on a regular file.
…ds the descriptor closed Jobs that were given a descriptor after its close waited in a line and went to the pool when the close was back, just as its callback ran. If the callback opened a file, that file got the number, and a write from the line landed in it: 11 to 24 % of the time with a busy pool. - While a close waits for the jobs that came before it, the descriptor is still open, so its number cannot be another file's yet. What is given the number in that time is given the descriptor that is being closed, and gets EBADF without going near it. - Once the close is the pool's, the number can be the next file's at any moment. Nothing is held up or turned away from then on, as on main. - So a line is a count and at most one close. There is no queue, and no close can end up behind another one with nobody asking what its descriptor is. - What the pool says of a descriptor's kind takes effect also when the script that asked for the close is gone. Under `bun test --isolate` the next file's calls on the number waited for the blocked read, every time.
…core count Off Windows a read of a silent pipe has one of the pool's threads, as on main. On a machine with two cores the pool has two, so with two reads out nothing else ran, the close included, and the test hung. The children get four.
…hrough the runtime Removed - the 42 Rust unit tests of the Windows console input, IPC framing and spawn modules, and what ran them: the Windows suite of scripts/rust-miri.ts and a cfg!(miri) branch in the UTF-8 to UTF-16 conversion - parameters that were there for those tests: search_path's `exists` and `cwd_first`, make_env_block's `compare_names` and `parent_value` - the socket fault injection points this branch added (`socket`, `poll_slow`, `wait_fallback`) and the five tests on them, which ran in no CI lane. The points main has stay: its tests use them. In their place, in the bun test suite - console key records, written into a real console's input queue and read back from process.stdin in raw mode, with and without VT input - the image a name with a directory runs: .com before .exe, the cwd option, rooted and drive-relative names, a directory is not an image - the environment block's order, and its hidden drive variables - the four ways an IPC frame can be broken, and a message in several frames cut at every offset - 255 descriptors reach a child, 256 are refused A key record with a repeat count of 0 gives the key once on a console without VT input; on main it gives it 65,535 times. Without a test now, as nothing in the runtime can bring them about: select() for a socket AFD cannot poll, the thread-pool wait where ntdll has no wait completion packets (Wine), and a listener whose AcceptEx socket cannot be made.
What this does
Bun no longer links libuv. Windows gets its own event loop, built directly on an I/O completion port, and every Windows code path that went through
uv_*now calls Win32/NT itself. The libuv dependency, the fork it was built from, and the two out-of-tree patches to itspoll.care gone from the build.On Linux, macOS and FreeBSD the event loop, uSockets, uWS, timers, spawn,
src/io, thebun_syssyscalls,Bun.serveand the streams code are the old POSIX arms. A few shared code paths did change there; they are listed under "Behaviour changes › Every platform".How Windows works now
packages/bun-usockets/src/eventing/iocp.c, replaceslibuv.c. Every pending operation is anOVERLAPPEDat the head of a struct with a completion callback. A tick takes one batch of up to 128 completion packets (GetQueuedCompletionStatusEx) and looks at the port only before it runs its first callback, so timers and the loop's post handlers wait for no more than one batch, and nothing a tick delivers can be an answer to something that tick did (what epoll and kqueue give by construction; a synchronous wait on a promise, which runs microtasks only between ticks, relies on it). A socket poll that was cancelled to widen its mask is resubmitted as soon as its packet is dequeued and the port is read once more behind it, still before any callback, so the wider poll reports one iteration after the change as it does under epoll. A tick entered from inside a callback starts its batch with what the outer tick had not dispatched yet. An operation whose outcome is known at the call (it finished or failed at once) is put on a per-loop list and completed in the next tick, as libuv's pending-request list did.IOCTL_AFD_POLLon\Device\Afd(the mechanism behind wepoll, mio and libuv's ownuv_poll_t). Polls are non-exclusive, so two loops can watch one socket. Sockets behind a non-IFS LSP fall back toselect()on a thread, as libuv did. Winsock is started on first use.AcceptEx, one outstanding per listener;accept()is never called on such a listener (see "Listening sockets" under "Behaviour changes"). A listener whose provider is not AFD (a non-IFS LSP) is polled andaccept()ed, as its sockets are polled withselect(). A socket whose file object already belongs to another process's port completes through an event.uv_timer_t, no idle handle, no "forever timer". The idle park of--watch/--hotis bounded at 1 s on every platform, as it is on POSIX on main (on Windows main woke there for every heap timer and a 4-minute timer).NtAssociateWaitCompletionPacket), withRegisterWaitForSingleObjectas the fallback.src/io/windows/): overlapped I/O on the same port. A pipe handle that cannot do overlapped I/O (an inherited synchronous stdin) is read by a thread of its own that waits with a zero-byte read;src/io/windows/README.mdrecords why nothing else works and a Windows 11 I/O-ring follow-up. A shell builtin that reads a console ends its input at a line that starts with Ctrl-Z, asReadFileon a console does.src/spawn_sys/windows/):CreateProcessW, job objects (as a process attribute), pseudoconsoles. A child inherits every inheritable handle of the process, exactly as on main and in Node (bInheritHandles = TRUE, no handle list; a child on a pseudoconsole inherits none, also as on main). A process-wide lock is held from the creation of a child's inheritable handles until they are closed, so a child spawned from another thread cannot pick them up. Nothing Bun itself creates is inheritable outside that lock (c-ares's sockets are, for as long as they live, as on main and in Node): a pipe pair made ahead of a spawn (the Chrome transport, the install security scanner) has a non-inheritable child end that the spawn duplicates.node:fs,fs.watch,dns,os,tty, signals: direct calls; async work runs on Bun's existing work pool instead of libuv's second thread pool. A job that waits on something outside the process (dns.lookup, a read of a pipe or a console) has a thread of a second, small pool, so it does not hold up the rest.fs.openSyncnumbers behave as before), but internal opens return HANDLE-kind fds and no longer consume CRT slots.N-API
Windows now matches POSIX: the same small set of
uv_*functions is polyfilled and every otheruv_*export aborts with a message naming the function.uv_version,uv_version_string,uv_strerroranduv_err_name, which were real libuv functions on Windows, are now stubs too. The surface was deliberately not extended beyond the POSIX model.src/symbols.def'suv_*block is the list insrc/symbols.txt.napi_get_uv_event_loopandnode::GetCurrentEventLoop()return what they return on POSIX.Bugs on main that this fixes
Each of these has a test in this PR. "fails on main" means it failed on a Windows build of this PR's base commit.
spawn.test.ts› "threads spawning at the same time do not share their children's pipes"process.stdout.columnsis not what Node reports (60 for 120)process-signal-windows.test.ts› "a console window's resize raises SIGWINCH in a process that does not read stdin"fs.watch: deleting the watched directory emitsrenameforeverfs.watch.test.ts› "deleting the watched directory (recursive: …)" (4)fs.watch(dir)thenfs.watch(dir, {recursive: true})shared one non-recursive handlefs.watch.test.ts› "a recursive and a non-recursive watcher on the same directory are independent"MAX_PATHfail withENOENTunlessLongPathsEnabled; so doesfs.mkdtempSyncwith a prefix that makes the path longer than 260 charactersfs.test.ts› "relative paths past MAX_PATH", "mkdtemp prefix length"Bun.write(<absolute path of 260 characters or more>, <non-empty string or bytes>)rejects withENOENT(an empty string,writer(),fs.writeFileSyncand file-to-file copies on the same path work)23316-long-path-spawn.test.ts,23316-long-path-spawn-shell.test.ts(they expected the failure)C:zz) writes a file namedCwith a streamzzfs.test.ts› "a drive-relative path names a file in that drive's directory"zzstatfsandappendFileerrors for an absolute path carry\\?\C:\…instead of the path that was passedfs.test.ts› "statfs, appendFile and chown errors carry the path that was passed"2>&1/1>&2lose all output when stdout/stderr are filesbunshell.test.ts› "2>&1 and 1>&2 when the shell writes to files"Bun.spawn(["./nope"], {stdout: Bun.file(p)})leaves a stray empty filespawn.test.ts› "a path to a program that does not exist throws ENOENT and creates no file"Bun.write(Bun.file(readOnlyFd), req)resolves for a smallRequestbody, rejects with a bare numeric code for a large onebun-write.test.js› "… written to a read-only file descriptor rejects with EBADF"Requestcases failSCHED_NONE, a handle passed to another process) can freeze a worker: every acceptor is told about the one connection, and Winsock's non-blockingaccept()is a readiness check followed by an unbounded wait, so a loser blocks its loop thread until the next connectiontest-cluster-shared-leak.js;cluster.test.ts› "SCHED_NONE: workers sharing a listening socket keep running…"cat file > outcan finish before its queued writes: truncated output andpanic: expected Node::Cmd … got Freebunshell.test.ts› "builtin cat writes every chunk it queued" (3 tests)caton a stdin the first one read to its end (cat; cat,cat && cat, …):panic: called Option::unwrap() on a None valueon Windows, a hang on POSIX.cat dir > filehangs on Windowsbunshell.test.ts› "builtin cat" › "after a cat that read the same stdin to its end" (8 cases), "with an input it cannot read"builtin catcases faills -R,rm -v,cp -v,mkdir -v,touch) hangs when the reader of its pipe goes away with several chunks queued: one failure was delivered per command, not per chunkbunshell.test.ts› "a builtin with several chunks queued ends when its pipe's reader has gone" (ls -R,rm -rv)process.titleis capped at 1 KiB and cached by libuvprocess.test.js› "process.title reads a console title of %d UTF-16 units, truncated to 8191"process.report.getReport().sharedObjectstruncates or drops a module path ofMAX_PATHor moreprocess.test.js› "process.report.getReport().sharedObjects has all of a %d-unit module path"terminal-platform-gaps.test.ts› "a UTF-8 sequence split between two byte writes is joined, and one cut short by a string is replaced"worker-late-completion.test.ts(debug/ASAN builds)process.stdin.pause()(or a close) meets a synchronous stdin that another process is blocked reading: the reader thread is queued behind that read, where it cannot be cancelled, and libuv spins on the loop thread until it isprocess-stdin.test.ts› "pause() returns while a child is blocked reading the same synchronous stdin"net.connectto a busy named pipe that is destroyed while it waits still takes the instance the server makes next, and drops it; the client that is still waiting then failsnode-net.test.ts› "a client waits for a busy named pipe, and a waiting client can be destroyed"process.stdin.setRawMode(true)fails (setRawMode failed with errno: 1) when stdin is a console input handle opened without write access. Fixed in part, and only while a line read is pending: see "Console stdin opened read-only"process-stdin.test.ts› "setRawMode() takes effect during a line read of a console stdin opened read-only"fs.watch: after the watched directory is renamed, a change made through 8.3 aliases is reported under the aliases (ARATHE~1\ANOTHE~1.TXT): the long name was looked up through the path the watch was started withfs.watch.test.ts› "fs.watch resolves 8.3 aliases below the watched directory after that directory is renamed"Bun.write()calls that are not awaited reach a file descriptor out of order (for (const ch of text) Bun.write(Bun.stdout, ch)prints scrambled text): every write was its own work-pool job. Fixed for a pipe and a terminal; a stdout that is redirected to a file still goes to the pool and is still unordered, as on main (#11117)bun-write.test.js› "Bun.write() calls that are not awaited keep their order" (pipe, terminal)Bun.write(Bun.stdout, "")rejects with the error of a truncation stdout refuses (#13477)bun-write.test.js› "Bun.write(Bun.stdout, "") resolves with 0"Bun.stdin.text()(#41850) and aBun.file()read that rejects (#39787) exit 0 with the promise unsettledbun-file.test.ts› "a pending Bun.file() read that rejects…", "a pending Bun.stdin.text() keeps the process alive until its %s ends"FileSink.flush()returns while the write is still in flight, so a read of the file right afterawait writer.flush()can miss it (#42435)filesink.test.ts› "flush() settles once the bytes written before it are in the file"bunshell.test.ts› "a redirect target is closed when the command that wrote it settles", "has closed the files it opened by the time the command is done" › "cat f > g"socket.end()on a named pipe closes the pipe at once, so the reply to the last write is lost and the other end's write fails; Node'stest-net-pingpong.jsfails on its pipe variant (#39723)node-net.test.ts› "a reply to the last write before end() on a named pipe is received";test-net-pingpong.js(no longer expected to fail on Windows)end()closes the pipe, so the server sees a broken pipe and whatever it writes next is lostnode-net.test.ts› "end() on a message-type named pipe (plain | reject-remote)" (5 each), "a zero-length message on a message-type named pipe is the server's end of writing"fs.close(fd)that is not waited for overtakes what was given the descriptor before it: an earlier write fails withEBADF, or lands in whichever file gets the number next (POSIX: the pool starts jobs in no order; Windows: about 2 %EBADF)fs.test.ts› "fs.close() that is not waited for" (31)fs.read(0, …),Bun.stdin.text()) as the pool has threads, and no other async job (fs.promises.readFile, zlib, pbkdf2) ever finishesfs.test.ts› "reads of a silent pipe do not hold up the work pool" (8)fs.rmSync("./dir", {recursive: true, force: true})removes nothing and reports success (withoutforce:ENOENT); so does any relative path with a.or..in it, sync and promisesfs.test.ts› "removes what a relative path with . or .. in it names"exit 256isexitCode: 0,success: true,bun runexits 0, a crash (0xC0000005) reads 5spawn.test.ts› "an exit code that does not fit in a byte" (10){...process.env, PATH: x}does not reach a native child: which spelling of a name it sees is unspecified (x13 times of 24)spawn-env.test.ts› "the last spelling of a name wins"Bun.write(fd, Bun.file(path), {mode}): macOS and FreeBSD reject withEINVALafter the copy whenfdis a pipe or a socket; Linux changes the mode of the caller's descriptorbun-write.test.js› "… leaves the mode of the caller's file alone"fs.rmspins at 100 % CPU for ever when a file below is open; a tree that holds a read-only file or directory (every file of.git/objects) cannot be removed, nor a read-only directory withfs.rmdir(EPERM); shellmv, and whatever else renames throughrenameat, isInvalid argumentfs.test.ts› "on FAT32 | exFAT, which has no POSIX delete" (8);mv.test.ts› "…, which has no POSIX rename" (4)terminal-platform-gaps.test.ts› "key records in raw mode" › "on a console without VT input"Fixed without a test, by construction, by hand repro on both builds, or measured only outside Bun:
Bun.spawnchild withstdin/stdout: "pipe"that nobody reads, then isterminate()d: use-after-free on main (debug build panics with a misaligned0xdfdf…pointer, 7 runs of 7). Here the Worker exits with code 1, 8 of 8.dns.lookupcalls in flight each, thenterminate(): main segfaults 3 runs of 3. Here 4 of 4 pass.Bun.file(missing).text()awaited outside top-level await: on main (and the last release) the process exits 0 before the rejection is delivered. It now rejects withENOENT.fs.readv(fd, [])/fileHandle.readv([]): a debug build of main panics, a release build never settles the promise. It resolves 0.worker.terminate()with a shell command running left the child process running on Windows. It is killed, as on POSIX.caton the same stdin failed withbun: Bad file descriptoron Windows (the reader had closed the shell's fd at EOF).i32::MAXbytes panicked (int cast) once its firsti32::MAXbytes were written, on every platform (by reading).SetFileCompletionNotificationModesis no longer applied to handles Bun did not create (it can hang a sibling'sWriteFile).uv_loop_t(a port plus an async handle) was created on every thread that touched the filesystem and never run.net.connect(pipe).destroy()before the connect completes leaked the handle and kept the process alive; the connect is cancelled now.Bun.file()reads over 4 GiB failed withENOMEM;readv/writevtotals over 4 GiB wrapped, and a single buffer of 4 GiB or more had its length cut to 32 bits (exactly 4 GiB read nothing).Bun.file(p).lastModifiedfor an mtime between 2038 and 2106 was garbage (4501513648874496for 2040).process.cpuUsagewrapped every 24 hours and was quantised to milliseconds; peak RSS was rounded through kilobytes.isattywas true forNUL.tick_with_timeoutignored its timeout on Windows, and JavaScriptCore's own timers (GC, sweeper) only fired when something else woke the loop.cathung forever on a read error with output still queued; the shell's Windows writer could credit a dead command's completed bytes to the next command."as the lastPATHentry underflowed a length in libuv'ssearch_path(not reachable throughBun.spawn, so not tested).process.stdout.writecame out garbled after another process on the console had put its code page back (console.logstill does).Bun.stdin.text()on an overlapped stdin rejected withEUNKNOWN.fs.statfscounted clusters in 32 bits andbavailignored the caller's quota (now libuv 1.52's version).Workarounds deleted
52 in total; the ones a reader of the code will notice:
us_loop_pump'sactive_handles++; uv_run(NOWAIT); active_handles--, direct writes touv_loop_t.active_handlesfrom C and Rust, and 12us_socket_ref/unrefcalls in uWS that did nothing on any other platform.uv_timer_t+uv_idle_tpair whose only job was to wakeuv_runfor Bun's own timer heap, the "forever timer", and the Windows-onlyus_timer_tAPI.sys_uv,FsReq/UvFsReq, and the Windows-only state machines that duplicated POSIX ones (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-kind fds. What remains is renamed to say what it is (crt(),FdKind::Crt).open_handlesregistry keyed byuv_handle_t*and five libuv-only Worker teardown steps.is_writing/is_readingflags, theNUL→uv_tty_init→EBADF→ "restart as file" fallback, fds set toINVALIDafter libuv took ownership.recvafter every readable event and after every connect; re-arming the AFD poll before the callback (exactly two completions per message).<uv.h>inZigGlobalObject.h, which pulledwindows.h/winsock2.hand libuv's macros into nearly every C++ TU.cfg!(windows)arm of the GC controller is gone.fchmod's set-ARCHIVE, toggle, clear-ARCHIVE (oneNtSetInformationFile); theCreateSymbolicLinkWretry for Windows before 1703 and its process-wide latch;statfs'sGetDiskFreeSpaceW+GetFullPathNameWladder; an unreachable\\.\branch that calledCreateFileWwith NT constants;Exited.rawnext to a truncatedExited.code.Performance
Release builds (ThinLTO, what CI ships without PGO) of this branch and of main at the merge base, same machine (Windows 11, Ryzen AI 9 HX 370: 4 Zen 5 + 8 Zen 5c cores, 24 logical CPUs), nothing else running, runs alternated between the two binaries, median of 3–5 runs per binary. Ratio is this branch / main; above 1 is better for the branch. 67 benchmark cases, about 900 metrics; the tables list what is outside ±7 %.
Faster
cat big > out/cat < big | cat > out/prog | cat > out, 1 GiB (MiB/s)fs.watch: files reported of 5,000 created while the loop is busy, flat / recursivefs.watch: create 1,000 watchers (ms)Bun.write(path, string)1 KiB: create / overwrite, 64 concurrent / overwrite one at a time (files/s)fs.watch: create → event latency p50, flat / recursive (ms, one run)Bun.file().text(), small files, 64 concurrent (files/s)process.sendround trips, 1 KiB JSON (per s)setTimeout+clearTimeoutpairs (ops/s)fs.open+read+closecallbacks, 64 concurrent (ops/s)fs.realpath.native, 64 concurrent (ops/s)'data', 1 GiB (MiB/s)node:netnamed pipe ping-pong 64 B, 1 / 32 connections (round trips/s)echo hi | catin the shell (commands/s)httpsfetchGET, 1 at a time / 64 at a time (req/s)node:netTCP ping-pong 64 B, 1 / 32 connections (round trips/s)setTimeout(0..3 ms), half cleared (ops/s)fs.promises.mkdir({recursive})chains of 8, 64 concurrent (dirs/s)node:tlsping-pong 64 B, 1 / 32 connections (round trips/s)fetch, 64 concurrent, against another process (req/s)httpsfetchstreamed download, 512 MiB (MiB/s)node:httphello (req/s) / p99 (ms)Bun.servehello: keep-alive p99 / a connection per request p99 (ms)setTimeout(16 ms)overshoot p50 / p99 (ms)bun -e 0/bun --version(ms)UDP echo, WebSocket echo and
Bun.listenTCP/TLS/named-pipe ping-pong were 1.2–1.8× in a single-run pass and were not re-measured.Bun.servehello throughput, 1 MB responses,spawnrates,fswalks,rmSync,readv/writev,setImmediatechains, Worker start and RSS are within ±7 %.Slower
process.stdin'data'/Bun.stdin.stream(), 1 GiB, pipe fromBun.spawn(MiB/s)a | b)node:netover a named pipe, one sideend()s and the other waits for'end': time from connect to'close'(ms; Node 25: 63)bun a | bun bin the shell;child.stdin.write+'drain'; 1 GiB echoed through a child; 16 MiB stdin payloads (MiB/s)echo line > file, the same file truncated every time (commands/s, two runs)process.stdin.setRawMode(true)+(false)under a pseudoconsole (pairs/s)Response(Bun.file)of a 1 KiB file, 16 at a time / 1 at a time (req/s)Bun.file().writer(), 100,000 awaited 64 B writes (writes/s)Bun.write(path, Blob)overwrite /Bun.file().unlink(), 64 concurrent (files/s)fs.promises.stat, 64 at a time (calls/s)console.log/process.stdout.writeto a pseudoconsole (lines/s)postMessagemain → Worker p50 (µs)Why:
pause()(this one still can, see "Known gaps"). Windows 11's I/O rings do asynchronous I/O on such a handle without a thread (measured 6,700 MiB/s against libuv's 4,984 in the same harness,src/io/windows/README.md); that is the follow-up.> fileover and over. Closing a file that was written to is slow on Windows (metadata, filter drivers such as an antivirus scan), and the next truncating open of the same file waits behind it. Here the redirect target is closed before the command settles; main closes it after the command has settled, so its next command does not wait for that close. Appending (>>: 3,456–3,461 commands/s against 3,208–3,468) is level, and redirecting to a different file each time (2,299–2,572 against 1,444–1,959) is faster here.fsrows above are faster. With far more pool threads than CPUs (a CPU-limited container that sees every host core) the small-file response row drops to 0.5×; with the pool sized for the CPUs it is 0.78–0.85×.setRawMode. Three console round trips per call (mode, input-handle check, set) against libuv's one; about 20 µs each under a pseudoconsole.Also measured (one run each),
fs.watchunder a burst from inside the process, 16 threads creating 5,000 files in the watched tree: all 5,000 are reported here for a flat directory and 2,439 for a recursive watch; main reports 5,000 for both, with creating the files about 4× slower there. The system keeps 4 KiB of changes per directory handle on both builds. With the same external process creating 10,000 files, 9,610–10,000 are reported here and 4,711–6,986 on main.Behaviour changes
Windows
uv_version,uv_version_string,uv_strerror,uv_err_nameabort as they do on POSIX.napi_get_uv_event_loopno longer returns auv_loop_t*.Bun.fileAPI (text(),exists(),stream(),writer(),Bun.write, copies,fetchbodies) and static routes open the path as it was written, so Win32's name rules apply: a device name (NUL,CON) is the device, trailing dots and spaces are dropped. (node:fs, here as in Node, names an absolute path literally: Node v25 createsa.and a regular file calledNUL.) On mainBun.write(path, string)already followed Win32's rules;Bun.file(p).writer()andBun.write(p, stream)named the file literally (a5.was created). TheBun.fileoperations that go throughnode:fsinternally (delete(),stat(), the truncate of an empty or sliced write, aBun.fileinside aBlob) are told the path is aBun.file's.node:fsitself is unchanged. A check of the name made in front ofBun.file(join(root, urlPath)), such as a list of extensions not to serve, is passed by a trailing dot or space:/secret.env.is served (main: 404).lastModified:fs.Statstimes on Windows keep Node's wrap of the seconds through an unsigned 32-bit value.Bun.file(p).lastModified,Last-ModifiedandIf-Modified-Sincedo not wrap, so from 2106 they differ fromfs.statSync(p).mtimeMs.fs.mkdtempwith a prefix too long for any path givesENAMETOOLONG(main and Node:ENOENT).fs.utimeskeeps libuv's double arithmetic, which Node'stest-fs-utimes-y2K38.jspins.fs.writeFileSync(dir)reportsEISDIRwithsyscall: "open"and the path (main:syscall: "write", no path; now as Node). Errors from files Bun opened itself (fs.readFileSync(dir),appendFileSync(dir)) no longer carryfd(as Node).process.chdir(file)givesENOENT(main:ENOTDIR; now as Node).\\?\C:/with/forward/slashesopens (main:ENOENT).fs.realpath.nativeworks inside an AppContainer (main and Node:EPERM). ABufferpath that is not valid UTF-8 names no file (ENOENT, measured identical to Node v25 forwriteFile,open,mkdir,stat,existsandpromises.writeFile) for every call butmkdirwithrecursive(EPERM) andrmwithrecursive(EINVAL); on mainfs.writeFileSyncand the other calls that open throughopenatreplaced each bad byte with U+FFFD. Asyncfs.readv/writeverrors saysyscall: "read"/"write"(main:readv/writev).renameand nothing after it; the watcher stays open untilclose()(main and Node repeatrenamefor as long as the watcher lives). A directory made again under the same path can be watched again while the first watcher is still open. Changes are read by a thread of their own, so a burst made while the JS thread is busy is drained as it happens; one that still outruns the 4 KiB request buffer is reported as lost. Lost changes are reported as('change', null), as Node does. File watches report the real path's basename; relative symlink targets resolve against the link.fs.watch("NUL")throwsENOENT(main: succeeds and never fires). A changed name that can be an 8.3 alias is resolved in its own directory, reached by handle from the watched one (NtQueryDirectoryFile, each directory handle asked about one name only), not withGetLongPathNameWon the original path.SO_ERROR(ECONNREFUSED,ETIMEDOUT, …) instead of what a zero-byterecvcould see.destroy()during a named-pipe connect cancels it. A socket sent by a Node peer over IPC is accepted. Pipe reads arrive one chunk per loop turn, and the first chunk of a burst of small writes is the first write alone (libuv read until the pipe was empty inside one iteration): a line that a child prints with two writes arrives as two chunks 19 times of 20 (main and Node: one), sochunk.includes("listening")can miss it.pause()on a pipe Bun created does not cancel the pending read: up to 64 KiB already read is held untilresume(), and a peer's write that this read took succeeds (on main it pended and failed withEPIPEwhen the reader closed). The pending read takes the writer'sWriteQuotaAvailableto 0, which main's zero-byte read did not: a writer in non-blocking mode seesEAGAINmore often.error.syscallof a stream read error isread(main:recv; a failed read start saidopen).--watch/--hotis parked with nothing alive (as on POSIX; on main the forever timer'suv_runran them).SIGWINCHcomes from the console's layout WinEvent (a hook thread that exists only while something listens) and from the resize records a raw-mode stdin reads. Under a pseudoconsole (Windows Terminal, VS Code,Bun.Terminal) no WinEvents exist, so a process that is not reading stdin in raw mode hears no resize, as in Node and on main.process.stdout.columnsis the screen buffer's width, as in Node. An unhandled Ctrl+Break or console close restores the console modes, as an unhandled Ctrl+C does. A handled Ctrl+C leaves them alone aftersetRawMode(false)(main reset them under a liveSIGINTlistener). CPU times are not quantised. Theprocess.titlegetter asks the console on every read. Two Ctrl+C presses inside one slow tick are two'SIGINT'events (libuv coalesced them).new (process.binding('tty_wrap').TTY)(fd)on a non-console throwsfd<N> is not a tty, the POSIX text.process.reportformats interface addresses with the formatteros.networkInterfaces()uses.stdio: "overlapped"at 0–2 gives the child an overlapped handle, as in Node (it was an alias of"pipe")."socket-fd"follows the POSIX contract (the caller's socket owns the handle).env: {FOO, foo}: the last spelling is kept, as the last assignment to a variable is (libuv kept both, inqsort's unspecified order).exitCodecan be above 255, as in Node.Bun.spawnSyncwith no pipe, timeout,maxBufferor signal blocks in a wait instead of running a loop, as on POSIX. Internal blocking spawns (bun upgrade,bun create,bun pm version,bun test --changed,Bun.openInEditor) pass the live environment rather than the snapshot taken at startup, so aprocess.env.X = …reaches those children. WithipcandonDisconnect,exitedcan settle beforeonDisconnect.process.stdin.pause()is carried into the next line read (Node and main drop it).GetAddrInfoWon Bun's threads for jobs that wait. A host name that is not ASCII is IDNA-encoded first with the encoderurl.domainToASCIIuses (libuv punycoded each label without case mapping:MÜNCHEN.debecamexn--MNCHEN-psa.de). No synchronousEINVALfor names over 256 bytes; lookups in flight are not cancelled at teardown.WSAStartupis tried once and later calls reportWSANOTINITIALISED(libuv aborted the process).ERROR_NOACCESSisEFAULT(main:EACCES),ERROR_BUFFER_OVERFLOWENAMETOOLONG(EFAULT),ERROR_BROKEN_PIPEEOF(EPIPE),ERROR_NO_DATAEAGAIN(EPIPE).writeFile/writeFileSync/fs.promises.writeFilewith anaflag append atomically across processes (the open is append-only, asappendFileand Node do it; on main concurrent writers lost lines). To a named pipe such a write isEBADF, as in Node. Files these calls create honourprocess.umask()(the read-only attribute; they were always writable). NumericO_DIRECT/O_SYNCare honoured andO_WRONLY|O_RDWRisEINVALon those paths.process.umask()reports the value Bun last set. One translation of open flags servesopenandopenat. A descriptor opened withUV_FS_O_FILEMAPandO_APPENDcannot be truncated.fs.readSyncstarts an aborted read again (main:ECANCELED; by reading).dir.\f.txtwrites intodir). A copy onto a directory or a read-only file isEPERM, notENOENT; a directory as the copy source rejects like every other copy path; an unreadable source is blamed on the source. The destination is opened withFILE_FLAG_SEQUENTIAL_SCANagain.console.logwrites the same way); a descriptor only when its handle is synchronous. Any other pipe, a console opened by name, a serial port or a handle opened for overlapped I/O still goes to the work pool: a write there can wait on whatever reads the other end, which may be this thread. The open, the write and the close are on the calling thread as well, so a share that cannot be reached holds up the event loop for as long as the open takes (21 s measured; main: 1 ms).Bun.write(readOnlyFile, "")fails inopenwithEPERM(main: intruncate).FileSink.flush()returns a promise while a write is in flight and settles when it has been written (main:0at once).Bun.write(fd, …), which asks whether the handle is synchronous. Something that is not a pipe fails at open. A parent that pausesprocess.stdinfrom its'data'handler leaves every unread byte in the pipe for a child that inherits it (from anywhere else, see "Known gaps"). A named-pipe server whose instance cannot be created or connected keeps listening and retries from the loop without spinning; nothing is reported, as in Node.AcceptEx;accept()is never called on a listener (it blocks the thread that loses a race on a shared listener). A listener that cannot startAcceptExis retried from the loop; the waiting connections are taken once it can. A listening socket received from the cluster primary is not inherited by processes the worker spawns.CreatePseudoConsolemakes (a terminal'sexitno longer waits for an unrelated child of another thread). A process whose job cannot nest and allows no silent breakaway stops asking for the kill-on-close job.(echo a; external) | catsends the external command's output into the pipe (1.4.0: to the terminal). Paths pastMAX_PATHwork as redirect targets and incat,ls,touch.spawnSyncloop): freeing a loop first closes what is still open on it (pipes, consoles, files, pipe connects, process exit waits), then collects every operation it counts, however many there are. Nothing the loop owns waits for a thread that cannot be stopped (aselect()for a socket AFD cannot poll, a blocking read or write on a synchronous pipe that another process is ahead of): that thread frees what it has. On mainuv_loop_closewas retried 64 times and whatever was left stayed allocated. A Worker that has a work-pool read blocked on a pipe or a console (Bun.stdin.text(),fs.read(0, …)) still cannot finish terminating until that read returns, as on main.pause(),read_stopand close never wait for the reader thread. One cancel is tried; a read queued behind another process's read of the same pipe stays parked in a zero-byte read, which takes nothing, until the pipe next has something. A write to such a pipe owns its bytes (the streaming writer lends its buffer and takes it back, nothing is copied) and goes through a duplicate handle, so closing the pipe never waits behind it.FSCTL_PIPE_WAITon the loop (no thread inWaitNamedPipeW);destroy()cancels the wait at once. The 30 s limit is unchanged.end():socket.end()no longer closes the pipe at once. It first waits, on the loop, until the other end has read what was written (an overlappedFSCTL_PIPE_FLUSH;destroy()cancels it at once, where Node waits for the other end to read). Then, on a message-type pipe, it sends a zero-length message, which is how go-winio (the Docker and Podman pipes), WCF and .NET streams say "no more writes", and keeps reading. The message is a convention, and a server that does not know it (mpv's) waits for the client to close, so by default the pipe is then closed like a byte-type one, once nothing has arrived for 50 ms: a server that answers when it is told is heard (in Node its reply is lost), a slower one is cut off as in Node. WithallowHalfOpen: true(net.connect,Bun.connect) the server has as long as it likes; Node cannot do this. On a byte-type pipe (every pipe Node or Bun creates) Windows has nothing that tells the other end, and nothing that says whether it will write again (npfs.syshas no shutdown control code; its event facility reports readable, writable and closed), so Bun does what libuv does: it keeps reading until nothing has arrived for 50 ms (eof_timeout), then reports'end'and closes. A reply later than that is lost, as in Node. A zero-length message received on a message-type pipe is the other end's'end'; withallowHalfOpenthe socket stays writable. Bun's own pipe servers stay byte-type: a Node client'ssocket.write("")reaches a message-type server as a zero-length message.end(), what it costs: the other end of a byte-type pipe sees the end of the stream about 50 ms later than on main (as in Node). A client that reads until'end'to know a response is complete pays that once per connection. Thenode:httpclient does not (it destroys its socket), andBun.connect/Bun.listensocket.end()still closes at once; theirsocket.shutdown()takes the new path.node:fson a file descriptor:fs.read,fs.write,fs.readv,fs.writev,fs.open,fs.closeandfs.statfsrun on Bun's work pool, as on POSIX, not on libuv's. It starts jobs in no particular order, so operations that are not awaited have none among themselves (twenty such writes keep their order 0 times of 20 here, on main and in Node).fs.closeis the exception: see "Every platform".ConnectNamedPipeis reported as a connection, with its data and then'end', as a Unix socket server would (libuv and Node drop it).SCHED_NONE: a connection on a shared listener belongs to the worker whoseAcceptExthe kernel completes, also when that worker is busy and another is idle (as in Node on Windows; on main the first worker to callaccept()took it).Bun.spawnSync: checks the remaining stack before it spawns, as on POSIX (main skipped the check on Windows). The error for a private loop that cannot be created namesCreateIoCompletionPort(main:uv_loop_init).bun installand archive extraction:/in a link target is stored as\, so a relative target with forward slashes resolves (fs.symlinkalready did this).CreateFileW("CONIN$", GENERIC_READ), cmd.exe's< CON): with a line read pending,setRawMode(true)reports success and keys arrive one at a time, without Enter (the key that wakes the read is typed throughCONIN$when the handle refuses it). The console's own mode does not change, which needs write access too: Ctrl+C is still a signal, and there are no VT input sequences and no resize records. With no read pending it fails with errno 1. On main it always does.catis a default builtin on Windows.Every platform
err.errnoand getscode: "EUNKNOWN"and the messageEUNKNOWN: unknown error, <syscall>(main: nocode,Unknown Error, <syscall>).fs.utimes/lutimes/futimeswith an invalidDateleave that time unchanged, and the strings"Infinity"/"-Infinity"mean now; both threwERR_INVALID_ARG_TYPE. This matches Node.fs.fsyncandfs.unlinkon POSIX retryEINTRlike the rest ofnode_fs(their Windows and POSIX arms were merged).fs.close(fd): waits for the async operations that were given the descriptor before it. What is given the descriptor while it waits getsEBADFand does not touch it. On a pipe, a console or a device, where an operation can last as long as the other end likes, it does not wait.write(2)or while waiting to write carriespath(orfd). WhenopensaysENOENTand creating the parent directory fails, the rejection is themkdirerror with the file's path (main: theopenerror). On POSIXBun.write(path, "")whose truncate fails withEPERMrejects with that (main went on tomkdirandopen).{mode}applies only to a fileBun.writeitself opens. A size-limited read of a pipe (Bun.stdin.slice(0, n)) that takes several reads stops atn; on POSIX main could return and consume up to2n − 1bytes.dns.lookupruns on threads kept for jobs that wait, not on the work pool's.disconnect(), messages that arrive in a later read are dropped and a descriptor sent with them is closed (main delivered them until the deferred close ran). On POSIX what the same read brought in is still delivered, as in Node.catcarries on with the next input after one it cannot read, printscat: x: <reason>and exits 1 (main: stopped at the first, or exited silently with the errno as its code). A secondcaton a stdin already at its end sees EOF. Builtins that queue several output chunks end when the reader of their pipe goes away. The shell writer keeps one buffer per chunk instead of one shared buffer.Bun.spawnSync: a deadline already in the past ticks with a zero timeout, not a negative one.process.versions.uvis1.52.1(Windows reported1.51.1-dev, POSIX1.48.0).uv_*" crash no longer says "for POSIX systems"; on macOS a longstd::__libcpp_verbose_abortmessage is clamped to its 1024-byte buffer; when fd 0 is closed at startup the shell's placeholder stdin is opened read-write; the window in whichfs.watchdrops a repeated event is measured on the monotonic clock.process.umask(): reads a value Bun keeps (read once at startup, updated byprocess.umask(mask)), so the getter no longer sets the process umask to 0 for an instant. A umask changed behind Bun's back (an addon or FFI callingumask(2)) is not seen.bun installgives bin targets0777 & ~umaskwith every linker (main's isolated linker used0777).fs.watchon Linux and FreeBSD: a watch whose root was deleted is dropped from the table of shared watches, so a laterfs.watch()of the same path gets a working watch (on main it attached to the dead one and heard nothing).yes: gives the event loop a turn every four chunks whatever it writes to. On POSIX main a redirect to a disk file never did, soyes > filestarved timers and everything else (by reading).SIGCHLDhandler is installed withSA_RESTART, so a child's exit no longer fails another thread's restartable system call withEINTR.Test changes
process-stdin.test.ts› "pipe backpressure";spawnSync.test.ts› "should use spawnSync optimizations when possible" (fails on main: the blocking path was POSIX-only);test/napi/uv.test.tsanduv_stub.test.ts. Also enabled, passing on main too (the skip reasons were stale):fs.test.ts› "utimesSync › works after 2038" and "BigIntStats *Ns does not clamp for post-2262 timestamps",spawn-stdin-readable-stream› "stderr for-await applies backpressure",websocket-proxy-close-reentrancy.fs.test.tstime tests ("a time past 2106 reads back as Node reports it", "stat, lstat and fstat report times after 2038, after 2106 and before 1970 as Node does") pin Node's 32-bit wrap; they pass on main.napi/uv.test.ts› "uv_tty_reset_mode after setRawMode" is now POSIX-only (it tests termios).node-net.test.ts› "should allow reconnecting after end()" waited 3 ms afterend()and assumed the socket had closed. Timers are more precise now, which exposed the race; the test waits forclose. The time fromend()tocloseis the same on both builds.appcontainer.test.ts:fs.realpathSync.nativeis expected to work (wasEPERM).23316-long-path-spawn*.test.ts:Bun.writeto the long path is expected to succeed.fs-path-length.test.ts› "Buffer paths containing malformed byte sequences" expectedfs.writeFileSyncto create files named with U+FFFD. On mainopen,mkdir,stat,renameandunlinkwith the same bytes already failed withENOENT, so such a file could be created and then not reached. The test now expects every call to fail withENOENTand nothing to be created, for the same overlong sequences.filesink.test.ts› "Bun.spawn stdin pipe with an unref'd child" spawns the childdetached: the parent exits sooner here, and the job killed the non-detached child before it printed (old test: 3 of 10 here, 9 of 10 on main; withdetachedboth deliver every byte).bun-write.test.js› "Bun.write() without uv_fs_copyfile" is renamed "without CopyFileW" and re-runs only the nine file-to-file tests (-t "path to path: "), the ones that reachCopyFileW; it re-ran the whole file.fs.watch.test.ts› "delivers a null-filename 'change' event when ReadDirectoryChangesW overflows" no longer overflows (2000 of 2000 names arrive) and passes through its "all events delivered" arm.test/expectations.txtno longer expectstest-net-pingpong.jsto fail on Windows.process-stdin.test.ts› "stdin with 'readable' event handler should receive data when paused" asserted that two writes arrive as one 8-byte chunk; pipe reads arrive one chunk per loop turn now, so it collects the chunks until'end'. "explicit read(n) with no 'readable' listener" bounds its wait by time (it counted 20,000setImmediateturns).run_command.test.tsand twobun-write.test.jsterminal tests wait for the terminal'sexitbefore they read its output.napi/uv.test.tsanduv_stub.test.tsare gated oncanBuildNodeAddons(), which also skips them on a macOS machine without a C++20 compiler (they always ran there); on every platform the addon is built withbun --bun node-gypand the hooks have 300 s.ssl-ctx-cache.test.tscallsend()fromopenagain on Windows; the workaround it drops (ending after the handshake) is not needed on main either, where the new version passes.fs,fs.watch,net, named pipes,spawn, IPC,process.stdin, the terminal, the shell,Bun.fileandcluster, most of them Windows-only. Those that are not in the table above guard the new code and pass on main as well.process.stdin,Bun.spawnandipc(terminal-platform-gaps,spawn-path,spawn-env,spawn.ipc,spawn).tempVolume()in the harness makes a throwaway FAT32, exFAT or NTFS volume with diskpart. It needs an elevated process; the tests are skipped without one.child_process_ipc_handle.test.tsruns on Windows: 10 pass, 4 todo, 1 skip, each with its observed failure (the whole file was skipped).gc-controller-cadence.test.ts› "an idle collection finishes while the program is parked" runs on Windows.Bun.file(fd)reads (3), the stdout reader of an unref'd child (4), blob-like inputs at stdio index 3 and up (3), a caller's fd left open (2),onDisconnect, a write after the peer reset, cluster (3), a stream over a write-only fd,process.cpuUsage(2), two uncaught-handler exit codes, TLS over a unix socket (2).flaky-tests.txtlosesnode-net.test.tsandtest-cluster-shared-leak.js. The two 8.3-alias tests are skipped where the volume makes no aliases (they passed without testing anything).bun-write› "keep their order" on a terminal asserts the screen, not ConPTY's bytes. Two tests pinned a Ctrl+C exit code cut to a byte (58); they expect0xC000013A. The dev-server harness knows an aborted client by0xC0000409(was 9).Verification
isWindowsand that this PR does not touch (95 of them run tests on Windows) were run one at a time on both builds. No file passes on the base and fails here. Files with failures fail the same tests on both, except the twofetchfiles under "Performance".worker-late-completion(28/0 vs 24/4),fetch.tls(34/0 vs 32/2) andworker-terminate-lifetime(23/0 in 60 s vs killed at 120 s) are better here.todoIf(isWindows)tests run with--todo: identical on both builds.appcontainer,node-netreconnect,23316).socket-syscall-fault.test.ts,socket-fault-injection.test.ts) need a build with socket fault injection on. They pass on such a build locally (socket-syscall-fault5 of 5,socket-fault-injection16 of 16).fs717,streams623,bunshell497,spawn176,node-net140,bun-write116,socket110,terminal84,child_process69,fs.watch56,worker44,process-stdin35,cluster33,terminal-platform-gaps28,spawn.ipc24,spawn-path24,spawn-env21.fs593,fs.watch53,bunshell479,spawn154,child_process63,process-stdin35,filesink35,bun-write101,node-net125,socket99,socket-syscall-fault8,cluster29,fetch390,serve319,node-http162,node-dns166,node-tls-connect75,websocket-server142,worker45,worker_threads142,terminal84.cargo check --workspaceforx86_64-pc-windows-msvcandx86_64-unknown-linux-gnu, and clippy for Linux, are clean.fs.watch,process-stdin,cluster,socket,socket-syscall-fault,spawn,node-net,bunshell,filesink,worker, all 0 failures. macOS and FreeBSD are covered by CI only, as are the tests un-gated on POSIX (fs.utimesinvalidDate, IPC, shell,Bun.writeerror fields, size-limited stdin read).fscalls, 41 long-path cases that fail on main and now equal Node, a 445-argument quoting corpus (byte for byte Node's), 88 file operations on real FAT32 and exFAT volumes (12 differences from Node on main, 4 here, all the direction of a slash inerr.path), files over 4 GiB (14 cases),CTRL_CLOSE_EVENT(throughClosePseudoConsole).windowsHide, timers across sleep/resume, a real Ctrl+C at a terminal against a shell builtin, a full disk. Windows arm64 is built and tested by CI only; the performance numbers are x64.Known gaps and follow-ups
terminal.test.ts› "terminal is released after a write() that follows the child's exit" failed once in CI on Windows Server 2019 (3 of 4 terminals collected). It passes 55 of 55 locally on Windows 11; not explained yet.select()for a socket AFD cannot poll (a non-IFS LSP), the thread-pool wait where ntdll has no wait completion packets (Wine), a listener whoseAcceptExsocket cannot be made (Winsock out of buffers). They had tests on fault-injection points made for them, which ran in no CI lane; native code that exists only for tests was taken out. The Windows tests on main's fault-injection points run in no CI lane: fault injection defaults to the ASAN profile, and the only ASAN lanes are Linux.CreateProcessWthat stalls (an antivirus scan) stalls every spawn in the process, the JS thread's too (main: that thread). Naming exactly the child's handles (PROC_THREAD_ATTRIBUTE_HANDLE_LIST) would remove the lock; a handle an addon or Bun's own parent marked inheritable would then no longer be passed on, so it is left for later. A socket received over IPC is inheritable from the sender'sWSADuplicateSocketWuntil it is imported.accept()ed.accept()returns an inheritable handle, and it is made non-inheritable right after, outside the spawn lock: a child spawned by another thread in between inherits the connection. Taking the lock aroundaccept()would let a connection lost to another acceptor of a shared listener stall every spawn. Not testable here (no such provider installed).process.stdin.pause()on a synchronous pipe, called from anywhere but stdin's own'data'handler with input flowing, can leave up to 64 KiB taken from the pipe and not delivered: a child that then inherits stdin starts behind it (4 to 9 rounds of 12; main: 0).closeapart (see "Asyncnode:fson a file descriptor"). Ordering them per descriptor, on every platform, would also putBun.write(Bun.stdout, …)calls that are not awaited in order when stdout is a file.Bun.spawnwithipc:exitedcan settle beforeonDisconnect. Holdingexiteduntil the channel's close is delivered changes timing on every platform.EPERMfrom the loop; main and Node reportENOTCONN(EPIPE) at the call.uv.h) is 1.51; two 1.52 exports (uv_tcp_keepalive_ex,uv_udp_open_ex) are not stubbed.src/io/windows/README.md).fetch.test.tshas 27 failures in a whole-file run against 17 on the base build; all pass alone.Bun.spawnwith acwdpastMAX_PATHfails withENOENT, passing a socket from Bun to a Node child over IPC, IPC messages lost at exit (the parent closes the channel when the child exits, not at its end; five sends and thenexitdeliver one),wsock32.liblinked ahead ofws2_32.lib(setMulticastInterfacesets Don't-Fragment),console.loggarbled after another process restores the code page,process.chdirto another drive does not keep the=X:variable that drive-relative paths resolve against, a file still open whenawait writer.end()has settled and one never closed when a writtenwriter()is dropped,child.kill("SIGHUP")throwsENOSYS(Node kills),"pipe"at stdio index 3 and up gives the child an overlapped end (Node: synchronous),tty.ReadStream(fd).setRawMode()fails for every console fd but 0.