Skip to content

Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI - #46021

Merged
Jarred-Sumner merged 32 commits into
mainfrom
claude/hardening
Oct 20, 2026
Merged

Jarred-Sumner merged 32 commits into
mainfrom
claude/hardening

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Hardening. This collects the open pull requests that fix a crash in Bun into one change, and fixes about 20 more crashes that had no pull request.

"Crash" here means a segfault, a Rust panic, an abort, a failed assertion, memory corruption that a sanitizer reports, a native stack overflow, or an exception that ends the process and that the program can't catch. With this PR those cases throw a normal error, print a normal CLI error and exit 1, or just work.

306 pull requests are included: 227 whole, and 79 cut down to the crash fix where the rest was a refactor, a feature or a behavior change. Each one was checked against current main first. 45 more turned out to be fixed already, and 17 were duplicates. The full list is at the bottom.

Crashes you could hit in normal use

Most of the rest needs unusual input: strings near the 2 GiB limit, buffers that another thread or a getter resizes or detaches in the middle of a call, worker.terminate() at a bad moment, running out of memory or file descriptors, very deep nesting, or hostile arguments to bun:ffi, node:vm and bun:sqlite.

Behavior changes

Things that didn't crash before and behave differently now:

File formats

Performance

Measured in instructions per operation on release builds of this branch and its base, with an A/A run for the noise floor (0.0 to 0.2 %).

No change: Bun.serve, node:http, WebSocket echo and publish, bun build of 3,807 modules, bun run with 2,000 imports, readdir / Glob / fs.cp over 23,689 entries, startup and RSS. The binary grows by 176 KB (0.17 %).

Faster Instructions
WebSocket echo of a 1 MB non-ASCII text message −72 %
Bun.inspect of a large non-ASCII string −39 %
Bun.markdown.html on large non-ASCII input −33 %
$`cmd` with 300 environment variables −6 %
fs.readSync −5 %
TextEncoder.encodeInto, short ASCII strings −4 %
async crypto.pbkdf2, 16-byte inputs −17 %
Slower Instructions Why
cc() calls +5 to +51 per call Argument count and type checks, and a check for close() (#37989, #38057, #43802, #44921). dlopen() calls don't change
process.env.X = v 38 → 593 per write Writes aren't cached. Reads don't change (#38871)
async crypto.pbkdf2, inputs of 1 KB or more +4 to +9 % Password and salt are copied (#40554)
bun build with an object --define used thousands of times +4.5 % The object is copied per use (#45969)

Not included

How did you verify your code works?

  • Tests. Every fix has a test that fails on main (a release or an ASAN build) and passes on this branch, except where that isn't practical: code that only runs on Windows or macOS, inputs that need several GB of memory, and a few rare races. Those were checked by hand or by reading.
  • Stored repro scripts. 230 crash repros ran on release and ASAN builds of this branch and of main, side by side. Release: 88 no longer crash. ASAN: 106 no longer crash. None crashes that didn't before. What still crashes needs WebKit, is SQLite's own bug, or isn't a crash. The ASAN number is from a later commit than the release number.
  • Dev server. Because one fix makes requests wait, 2,171 generated programs ran random interleavings of edits, build errors, failing plugins, reloads, stops and disconnects with about 120,000 requests. No request hung, was answered twice or got a stale body. main hung in 82 of 1,161 programs. This also found the four dev server bugs listed above.
  • Path helpers. 336,000 boundary cases with buffers of exactly the computed size, and 12,003 random calls through 30 JS APIs on an ASAN build, compared with main. No sanitizer report, and the results match wherever main doesn't panic.
  • What worked on 1.4.3 still works. Reviewers compared behavior with 1.4.3 and took out what changed it: for example an output directory layout change for 9 or more entry points, a server.reload() change, and a changed error message for import("file:///50%deposit.ts").
  • Not run locally: the Windows, macOS and FreeBSD code paths. CI compiles and tests them.

Included pull requests

Bundler, transpiler, CSS, macros (33)

bun build --compile and standalone executables (3)

Resolver, module loading, plugins, paths (25)

Dev server, bun bake, routers, --watch and --hot (28)

Bun.serve, sockets, HTTP, WebSocket, HTTP/2, QUIC, DNS (28)

Streams, Blob, S3, files, node:fs (28)

Node-API, V8 API, bun:ffi, process.dlopen (15)

node:vm, workers, process, timers (19)

Shell, spawn, CLI, bun test, bun install (31)

Web APIs, Buffer, encoding, URL, crypto, zlib, SQLite, Redis and the rest (96)

Already fixed on main, or duplicates of a pull request above

Already fixed: #27323 #30401 #31011 #31206 #31566 #31626 #31695 #31702 #31945 #32773 #34154 #34697 #34744 #34964 #35655 #35816 #36020 #36181 #36296 #36342 #36581 #36602 #36807 #36818 #36857 #37172 #37235 #37239 #37267 #37420 #37731 #37914 #38113 #38119 #38509 #38514 #38540 #38975 #39410 #40749 #41163 #41171 #42108 #43085 #43230

Duplicates: #45654 #29986 #33378 #36895 #37070 #37639 #38014 #39021 #41266 #42250 #42951 #43238 #44259 #45335 #45565 #45726 #45965

Fixes #5019
Fixes #6106
Fixes #10690
Fixes #10886
Fixes #13566
Fixes #14745
Fixes #15551
Fixes #20454
Fixes #21310
Fixes #29145
Fixes #30454
Fixes #30613
Fixes #30766
Fixes #32103
Fixes #32142
Fixes #32183
Fixes #32212
Fixes #39060
Fixes #42296
Fixes #42931
Fixes #43671
Fixes #44201
Fixes #44735
Fixes #44783
Fixes #45198
Fixes #45327
Fixes #45491
Fixes #45721

@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 4:05 PM PT - Oct 20th, 2026

@Jarred-Sumner, your commit f48d297 is building: #129662

Comment thread src/runtime/napi/uv_posix.rs Outdated
Comment thread src/install/PackageInstaller.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/runtime/napi/uv_posix.rs
Comment thread src/runtime/shell/ParsedShellScript.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.

Beyond the inline findings, I also checked the Windows us_loop_free rework in packages/bun-usockets/src/eventing/libuv.c (uv_walk + bounded uv_loop_close retry) — every loop Bun creates passes a null hint to us_create_loop, so usockets owns the uv_loop it walks, the wait is deadline-bounded, and a loop that never closes is leaked rather than freed, so it is not a crash or misuse of a foreign loop.

Extended reasoning...

The us_loop_free change only runs on loops where is_default is 0, which is every loop in src/ since all us_create_loop call sites pass a null hint; the retry loop breaks at a 10s deadline and skips freeing the uv_loop when uv_loop_close still reports busy.

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

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🔴 src/jsc/bindings/sqlite/JSSQLStatement.cpp — A bun:sqlite user whose connection has a commit/update hook or user function can hit a use-after-free of a finalized statement, which the base never did. jsSQLStatementFunctionIterateEnd copies castedThis->stmt into a local at JSSQLStatement.cpp:2272 and only then runs CHECK_NOT_BUSY at :2275. That macro calls finalizeOrphans (:2710), which sqlite3_finalize()s GC-orphaned statements and so runs user hook JS. If that JS calls stmt.finalize() or db.close(true), the local stmt is freed, and :2278 passes it to sqlite3_reset. Fix: re-read castedThis->stmt after any CHECK_NOT_BUSY / finalizeOrphans and bail when it is null, at every site that captures stmt or db before the macro.

    Why this was flagged

    Trigger: a connection with a hook, user function or authorizer (Bun::sqliteCommitsWhenFinalized) has a GC-orphaned statement in VersionSqlite3::orphans (JSSQLStatement.cpp:193); user code then calls the iterator's return()/end, reaching jsSQLStatementFunctionIterateEnd. The local stmt is read at :2272 before CHECK_NOT_BUSY at :2275. CHECK_NOT_BUSY (:160-161) calls finalizeOrphans (:2710), whose sqlite3_finalize commits and runs the user's commit hook JS, and reportUncaughtExceptionAtEventLoop (:2718) can also run JS. If that JS calls stmt.finalize() on the same statement or db.close(true), castedThis->stmt is nulled and the sqlite3_stmt freed, but the stale local is still used at :2278 (sqlite3_reset(stmt), sqlite3_db_handle(stmt)): heap use-after-free. On the base branch orphans did not exist; finalization happened in the GC finalizer so no user JS ran between the null check and the use. Remedy: order CHECK_NOT_BUSY before any read of castedThis->stmt and re-check stmt for null afterwards.

    Verification: In src/jsc/bindings/sqlite/JSSQLStatement.cpp, jsSQLStatementFunctionIterateEnd copies auto* stmt = castedThis->stmt; at :2272 and only then runs CHECK_NOT_BUSY at :2275. That macro (:160-161) now calls finalizeOrphans. :2278 calls runCount(stmt) and sqlite3_reset(stmt) on the stale local. In the base file CHECK_NOT_BUSY (base :155-159) is a pure inCallback check.

  • 🟣 src/runtime/node/node_fs.rs:2881 — pre-existing: async fs.readv/fs.writev on a view of a non-shared WebAssembly.Memory still reads or writes freed memory after grow(), while this PR fixes only fs.read. FdVectorIo::from_js at node_fs.rs:2873-2881 pins the backing stores, but a pin does not keep a Wasm block alive (array_buffer.rs:757 comment), so the iovecs captured there point at a freed block once memory.grow() runs before the pool thread's readv/preadv (node_fs.rs:6276) or uv_fs_read (node_fs.rs:869). Fix: give the vector path the same protection fs.read got at node_fs.rs:4159-4163, so every async fd I/O argument (read, readv, write, writev) copies or uses scratch for buffers that can_be_freed_while_pinned and writes back after completion.

    Why this was flagged

    A script calls fs.readv(fd, [new Uint8Array(mem.buffer)], cb) where mem is a non-shared WebAssembly.Memory, then calls mem.grow(1) before the callback runs. FdVectorIo::from_js (node_fs.rs:2873-2881) collects iovec pointers via Bun__JSArray__collectBufferSpans (bindings.cpp:7065-7071), which only pin()s the ArrayBuffer. The diff's own doc on ArrayBuffer::can_be_freed_while_pinned (array_buffer.rs:757-760) states grow() detaches a non-shared Wasm memory's buffer regardless of the pin count and frees the old block. The pool thread then runs Syscall::preadv/readv (node_fs.rs:6276, 6288) into that freed block: either EFAULT surfaces as a spurious I/O error, or, if the allocator reused the pages, the kernel writes file bytes over live memory. This PR added write_to_scratch_if_wasm/write_back for args::Read only (node_fs.rs:4159-4163, 1131-1134); the base branch had the same exposure, so readv/writev remain as on base. The pin at node_fs.rs:2878-2881 looks like the safeguard but, per array_buffer.rs:757, does not cover Wasm memory.

    Verification: node_fs.rs:2873-2881 FdVectorIo::from_js calls VectorArrayBuffer::from_js; nothing copies or scratches Wasm-backed views. array_buffer.rs:184-187: grow() detaches a non-shared WebAssembly.Memory's buffer regardless of the pin count. The raw iovecs are consumed off the JS thread at node_fs.rs:6276/6288. No equivalent of the fs.read fix (node_fs.rs:4159-4163) exists on the vector path.

@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 (5):

  • 🔴 src/runtime/napi/uv_posix.rs:370 — A native addon thread that keeps calling uv_async_send faster than its callback returns can pin the JS thread inside on…
  • Also unresolved: 4 minor or pre-existing.

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/ZigGlobalObject.cpp Outdated
Comment thread src/runtime/ffi/ffi_body.rs
Comment thread src/runtime/ffi/ffi_body.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.

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

Comment thread src/jsc/bindings/sqlite/JSSQLStatement.cpp Outdated
Comment thread test/js/bun/ffi/cc.test.ts Outdated
robobun added a commit that referenced this pull request Oct 28, 2026
…h-dir-event-lazy-abs-path

main took the read-error guard and the path bounds checks of this branch
in #46021. Conflicts:

- src/jsc/hot_reloader.rs: the directory arm stays as on this branch and
  reads only the watchlist. `cached_dir_entry` from main has no caller
  left and goes with the tombstones. The new function uses
  `FileSystem::top_level_dir()` and `WatchItemIndex`.
- src/watcher/Watcher.rs: `WatchItemIndex` is `u32` as on main, the
  eviction list stays a `Vec`.
- src/watcher/lib.rs: both sets of exports.
- test/cli/hot/hot.test.ts, test/cli/watch/watch.test.ts: the tests of
  main, then the tests of this branch.
robobun added a commit that referenced this pull request Oct 28, 2026
…orted form

main has the two long-path tests since #46021, so the copies of this
branch go. The abort on a read-error listing is fixed on main too, so
the two `.env` rows, which guarded a second way to that abort, repeat
the `c.ts` rows and go as well. The rows that stay are named for what
they now prove: a later save still reloads. Without the abort a lost
reload is a hang, so their time limit is finite on a debug build.

New test: `--hot` with an import that is saved by rename after a failed
`import()` in the same folder.
robobun added a commit that referenced this pull request Oct 28, 2026
…on::entries` to the resolver

`WatchList::ensure_unused_capacity` reserves the table too, so
`append_assume_capacity` does not allocate.

`EntriesOption::entries` has two callers in the resolver and none in the
reloader since #46021, so this branch no longer deletes it.
robobun added a commit that referenced this pull request Oct 28, 2026
Remove the debug-only timeout of the two tests. The default applies.

Each child keeps two threads busy: the compress loop and the writer.
On a debug build, two children at once take 2.3 to 3.4 s each (median
of two batches), which is close to the 5 s default. One child at a
time takes 1.9 s.

On a release build from before #46021 the two tests fail as often
either way: 104 of 120 tests with two children at once, 102 of 120
with one at a time.
robobun added a commit that referenced this pull request Oct 28, 2026
- `JSValue::get_own_non_observable` borrows the name through `StringView`.
  `String::borrow_utf8` is gone since #46825.
- The two JSX props iterators set `include_symbols` (added in #41910) to
  `false`, which is what the observable iterator does on main.
- `JSC__JSValue__getOwnNonObservable` builds its property key with
  `Bun::toIdentifier`, like `JSC__JSValue__getOwn` since #46021.
- The two new `Bun.inspect` tests move to the JSX tests at the end of
  `inspect.test.js`. Above the `ErrorEvent` test they pushed its source
  line past 999, and the wider line-number gutter broke its inline
  snapshot.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants