Skip to content

Remove libuv on Windows - #42819

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

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

Conversation

@dylan-conway

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

Copy link
Copy Markdown
Member

What this does

Bun no longer links libuv. Windows gets its own event loop, 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 its poll.c are gone from the build.

On Linux, macOS and FreeBSD the event loop, uSockets, uWS, timers, spawn, src/io, the bun_sys syscalls, Bun.serve and 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

  • Loop. A new uSockets backend, packages/bun-usockets/src/eventing/iocp.c, replaces libuv.c. Every pending operation is an OVERLAPPED at 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.
  • Sockets. Readiness comes from IOCTL_AFD_POLL on \Device\Afd (the mechanism behind wepoll, mio and libuv's own uv_poll_t). Polls are non-exclusive, so two loops can watch one socket. Sockets behind a non-IFS LSP fall back to select() on a thread, as libuv did. Winsock is started on first use.
  • Accepting. Every connection is taken with an overlapped 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 and accept()ed, as its sockets are polled with select(). A socket whose file object already belongs to another process's port completes through an event.
  • Timers. Bun's own timer heap drives the wait timeout; sub-millisecond waits use a high-resolution waitable timer delivered into the port by a wait completion packet. No uv_timer_t, no idle handle, no "forever timer". The idle park of --watch/--hot is 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).
  • Process exit, events, console input arrive through wait completion packets (NtAssociateWaitCompletionPacket), with RegisterWaitForSingleObject as the fallback.
  • Pipes, console, files (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.md records 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, as ReadFile on a console does.
  • Spawn (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.
  • File descriptors. The CRT fd table stays (so fs.openSync numbers behave as before), but internal opens return HANDLE-kind fds and no longer consume CRT slots.
  • Allocator. A tick that parks hands the thread's heaps to the mimalloc scavenger, as the epoll/kqueue loops do (see "Performance").

N-API

Windows now matches POSIX: the same small set of uv_* functions is polyfilled and every other uv_* export aborts with a message naming the function. uv_version, uv_version_string, uv_strerror and uv_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's uv_* block is the list in src/symbols.txt. napi_get_uv_event_loop and node::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.

Bug Test On main
Two threads spawning at the same time (Workers). A child inherits every inheritable handle, and the handles made for one child are inheritable while another thread spawns, so a child can hold another child's pipe ends and that pipe does not reach EOF until it exits. Fixed by the lock described under "Spawn" spawn.test.ts › "threads spawning at the same time do not share their children's pipes" times out
After a console window is resized process.stdout.columns is 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" fails
fs.watch: deleting the watched directory emits rename forever fs.watch.test.ts › "deleting the watched directory (recursive: …)" (4) fails
fs.watch(dir) then fs.watch(dir, {recursive: true}) shared one non-recursive handle fs.watch.test.ts › "a recursive and a non-recursive watcher on the same directory are independent" fails
Relative paths past MAX_PATH fail with ENOENT unless LongPathsEnabled; so does fs.mkdtempSync with a prefix that makes the path longer than 260 characters fs.test.ts › "relative paths past MAX_PATH", "mkdtemp prefix length" fails
Bun.write(<absolute path of 260 characters or more>, <non-empty string or bytes>) rejects with ENOENT (an empty string, writer(), fs.writeFileSync and 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) the old expectation passes
A drive-relative name without a dot (C:zz) writes a file named C with a stream zz fs.test.ts › "a drive-relative path names a file in that drive's directory" fails for zz
statfs and appendFile errors for an absolute path carry \\?\C:\… instead of the path that was passed fs.test.ts › "statfs, appendFile and chown errors carry the path that was passed" fails
Shell 2>&1 / 1>&2 lose all output when stdout/stderr are files bunshell.test.ts › "2>&1 and 1>&2 when the shell writes to files" fails
Bun.spawn(["./nope"], {stdout: Bun.file(p)}) leaves a stray empty file spawn.test.ts › "a path to a program that does not exist throws ENOENT and creates no file" fails
Bun.write(Bun.file(readOnlyFd), req) resolves for a small Request body, rejects with a bare numeric code for a large one bun-write.test.js › "… written to a read-only file descriptor rejects with EBADF" the two Request cases fail
A listening socket shared by several acceptors (cluster SCHED_NONE, a handle passed to another process) can freeze a worker: every acceptor is told about the one connection, and Winsock's non-blocking accept() is a readiness check followed by an unbounded wait, so a loser blocks its loop thread until the next connection Node's test-cluster-shared-leak.js; cluster.test.ts › "SCHED_NONE: workers sharing a listening socket keep running…" Node test hangs about half the time; the new test fails about 1 run in 6
Shell builtin cat file > out can finish before its queued writes: truncated output and panic: expected Node::Cmd … got Free bunshell.test.ts › "builtin cat writes every chunk it queued" (3 tests) the named-pipe case fails
A second cat on a stdin the first one read to its end (cat; cat, cat && cat, …): panic: called Option::unwrap() on a None value on Windows, a hang on POSIX. cat dir > file hangs on Windows bunshell.test.ts › "builtin cat" › "after a cat that read the same stdin to its end" (8 cases), "with an input it cannot read" 17 of the 21 builtin cat cases fail
A shell builtin that queues one output chunk per file or directory (ls -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 chunk bunshell.test.ts › "a builtin with several chunks queued ends when its pipe's reader has gone" (ls -R, rm -rv) hangs
process.title is capped at 1 KiB and cached by libuv process.test.js › "process.title reads a console title of %d UTF-16 units, truncated to 8191" fails (needs a console)
process.report.getReport().sharedObjects truncates or drops a module path of MAX_PATH or more process.test.js › "process.report.getReport().sharedObjects has all of a %d-unit module path" 3 of 4 fail
A UTF-8 sequence split between two byte writes to a console is not joined 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" fails
Work that completes after its Worker began tearing down was invisible to the teardown protocol (it ran as libuv requests, not pool jobs) worker-late-completion.test.ts (debug/ASAN builds) 4 of 28 fail
The event loop freezes when 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 is process-stdin.test.ts › "pause() returns while a child is blocked reading the same synchronous stdin" times out
A net.connect to 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 fails node-net.test.ts › "a client waits for a busy named pipe, and a waiting client can be destroyed" fails 4 of 6 runs (whichever waiter's thread opens the instance first)
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" throws
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 with fs.watch.test.ts › "fs.watch resolves 8.3 aliases below the watched directory after that directory is renamed" fails
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) fails
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" fails
A read that is still pending when the module has finished evaluating does not keep the process alive: Bun.stdin.text() (#41850) and a Bun.file() read that rejects (#39787) exit 0 with the promise unsettled bun-file.test.ts › "a pending Bun.file() read that rejects…", "a pending Bun.stdin.text() keeps the process alive until its %s ends" 3 fail
FileSink.flush() returns while the write is still in flight, so a read of the file right after await writer.flush() can miss it (#42435) filesink.test.ts › "flush() settles once the bytes written before it are in the file" fails
A shell redirect target is still open when the command's promise settles (an open that shares nothing fails 200 times of 200; it is closed one tick later) 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" fails
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's test-net-pingpong.js fails 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) fails
A message-type named pipe server (the Docker engine's and Podman's pipes, a .NET message-mode server) cannot be told that the client has finished writing, and its own end-of-write message is ignored: end() closes the pipe, so the server sees a broken pipe and whatever it writes next is lost node-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" 9 of 11 fail
fs.close(fd) that is not waited for overtakes what was given the descriptor before it: an earlier write fails with EBADF, 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) 23 fail
Windows: as many reads of a silent pipe (fs.read(0, …), Bun.stdin.text()) as the pool has threads, and no other async job (fs.promises.readFile, zlib, pbkdf2) ever finishes fs.test.ts › "reads of a silent pipe do not hold up the work pool" (8) 7 fail
fs.rmSync("./dir", {recursive: true, force: true}) removes nothing and reports success (without force: ENOENT); so does any relative path with a . or .. in it, sync and promises fs.test.ts › "removes what a relative path with . or .. in it names" fails
A child's exit code above 255 is cut to its low byte: exit 256 is exitCode: 0, success: true, bun run exits 0, a crash (0xC0000005) reads 5 spawn.test.ts › "an exit code that does not fit in a byte" (10) 9 fail
{...process.env, PATH: x} does not reach a native child: which spelling of a name it sees is unspecified (x 13 times of 24) spawn-env.test.ts › "the last spelling of a name wins" 8 fail
Bun.write(fd, Bun.file(path), {mode}): macOS and FreeBSD reject with EINVAL after the copy when fd is a pipe or a socket; Linux changes the mode of the caller's descriptor bun-write.test.js › "… leaves the mode of the caller's file alone" POSIX only: failed on macOS in CI with main's code
FAT32 and exFAT (USB sticks, SD cards): a recursive fs.rm spins 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 with fs.rmdir (EPERM); shell mv, and whatever else renames through renameat, is Invalid argument fs.test.ts › "on FAT32 | exFAT, which has no POSIX delete" (8); mv.test.ts › "…, which has no POSIX rename" (4) 10 of 12 fail
A console key record with a repeat count of 0 gives the key 65,535 times (a console without VT input) terminal-platform-gaps.test.ts › "key records in raw mode" › "on a console without VT input" fails

Fixed without a test, by construction, by hand repro on both builds, or measured only outside Bun:

  • A Worker that holds a Bun.spawn child with stdin/stdout: "pipe" that nobody reads, then is terminate()d: use-after-free on main (debug build panics with a misaligned 0xdfdf… pointer, 7 runs of 7). Here the Worker exits with code 1, 8 of 8.
  • Four Workers with 20 dns.lookup calls in flight each, then terminate(): 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 with ENOENT.
  • 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.
  • A subprocess started after a builtin cat on the same stdin failed with bun: Bad file descriptor on Windows (the reader had closed the shell's fd at EOF).
  • An IPC message larger than i32::MAX bytes panicked (int cast) once its first i32::MAX bytes were written, on every platform (by reading).
  • An inherited overlapped stdio pipe was associated with Bun's port; the association belongs to the file object, so a sibling process's completions could be delivered to Bun. Foreign handles are never associated now.
  • SetFileCompletionNotificationModes is no longer applied to handles Bun did not create (it can hang a sibling's WriteFile).
  • A 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.
  • A Node peer sending a socket over IPC got a NACK and the duplicated socket leaked.
  • Bun.file() reads over 4 GiB failed with ENOMEM; readv/writev totals 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).lastModified for an mtime between 2038 and 2106 was garbage (4501513648874496 for 2040).
  • process.cpuUsage wrapped every 24 hours and was quantised to milliseconds; peak RSS was rounded through kilobytes.
  • isatty was true for NUL.
  • Internal opens no longer count against the CRT's 8,192-fd cap.
  • The socket-timeout sweep timer, once started, was never stopped: a process that had ever had a socket woke every 4 s for the rest of its life.
  • tick_with_timeout ignored its timeout on Windows, and JavaScriptCore's own timers (GC, sweeper) only fired when something else woke the loop.
  • Shell builtin cat hung 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.
  • A lone " as the last PATH entry underflowed a length in libuv's search_path (not reachable through Bun.spawn, so not tested).
  • process.stdout.write came out garbled after another process on the console had put its code page back (console.log still does).
  • Bun.stdin.text() on an overlapped stdin rejected with EUNKNOWN.
  • The client of a one-way (outbound) pipe server received nothing. It receives the first write (Node: all of them).
  • fs.statfs counted clusters in 32 bits and bavail ignored 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's active_handles++; uv_run(NOWAIT); active_handles--, direct writes to uv_loop_t.active_handles from C and Rust, and 12 us_socket_ref/unref calls in uWS that did nothing on any other platform.
  • The uv_timer_t + uv_idle_t pair whose only job was to wake uv_run for Bun's own timer heap, the "forever timer", and the Windows-only us_timer_t API.
  • The second thread pool.
  • 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).
  • Fd juggling: 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).
  • The open_handles registry keyed by uv_handle_t* and five libuv-only Worker teardown steps.
  • Shell: is_writing/is_reading flags, the NUL → uv_tty_init → EBADF → "restart as file" fallback, fds set to INVALID after libuv took ownership.
  • A zero-byte recv after every readable event and after every connect; re-arming the AFD poll before the callback (exactly two completions per message).
  • <uv.h> in ZigGlobalObject.h, which pulled windows.h/winsock2.h and libuv's macros into nearly every C++ TU.
  • Parking with JSC heap access held while an idle collection is unfinished: the Windows loop lets go of it as the POSIX loops do, and the cfg!(windows) arm of the GC controller is gone.
  • fchmod's set-ARCHIVE, toggle, clear-ARCHIVE (one NtSetInformationFile); the CreateSymbolicLinkW retry for Windows before 1703 and its process-wide latch; statfs's GetDiskFreeSpaceW + GetFullPathNameW ladder; an unreachable \\.\ branch that called CreateFileW with NT constants; Exited.raw next to a truncated Exited.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

this branch main
Shell builtins moving data: cat big > out / cat < big | cat > out / prog | cat > out, 1 GiB (MiB/s) 803 / 1,019 / 1,406 18.6 / 21.0 / 14.8 43× / 48× / 95×
fs.watch: files reported of 5,000 created while the loop is busy, flat / recursive 5,000 / 5,000 1 / 1
fs.watch: create 1,000 watchers (ms) 109 837 7.6×
Bun.write(path, string) 1 KiB: create / overwrite, 64 concurrent / overwrite one at a time (files/s) 3,913 / 5,049 / 3,092 3,001 / 3,311 / 2,109 1.30× / 1.53× / 1.47×
fs.watch: create → event latency p50, flat / recursive (ms, one run) 0.23 / 0.43 1.32 / 2.49 5.7× / 5.8×
Bun.file().text(), small files, 64 concurrent (files/s) 122,256 47,576 2.57×
process.send round trips, 1 KiB JSON (per s) 67,016 26,822 2.50×
setTimeout + clearTimeout pairs (ops/s) 10.4 M 4.2 M 2.47×
fs.open + read + close callbacks, 64 concurrent (ops/s) 124,305 60,909 2.04×
fs.realpath.native, 64 concurrent (ops/s) 59,688 31,308 1.91×
A child's stderr / stdout 'data', 1 GiB (MiB/s) 5,229 / 4,404 2,867 / 3,166 1.82× / 1.39×
node:net named pipe ping-pong 64 B, 1 / 32 connections (round trips/s) 117,738 / 150,405 76,116 / 112,513 1.55× / 1.34×
echo hi | cat in the shell (commands/s) 6,054 4,020 1.51×
https fetch GET, 1 at a time / 64 at a time (req/s) 18,219 / 41,980 12,616 / 33,662 1.44× / 1.25×
node:net TCP ping-pong 64 B, 1 / 32 connections (round trips/s) 56,478 / 53,531 44,107 / 38,345 1.28× / 1.40×
setTimeout(0..3 ms), half cleared (ops/s) 430,530 321,923 1.34×
fs.promises.mkdir({recursive}) chains of 8, 64 concurrent (dirs/s) 5,497 4,193 1.31×
node:tls ping-pong 64 B, 1 / 32 connections (round trips/s) 33,926 / 35,671 27,151 / 29,775 1.25× / 1.20×
fetch, 64 concurrent, against another process (req/s) 87,164 71,267 1.22×
https fetch streamed download, 512 MiB (MiB/s) 533 446 1.19×
node:http hello (req/s) / p99 (ms) 42,355 / 4.67 38,190 / 5.31 1.11× / 0.88×
Bun.serve hello: keep-alive p99 / a connection per request p99 (ms) 3.73 / 1.91 4.23 / 3.90 0.88× / 0.49×
Timers: setTimeout(16 ms) overshoot p50 / p99 (ms) 0.20 / 0.51 0.74 / 1.50
RSS per idle TCP connection, both ends (KiB) 2.97 3.36 0.88×
Startup: bun -e 0 / bun --version (ms) 15.6 / 5.56 17.0 / 5.81 0.91× / 0.96×

UDP echo, WebSocket echo and Bun.listen TCP/TLS/named-pipe ping-pong were 1.2–1.8× in a single-run pass and were not re-measured. Bun.serve hello throughput, 1 MB responses, spawn rates, fs walks, rmSync, readv/writev, setImmediate chains, Worker start and RSS are within ±7 %.

Slower

this branch main
A child reading a synchronous-pipe stdin: process.stdin 'data' / Bun.stdin.stream(), 1 GiB, pipe from Bun.spawn (MiB/s) 1,226 / 1,255 2,639 / 2,485 0.46× / 0.51×
… pipe made by cmd.exe (a | b) 1,288 / 1,333 1,895 / 1,782 0.68× / 0.75×
node:net over a named pipe, one side end()s and the other waits for 'end': time from connect to 'close' (ms; Node 25: 63) 51 0.1–0.3 (and the reply to the last write is lost)
What is bottlenecked on it: bun a | bun b in the shell; child.stdin.write + 'drain'; 1 GiB echoed through a child; 16 MiB stdin payloads (MiB/s) 1,261; 1,349; 1,046; 420–468 2,368; 2,468; 1,516; 570–591 0.53×; 0.55×; 0.69×; 0.72–0.81×
echo line > file, the same file truncated every time (commands/s, two runs) 1,106–1,238 1,882–2,564 0.43–0.66×
process.stdin.setRawMode(true) + (false) under a pseudoconsole (pairs/s) 8,754 25,116 0.35×
Response(Bun.file) of a 1 KiB file, 16 at a time / 1 at a time (req/s) 5,317 / 6,279 6,475 / 7,108 0.82× / 0.88×
Bun.file().writer(), 100,000 awaited 64 B writes (writes/s) 39,477 47,801 0.83×
Bun.write(path, Blob) overwrite / Bun.file().unlink(), 64 concurrent (files/s) 2,402 / 5,343 2,785 / 6,132 0.86× / 0.87×
fs.promises.stat, 64 at a time (calls/s) 28,798 32,663 0.88×
console.log / process.stdout.write to a pseudoconsole (lines/s) 30,580 / 28,532 34,395 / 31,575 0.89× / 0.90×
Waking an idle loop from another thread: postMessage main → Worker p50 (µs) 17.6 14.1

Why:

  • Synchronous-pipe stdin. The handle is read by a dedicated thread that says "readable", is asked, then reads: two loop round trips per 64 KiB. libuv reads on the loop thread after a zero-byte read on a pool thread, which is faster and blocks the event loop whenever another process (a child that inherited stdin) holds the pipe's one I/O slot; a reader that reads ahead loses a chunk for a child that inherits stdin after 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.
  • > file over 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.
  • Small async file operations one at a time. They moved from libuv's thread pool (one thread wake-up per task) to Bun's, the one POSIX uses, where a worker that finds a task hands the search for more to a second thread: two wake-ups per isolated task. It is also why the concurrent fs rows 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.
  • Idle-loop wake-up. A few microseconds more on a one-way wake; a Worker ping-pong is 18.1 µs a round trip here against 19.6.

Also measured (one run each), fs.watch under 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

  • N-API: uv_version, uv_version_string, uv_strerror, uv_err_name abort as they do on POSIX. napi_get_uv_event_loop no longer returns a uv_loop_t*.
  • Bun.file paths: every Bun.file API (text(), exists(), stream(), writer(), Bun.write, copies, fetch bodies) 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 creates a. and a regular file called NUL.) On main Bun.write(path, string) already followed Win32's rules; Bun.file(p).writer() and Bun.write(p, stream) named the file literally (a5. was created). The Bun.file operations that go through node:fs internally (delete(), stat(), the truncate of an empty or sliced write, a Bun.file inside a Blob) are told the path is a Bun.file's. node:fs itself is unchanged. A check of the name made in front of Bun.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).
  • Bun.file(fd): reads keep main's Windows behaviour: they are positioned and the descriptor's position does not move (POSIX reads from the position and moves it).
  • lastModified: fs.Stats times on Windows keep Node's wrap of the seconds through an unsigned 32-bit value. Bun.file(p).lastModified, Last-Modified and If-Modified-Since do not wrap, so from 2106 they differ from fs.statSync(p).mtimeMs.
  • fs: fs.mkdtemp with a prefix too long for any path gives ENAMETOOLONG (main and Node: ENOENT). fs.utimes keeps libuv's double arithmetic, which Node's test-fs-utimes-y2K38.js pins. fs.writeFileSync(dir) reports EISDIR with syscall: "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 carry fd (as Node). process.chdir(file) gives ENOENT (main: ENOTDIR; now as Node). \\?\C:/with/forward/slashes opens (main: ENOENT). fs.realpath.native works inside an AppContainer (main and Node: EPERM). A Buffer path that is not valid UTF-8 names no file (ENOENT, measured identical to Node v25 for writeFile, open, mkdir, stat, exists and promises.writeFile) for every call but mkdir with recursive (EPERM) and rm with recursive (EINVAL); on main fs.writeFileSync and the other calls that open through openat replaced each bad byte with U+FFFD. Async fs.readv/writev errors say syscall: "read"/"write" (main: readv/writev).
  • fs.watch: deleting the watched directory gives one rename and nothing after it; the watcher stays open until close() (main and Node repeat rename for 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") throws ENOENT (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 with GetLongPathNameW on the original path.
  • Sockets and pipes: a failed connect reports the socket's SO_ERROR (ECONNREFUSED, ETIMEDOUT, …) instead of what a zero-byte recv could 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), so chunk.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 until resume(), and a peer's write that this read took succeeds (on main it pended and failed with EPIPE when the reader closed). The pending read takes the writer's WriteQuotaAvailable to 0, which main's zero-byte read did not: a writer in non-blocking mode sees EAGAIN more often. error.syscall of a stream read error is read (main: recv; a failed read start said open).
  • Timers: not rounded to whole milliseconds. A 3 ms timer fires at 3.15 ms instead of 4.12 ms. Unref'd timers do not fire while an idle --watch/--hot is parked with nothing alive (as on POSIX; on main the forever timer's uv_run ran them).
  • Process: SIGWINCH comes 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.columns is 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 after setRawMode(false) (main reset them under a live SIGINT listener). CPU times are not quantised. The process.title getter 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 throws fd<N> is not a tty, the POSIX text. process.report formats interface addresses with the formatter os.networkInterfaces() uses.
  • Spawn: 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, in qsort's unspecified order). exitCode can be above 255, as in Node. Bun.spawnSync with no pipe, timeout, maxBuffer or 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 a process.env.X = … reaches those children. With ipc and onDisconnect, exited can settle before onDisconnect.
  • stdin: a child's synchronous stdin is read by a dedicated thread, never on the loop thread. On a console, text typed (without Enter) before process.stdin.pause() is carried into the next line read (Node and main drop it).
  • dns: lookups run GetAddrInfoW on Bun's threads for jobs that wait. A host name that is not ASCII is IDNA-encoded first with the encoder url.domainToASCII uses (libuv punycoded each label without case mapping: MÜNCHEN.de became xn--MNCHEN-psa.de). No synchronous EINVAL for names over 256 bytes; lookups in flight are not cancelled at teardown.
  • Winsock: a failed WSAStartup is tried once and later calls report WSANOTINITIALISED (libuv aborted the process).
  • Error codes: four Win32 errors map as in libuv 1.52.1: ERROR_NOACCESS is EFAULT (main: EACCES), ERROR_BUFFER_OVERFLOW ENAMETOOLONG (EFAULT), ERROR_BROKEN_PIPE EOF (EPIPE), ERROR_NO_DATA EAGAIN (EPIPE).
  • Files (node:fs): writeFile/writeFileSync/fs.promises.writeFile with an a flag append atomically across processes (the open is append-only, as appendFile and Node do it; on main concurrent writers lost lines). To a named pipe such a write is EBADF, as in Node. Files these calls create honour process.umask() (the read-only attribute; they were always writable). Numeric O_DIRECT/O_SYNC are honoured and O_WRONLY|O_RDWR is EINVAL on those paths. process.umask() reports the value Bun last set. One translation of open flags serves open and openat. A descriptor opened with UV_FS_O_FILEMAP and O_APPEND cannot be truncated. fs.readSync starts an aborted read again (main: ECANCELED; by reading).
  • Bun.write / Bun.file: missing parent directories are created under the names Win32 gives them (dir.\f.txt writes into dir). A copy onto a directory or a read-only file is EPERM, not ENOENT; a directory as the copy source rejects like every other copy path; an unreadable source is blamed on the source. The destination is opened with FILE_FLAG_SEQUENTIAL_SCAN again.
  • Bun.write, small input: input under 256 KiB is written on the calling thread when the destination is a file on a disk, or the process's own stdout or stderr (which console.log writes 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 in open with EPERM (main: in truncate). FileSink.flush() returns a promise while a write is in flight and settles when it has been written (main: 0 at once).
  • Pipes: a handle Bun is given is never asked what kind of pipe it is on the JS thread (the question waits on a synchronous pipe that has a read parked on it; main and libuv ask inline), except by Bun.write(fd, …), which asks whether the handle is synchronous. Something that is not a pipe fails at open. A parent that pauses process.stdin from 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.
  • Listening sockets: every connection is taken with AcceptEx; accept() is never called on a listener (it blocks the thread that loses a race on a shared listener). A listener that cannot start AcceptEx is 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.
  • Spawn: no handle Bun itself creates is inheritable outside the spawn lock, including the ones CreatePseudoConsole makes (a terminal's exit no 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.
  • Shell: a pipe between two builtins is an overlapped pipe on the loop (no thread per reader, no work item per write). Redirects to disk files are written synchronously, as on POSIX. A redirect target is closed before the command settles (main: after it has settled). (echo a; external) | cat sends the external command's output into the pipe (1.4.0: to the terminal). Paths past MAX_PATH work as redirect targets and in cat, ls, touch.
  • Loop teardown (Workers, the spawnSync loop): 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 (a select() 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 main uv_loop_close was 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.
  • Synchronous pipes: pause(), read_stop and 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.
  • Named pipe connect: a busy pipe is waited for with an overlapped FSCTL_PIPE_WAIT on the loop (no thread in WaitNamedPipeW); destroy() cancels the wait at once. The 30 s limit is unchanged.
  • Named pipe 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 overlapped FSCTL_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. With allowHalfOpen: 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.sys has 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'; with allowHalfOpen the socket stays writable. Bun's own pipe servers stay byte-type: a Node client's socket.write("") reaches a message-type server as a zero-length message.
  • Named pipe 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. The node:http client does not (it destroys its socket), and Bun.connect/Bun.listen socket.end() still closes at once; their socket.shutdown() takes the new path.
  • Async node:fs on a file descriptor: fs.read, fs.write, fs.readv, fs.writev, fs.open, fs.close and fs.statfs run 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.close is the exception: see "Every platform".
  • Pipe server: a client that connected, wrote and closed before the server's ConnectNamedPipe is reported as a connection, with its data and then 'end', as a Unix socket server would (libuv and Node drop it).
  • Console stdin, line mode: Ctrl+] typed by the user is not delivered (it is the key Bun types to end a pending line read); main delivered byte 0x1D.
  • Cluster with SCHED_NONE: a connection on a shared listener belongs to the worker whose AcceptEx the kernel completes, also when that worker is busy and another is idle (as in Node on Windows; on main the first worker to call accept() 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 names CreateIoCompletionPort (main: uv_loop_init).
  • Symlinks made by bun install and archive extraction: / in a link target is stored as \, so a relative target with forward slashes resolves (fs.symlink already did this).
  • Console stdin opened read-only (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 through CONIN$ 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.
  • Shell: see "Every platform"; cat is a default builtin on Windows.

Every platform

  • Errors: an errno outside Bun's table keeps the kernel's number in err.errno and gets code: "EUNKNOWN" and the message EUNKNOWN: unknown error, <syscall> (main: no code, Unknown Error, <syscall>).
  • fs: fs.utimes/lutimes/futimes with an invalid Date leave that time unchanged, and the strings "Infinity"/"-Infinity" mean now; both threw ERR_INVALID_ARG_TYPE. This matches Node. fs.fsync and fs.unlink on POSIX retry EINTR like the rest of node_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 gets EBADF and 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.
  • Bun.write and Bun.file: an async write that fails in write(2) or while waiting to write carries path (or fd). When open says ENOENT and creating the parent directory fails, the rejection is the mkdir error with the file's path (main: the open error). On POSIX Bun.write(path, "") whose truncate fails with EPERM rejects with that (main went on to mkdir and open). {mode} applies only to a file Bun.write itself opens. A size-limited read of a pipe (Bun.stdin.slice(0, n)) that takes several reads stops at n; on POSIX main could return and consume up to 2n − 1 bytes.
  • dns: a c-ares socket whose poll reports an error or hang-up is processed as readable and writable, as Node does. On POSIX main passed neither direction for an error-only event, so c-ares kept a socket nothing polled. dns.lookup runs on threads kept for jobs that wait, not on the work pool's.
  • IPC: after 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.
  • Shell: builtin cat carries on with the next input after one it cannot read, prints cat: x: <reason> and exits 1 (main: stopped at the first, or exited silently with the errno as its code). A second cat on 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.
  • Shell: a writer that fails completes every chunk queued behind the failed one, each with the error.
  • Bun.spawnSync: a deadline already in the past ticks with a zero timeout, not a negative one.
  • process.versions.uv is 1.52.1 (Windows reported 1.51.1-dev, POSIX 1.48.0).
  • Smaller: the text of the N-API "unsupported uv_*" crash no longer says "for POSIX systems"; on macOS a long std::__libcpp_verbose_abort message 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 which fs.watch drops a repeated event is measured on the monotonic clock.
  • process.umask(): reads a value Bun keeps (read once at startup, updated by process.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 calling umask(2)) is not seen. bun install gives bin targets 0777 & ~umask with every linker (main's isolated linker used 0777).
  • fs.watch on Linux and FreeBSD: a watch whose root was deleted is dropped from the table of shared watches, so a later fs.watch() of the same path gets a working watch (on main it attached to the dead one and heard nothing).
  • Shell 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, so yes > file starved timers and everything else (by reading).
  • Child processes (Linux waiter thread): the SIGCHLD handler is installed with SA_RESTART, so a child's exit no longer fails another thread's restartable system call with EINTR.

Test changes

  • Enabled on Windows: 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.ts and uv_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.
  • New fs.test.ts time 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 after end() and assumed the socket had closed. Timers are more precise now, which exposed the race; the test waits for close. The time from end() to close is the same on both builds.
  • appcontainer.test.ts: fs.realpathSync.native is expected to work (was EPERM).
  • 23316-long-path-spawn*.test.ts: Bun.write to the long path is expected to succeed.
  • fs-path-length.test.ts › "Buffer paths containing malformed byte sequences" expected fs.writeFileSync to create files named with U+FFFD. On main open, mkdir, stat, rename and unlink with the same bytes already failed with ENOENT, so such a file could be created and then not reached. The test now expects every call to fail with ENOENT and nothing to be created, for the same overlong sequences.
  • filesink.test.ts › "Bun.spawn stdin pipe with an unref'd child" spawns the child detached: 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; with detached both 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 reach CopyFileW; 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.txt no longer expects test-net-pingpong.js to 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,000 setImmediate turns).
  • run_command.test.ts and two bun-write.test.js terminal tests wait for the terminal's exit before they read its output.
  • napi/uv.test.ts and uv_stub.test.ts are gated on canBuildNodeAddons(), which also skips them on a macOS machine without a C++20 compiler (they always ran there); on every platform the addon is built with bun --bun node-gyp and the hooks have 300 s.
  • ssl-ctx-cache.test.ts calls end() from open again on Windows; the workaround it drops (ending after the handshake) is not needed on main either, where the new version passes.
  • About 250 new tests (many parameterized) across fs, fs.watch, net, named pipes, spawn, IPC, process.stdin, the terminal, the shell, Bun.file and cluster, most of them Windows-only. Those that are not in the table above guard the new code and pass on main as well.
  • The new code comes with no Rust unit tests and no fault-injection points of its own (main's points are kept). Console key records, the image a name runs, the environment block, IPC framing and the descriptor limit are tested through process.stdin, Bun.spawn and ipc (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.ts runs 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.
  • 24 more tests are un-gated on Windows because they pass for the right reason: 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.txt loses node-net.test.ts and test-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 expect 0xC000013A. The dev-server harness knows an aborted client by 0xC0000409 (was 9).

Verification

  • Windows x64, debug builds of this branch and of the base commit.
  • 131 test files that mention libuv or isWindows and 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 two fetch files under "Performance". worker-late-completion (28/0 vs 24/4), fetch.tls (34/0 vs 32/2) and worker-terminate-lifetime (23/0 in 60 s vs killed at 120 s) are better here.
  • 24 groups of todoIf(isWindows) tests run with --todo: identical on both builds.
  • The base versions of all modified test files (56 at the time) were run on this branch: the only old tests that fail here and pass on main are the three expectation changes listed under "Test changes" (appcontainer, node-net reconnect, 23316).
  • The Windows fault-injection tests (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-fault 5 of 5, socket-fault-injection 16 of 16).
  • On the current tree, a Windows release build, whole files, 0 failures: fs 717, streams 623, bunshell 497, spawn 176, node-net 140, bun-write 116, socket 110, terminal 84, child_process 69, fs.watch 56, worker 44, process-stdin 35, cluster 33, terminal-platform-gaps 28, spawn.ipc 24, spawn-path 24, spawn-env 21.
  • On an earlier tree, a Windows debug build with fault injection, whole files, 0 failures: fs 593, fs.watch 53, bunshell 479, spawn 154, child_process 63, process-stdin 35, filesink 35, bun-write 101, node-net 125, socket 99, socket-syscall-fault 8, cluster 29, fetch 390, serve 319, node-http 162, node-dns 166, node-tls-connect 75, websocket-server 142, worker 45, worker_threads 142, terminal 84.
  • CI runs the test suite on Windows Server 2019 x64 and Windows 11 arm64, on Debian 13, Ubuntu 25.04 and Alpine 3.23 (x64 and arm64), on the Linux x64 ASAN build and on macOS x64 and arm64. cargo check --workspace for x86_64-pc-windows-msvc and x86_64-unknown-linux-gnu, and clippy for Linux, are clean.
  • Linux x64 by hand, a debug ASAN build: 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.utimes invalid Date, IPC, shell, Bun.write error fields, size-limited stdin read).
  • Against main and Node 25, by hand: about 2,300 fs calls, 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 in err.path), files over 4 GiB (14 cases), CTRL_CLOSE_EVENT (through ClosePseudoConsole).
  • The legacy-console output emulator against libuv's: 20,300 cases, no difference. 200 externs, 43 struct sizes (x64 and arm64) and 154 constants against the SDK: no mismatch.
  • Not verified: not checked by hand: a real legacy (non-VT) console window, a machine with a non-IFS LSP installed, safe mode without networking, 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.
  • Without a test, as it takes the machine and not a script to get there: 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 whose AcceptEx socket 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.
  • Spawn still gives children every inheritable handle, and the lock serialises spawns across threads: a CreateProcessW that 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's WSADuplicateSocketW until it is imported.
  • A listener whose Winsock provider is not AFD (a non-IFS layered provider) is still polled and 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 around accept() would let a connection lost to another acceptor of a shared listener stall every spawn. Not testable here (no such provider installed).
  • A write to a synchronous pipe that another process is also writing to, or a read queued behind another process's read, cannot be cancelled by anything Windows offers. When the pipe closes first, the helper thread keeps its duplicate handle until the pipe next has an event, so the peer sees the end stay open that long.
  • Reading a synchronous-pipe stdin is about half as fast as on main (see "Performance"). Windows 11's I/O rings would do it without a thread and without the hazards of libuv's way; they need a silent fallback and do not exist on Windows 10 or Server 2019/2022.
  • 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).
  • Work-pool jobs for one file descriptor have no order among them, close apart (see "Async node:fs on a file descriptor"). Ordering them per descriptor, on every platform, would also put Bun.write(Bun.stdout, …) calls that are not awaited in order when stdout is a file.
  • Bun's thread pool wakes two threads for an isolated task; libuv's woke one. Small async file operations one at a time pay for it on Windows now as they do on POSIX, most of all when the pool has far more threads than the process has CPUs. A fix belongs in the pool and would help every platform.
  • The per-park scavenger hand-off cost (see "Performance"); a fix belongs in mimalloc and would help epoll/kqueue equally.
  • Bun.spawn with ipc: exited can settle before onDisconnect. Holding exited until the channel's close is delivered changes timing on every platform.
  • A pipe end opened without read (write) access reports EPERM from the loop; main and Node report ENOTCONN (EPIPE) at the call.
  • Console reads do not honour a reader's byte limit.
  • The header copied for N-API (uv.h) is 1.51; two 1.52 exports (uv_tcp_keepalive_ex, uv_udp_open_ex) are not stubbed.
  • I/O rings could replace the reader thread for synchronous pipe handles on Windows 11 (src/io/windows/README.md).
  • Debug builds only: fetch.test.ts has 27 failures in a whole-file run against 17 on the base build; all pass alone.
  • Bugs found on the way that exist on main and are not fixed here: an assertion when an HTTP/3 listener fails to bind, no write backpressure on named-pipe sockets, Bun.spawn with a cwd past MAX_PATH fails with ENOENT, 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 then exit deliver one), wsock32.lib linked ahead of ws2_32.lib (setMulticastInterface sets Don't-Fragment), console.log garbled after another process restores the code page, process.chdir to another drive does not keep the =X: variable that drive-relative paths resolve against, a file still open when await writer.end() has settled and one never closed when a written writer() is dropped, child.kill("SIGHUP") throws ENOSYS (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.

…on port

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

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

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Extended reasoning...

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

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

robobun commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 2:34 PM PT - Sep 27th, 2026

✅ @dylan-conway, your commit 4a7a6627b4a4319e145c5c94748c280b52a9ad87 passed in Build #121314! 🎉


🧪   To try this PR locally:

bunx bun-pr 42819

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

bun-42819 --bun

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

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

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

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still open from earlier reviews (2):

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

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

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

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

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

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

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

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

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

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

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

…n Windows only

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

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

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

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

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

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

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review completed

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

Still open from earlier reviews (1):

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

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

… stat and copy name files as its opens do

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

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

Comment thread src/runtime/webcore/blob/copy_file.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review 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.

dylan-conway and others added 3 commits September 26, 2026 21:42
…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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread src/jsc/job.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review 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.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still open from earlier reviews (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.

Comment thread src/jsc/job.rs Outdated
… 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.
Comment thread src/sys/windows/fs.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review found no issues

No high-confidence issues detected in this change.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

…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.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review found no issues

No high-confidence issues detected in this change.

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.
Comment thread src/jsc/job.rs Outdated
…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.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/jsc/job.rs Outdated
Comment thread src/jsc/job.rs Outdated
Comment thread src/jsc/job.rs Outdated
…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.
Comment thread test/js/node/fs/fs.test.ts
…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.
Comment thread packages/bun-usockets/src/eventing/iocp.c
Comment thread src/spawn_sys/windows/mod.rs

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants