Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one
by oven-shRust
Last 12 weeks · 1091 commits
5 of 6 standards met
Fixes #13388 writes the resolved version range into , but 's workspaces section kept a different string, so the very next rewrote the lockfile. With no package names, the lockfile kept the literal from the pre-resolution in-memory package.json: the string under , the stale old range otherwise. With package names, the lockfile got regardless of the user's pin level, and for aliases it dropped the alias target: Cause The lockfile's root dependency literals are rewritten before the save, but only for requests in (CLI positionals), by . Two gaps: with no package names tracks its targets through instead, which nothing preprocessed. itself hardcoded and ignored the alias prefix. The comment it carried described exactly this. In both cases got the correct pin-preserving literal from a separate computation in , so the two files could not agree. Fix One formatter, in : the resolved version at the same pin level (, , exact) as the user's literal, with the prefix preserved for aliases. It is the previously inline block in , extracted. Three paths now go through it: (new): the no-argument path, keyed off , mirroring . It writes the literal into the lockfile's root dependency and also records it on the entry; the post-install package.json edit now applies that recorded literal instead of re-deriving it. It cannot re-derive it anymore, because the lockfile dependency it used to read was just rewritten (concretely, its guard evaluated on the rewritten dependency would regress with ). (positional): when has an entry for the dependency (only records one), use the shared formatter. , which records nothing, keeps saving . The TODO comments are removed. 's post-install package.json write: the inline twin becomes a call to the helper. Its output is byte-identical. gains a field so the lockfile dependency is matched by name and dependency group, not name alone: with the same name in two groups, only moves one group in package.json, and the lockfile must leave the other group's entry alone too. Skipping literals in the new rewrite also means with no arguments no longer replaces a root entry in package.json with the resolved range, consistent with the existing test "should support catalog versions in update". Verification : 5 new cases (, plain, positional, and the two-group variant for both no-argument modes) assert that and agree on a plain and an -aliased dependency, and that the following has nothing left to save. All 5 fail on bun 1.4.0. A 13-case matrix (caret, tilde, exact, dist-tag, alias, two groups, root catalog, each crossed with , plain, and positional) against the npm registry produces byte-identical output before and after the change, and agreeing lockfiles only after.
Bun no longer links libuv on any platform. Windows event-loop, file, pipe, process, tty, signal, and DNS plumbing now run on an in-tree IOCP/AFD engine (), and JS-visible file descriptors are backed by Bun's own fd table () instead of the C runtime's lowio table. Why libuv forced three costly designs on Windows: 1. A second event-loop universe. Every loop-adjacent feature carried a uv twin: uv timers/idle handles re-implementing the JS timer wakeup, uv polls mirroring uSockets state, a embedded next to the usockets loop with manual keep-alive bridging between the two refcount domains. 2. A second fd ownership domain. JS fds were CRT fds (), so every fast path paid CRT lookups, and ownership (CRT vs ) was per-callsite convention. The CRT table also caps at 8192 entries. 3. A 4-hop async-fs chain. reads went JS → uv_fs threadpool → uv completion → JS-thread bounce, where used Bun's own worker pool with one hop. The measured cost is in the table below. What changed Event loop: one loop per thread (uSockets); the Windows backend is an in-tree engine implementing the existing contract. Sockets get readiness via AFD poll IOCTLs (the wepoll/libuv technique); pipes, files, processes, and wakeups deliver as completions on the same IOCP. The entire eventing backend is deleted from usockets, including the QUIC uv-timer arm — every backend now folds the QUIC deadline into its poll timeout. Completions posted for unref'd handles (e.g. an unref'd child's exit) are still collected during idle ticks, matching semantics. File I/O: synchronous NT-path-based wrappers (, ) plus worker-pool async; /// and are implemented natively (previously stubs whose errno constant collided with ). is fd-table-aware, so sequential I/O after a seek uses the logical position; // reach the engine's write-through/no-buffering arms instead of being dropped at the open boundary. fd table (): slot = HANDLE + kind + flags; bit-63 tagging keeps table indices and raw handles type-distinct; close-exactly-once enforced, including on adoption-failure paths. The CRT blob is still parsed/emitted at process boundaries, so fd passing to/from Node processes keeps working. Process/pipes/tty/signals/fs-watching: ConPTY-aware process handles; named pipe server/client preserving the libuv 16-byte IPC frame protocol (Node parent ↔ Bun child IPC unchanged); console tty handles with raw/raw-vt modes; console-ctrl signal bridge; directory watching on . Shell: regular-file writes are synchronous and return their completion directly (same shape as POSIX); pipes and console handles keep the async writer. Completion callbacks that fire while the interpreter is mid-step are queued and delivered in order instead of re-entering it. DNS: on the shared worker pool (ws2_32), same cache and request coalescing as POSIX. Process-global init that rode (error mode, CRT invalid-parameter handler, suspend/resume loop wake, console-ctrl hook) lives in the engine's . Winsock initializes at the first actual consumer — socket creation, AFD peer setup, , / — so network-free invocations never load the service-provider catalog. uv ABI surface for N-API addons: still exports the uv symbol set (305 symbols). 25 are real implementations (mutex/once/hrtime, getpid/getppid, kill, priorities, cpu_info, interface_addresses, rusage, RSS, guess_handle, strerror, version, get_osfhandle, …); the rest are loud crash stubs that name the unsupported symbol — the same policy POSIX builds ship with. An addon that previously drove uv handles on the Bun loop on Windows now fails fast with a clear message instead of silently sharing a loop that no longer exists. reports the emulated ABI level (1.51.0). : no longer assumes every native-stream source implements ; the lazy complete-buffer path produces a plain source without it (previously unreachable because uv opens never completed synchronously). Performance (Windows release builds, interleaved A/B runs) The gap was the uv chain: the same hardware serves 6.2 GB/s through both APIs now. Compatibility notes error returns are positive errnos (previously negative uv codes); in-tree consumers only truth-test the value. polyfill signal semantics match the engine: INT/QUIT/KILL/TERM terminate (exit code 1), 0 probes, others . Sockets passed as plain fds to were never supported on Windows; the rejection message changed shape. Addons mixing self-minted CRT fds with uv fd APIs get table-domain answers; uv file APIs themselves are crash stubs on all platforms. Testing New engine unit specs (108: loop, AFD poll, pipes, process, tty, signal, fs-event, wakeup, timers, unref'd-exit delivery through the public tick), fd-table specs including hostile parsing and a differential battery against ucrt's lowio table (same op script driven through /// and through the table, asserting element-wise identical observations), uv-errno mapping specs (miri-clean), and IPC frame known-answer tests. The uv stub/polyfill addon tests now run on Windows (previously POSIX-only): ~280 per-symbol crash-stub tests plus real-semantics tests through actual node-gyp-built addons. Suites at parity or better on Windows: timers, web streams, fs, process, spawn, net, tty, child_process, shell, serve. Cross-compiles clean for linux-gnu and darwin-aarch64; POSIX behavior unchanged (uv was already stubbed there).
What must never throw because of the value it is printing; that is the contract every relies on. Bun's (and everything built on it: , the class, diff rendering) violates it for any value carrying a throwing getter. Repro On 1.4.0 and current : So a logger that was asked to print instead throws, and if sits inside any container the real error is replaced by an unrelated internal . Cause Two bugs in : 1. The formatter read , , and directly, with no guards. Node.js wraps each of these in its own / (, ). A throwing getter escaped the same way, and non-string values were coerced with , which invokes a user (and throws a outright for values such as ). 2. 's treats every exception that is not its synthetic stack-overflow as an internal invariant violation and throws a fabricated , destroying the original error. Node re-throws the original (). Fix Port Node's guards, keeping Bun's surrounding structure: catches the getter and falls back to . Non-string stacks are rendered recursively via instead of , which also matches Node's output for , circular stacks, etc. wraps (falling back to when throws too, i.e. hostile /), and reads , and each under their own /. The duplicate-key hiding is inlined from Node so it reuses the already-read values instead of invoking the getters a second time; is deleted since that was its only caller. The read in is guarded the same way Node guards it. 's catch re-throws the original error instead of manufacturing the . Verification New test in covers throwing / / / / getters (alone, enumerable, all-at-once, and nested in containers) plus the non-string-stack shapes. Every exact expected string was captured from Node.js v26.3.0. Before / after for the full matrix The // rows print the full stack in Bun rather than Node's , because JSC's lazy does not invoke those getters while V8's does. That difference is pre-existing and benign; the contract (never throw) now matches. Three pre-existing assertions in locked in the old behavior and are updated to Node's current expectations (Node's own was updated the same way upstream): now renders instead of . The test already carried a . (throwing getter) now returns instead of throwing; Node's test asserts exactly that string. A was added to the file. This is unrelated to the fix: on unmodified , of this file already fails because the ported block takes 8.8s under a debug + ASAN build and exceeds the 5s default (CI passes an explicit , which is why it is green there). Without it the file cannot be run locally with a debug build at all.
Summary Fixes #30771 — in transcodes the close reason into a 128-byte stack buffer and then pointer-casts the buffer to before calling . When the transcoded UTF-8 lands in , the downstream read on the 125-byte array reference panics at runtime — and the panic crosses the boundary, aborting the process. Repro A UTF-16 reason of 42 × (126 UTF-8 bytes) passes the C++ spec check because is code units, not UTF-8 bytes: Runtime panic: Same failure mode via the Latin-1 arm ( can transcode 64 × 0x80 → 128 UTF-8 bytes) and the UTF-8 arm (any 126-byte pre-tagged UTF-8 slice). Cause Three transcoding arms in each write into : 16-bit: — succeeds up to 128 bytes before returning . UTF-8: — same. Latin-1: — returns only when the buffer ran out before the source, so up to 128 bytes of output slips through on a 128-byte output. Then is dereferenced as . The reference only carries 125 bytes of provenance; 's with panics on the bounds check (and is UB under Stacked/Tree Borrows even without the panic). Fix Shrink to . The existing overflow handling in all three arms ( → fall through to the no-reason ) now triggers exactly when the transcoded UTF-8 exceeds the RFC 6455 125-byte payload cap, matching the Zig original's intent without the unsafe cast. The array reference we hand to now covers the full 125-byte provenance, and the cast is gone. A catches any regression that reintroduces the bug. Test gains : a raw TCP server completes the WebSocket handshake, the client calls , and the test waits for on the client. Before: aborts with . After: .
What / arguments are silently corrupted before the native function sees them. With a C callee that reports what it actually received: arguments and the return path are correct; the bug is specific to the argument wrapper. These corrupt silently, and NaN, signed zero, and negative integers are exactly the values numeric C libraries assign meaning to. Cause in : nan_isnan: expected 1, got 0 negative_zero_signbit: expected 1, got 0 negative_bigint: expected "-5", got "5" huge_bigint: expected Infinity, got a thrown TypeError negative_huge_bigint: expected "-Infinity", got "Infinity" string: expected "2.5", got "NaN" (JSString pointer read as a double) undefined_arg: expected "NaN", got "0" ``bun bd test test/js/bun/ffi/int16_t32768!valffiWrappersFFIType.doublef64-0.0` and out-of-range BigInts). Whichever lands second is a one-hunk rebase, and this test applies unchanged either way.
Repository: oven-sh/bun. Description: Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one Stars: 93473, Forks: 4740. Primary language: Rust. Languages: Rust (67.6%), C++ (19.6%), TypeScript (8.2%), C (2.6%), JavaScript (1.2%). Homepage: https://bun.com Topics: bun, bundler, javascript, javascriptcore, jsx, nodejs, npm, react, rust, rust-lang, transpiler, typescript. Latest release: bun-v1.3.14 (1mo ago). Open PRs: 100, open issues: 6712. Last activity: just now. Community health: 100%. Top contributors: Jarred-Sumner, robobun, dylan-conway, nektro, cirospaciari, paperclover, Electroid, alii, colinhacks, pfgithub and others.