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 21, 2026
… in a `try` only for a throw

A pattern or a store that reassigns, and stays in a `try` for a throw,
became an output of its memo block. The block then gave the variable
back on a path that did not assign it: `[label] = next` in a `try` on a
path that returns was `draft | draft | draft`, where main is
`draft | saved | draft`. Left out of the account of the memo blocks, as
#44805 has it, such a write loses its declaration or spoils a saved
dependency.

- In a client build, a function that holds an update, or a store or a
  pattern that reassigns, only for a throw is left as written. What a
  pattern declares is still recorded.
- A store that only what is kept for its throw reads is kept for a
  throw too: `find_semantic_only_caught_instructions` takes it again.
- `bun lint` keeps the rule of #44805 in both flavors, so its reports do
  not change.
- The branch no longer holds the change of #43603 to
  `remove_unnecessary_try_catch`. Since #46021 a function is left as
  written when the body of a `try` cannot throw and its handler has more
  than one block. One line of #43603 stays: a memo block assigns a
  function that is declared behind the `try`.
robobun added a commit that referenced this pull request Oct 21, 2026
…ent-keys

Conflicts:
- `errors_declaration_emit/commonjs.rs`: main now writes the key of `this[k]`
  through `get_text_of_node` (#46021). That hunk is main's.
- `errors_declaration_emit.rs`: main renamed a local and reworded two comments
  next to lines of this branch.
- `differential-cases.json`: the programs of this branch are appended to main's.
Jarred-Sumner added a commit that referenced this pull request Oct 21, 2026
…6270)

### What does this PR do?

Hardening follow-up to #46021. It fixes regressions from #46021 and
#44618 that were never released, includes more of the open pull requests
that fix a crash, and fixes a few crashes that had no pull request.

#### Regressions fixed (none was released)

- `spyOn(Bun, "$")`, and the same for `"sql"`, `"postgres"` and `"SQL"`,
threw "does not support accessor properties yet".
- Async `fs.read`, `fs.write` and `zlib.brotliCompress` on a view over a
`WebAssembly.Memory` could lose data or copy far more than they needed.
- `node:vm` with `vm.constants.DONT_CONTEXTIFY`: `this` in host
accessors, prototype handling, `util.types.isProxy`, `Bun.deepEquals`,
`assert.deepEqual` and `console.log` of a context.
- `zlib` `stream.flush()` threw for arguments it used to accept, and
streams had a new enumerable property.
- `Bun.Transpiler` `exports.replace` with the name `"default"`.
- `process.env.X = 3000` could store the number once the function was
JIT-compiled.
- `node:sqlite` and `bun:sqlite` could lose rows at exit.
- Dev server: stylesheets shared between pages after a build error.
- Shell `cp` of a file onto itself exited 1.
- `error.stack`: the frame of `require()` reads `at anonymous (unknown)`
again.
- `import * as process from "process"` followed by `process.on(...)`
threw at load. The EventEmitter methods that the module exports now act
on `process`.
- `delete res.statusCode; res.end()` in a `node:http` handler threw. It
sends 200, as in Node. (from #46128)
- `node:tls`, `node:https` and `node:http2`: connection teardown and
write callbacks behave as in 1.4.3 and Node again.

#### Crash fixes

Bundler, parser and CSS: #42286, #46092, #46097, #46177, #46239, #36334,
#43024, #40769.
`bun test`, `--hot` and `--watch`: #30776, #38206, #38974.
Shell: #43183, #37743, #46168, #46207, #34900.
`node:http`, `node:http2`, `Bun.serve`, streams: #43449, #45380, #44457,
#42197, #42485, #38082, #38124, #46074, #45999, #46045.
`node:vm`, native addons, workers: #46129, #46198, #45615, #33412,
#38031.
Dev server: #42790, #42850, #39488.
Other: #35991, #38030, #38586, #40526, #41830, #46029, #46078, #46114,
and the bug of #42215.

#### Behavior changes

- `crypto.randomFill(view, cb)` reports `ERR_CRYPTO_OPERATION_FAILED` if
it could not fill the view. It used to report success.
- A function registered twice on `process` runs twice, as in Node.
(#41830)
- Under `--hot`, a reload after an uncaught exception cancels the timers
that the failed run created. The new run's timers fire, and so do timers
and `fs.watchFile` watchers that earlier runs kept on `globalThis`. The
reload also resets the exit code to 0. (#38206)
- The parent of a Worker no longer gets an `'error'` event for a failed
`Bun.serve` request in that Worker. The error is printed, as on the main
thread.
- `hash.write()` after `digest()` drops the chunk, as in Node. (#35991)
- `http2` over TLS with `server.emit("connection", socket)`:
`session.socket` is a `TLSSocket` and a `'connection'` listener fires,
as in Node. (#38124)
- A `slice()` with no type of a body `Blob` has `type === ""`, as in
Node. (#46114)
- `util.types.isProxy(globalThis)` is false, as in Node.
- A write on a `node:net` or `node:tls` socket that is still pending
when the connection closes calls back with `ECANCELED`, as in Node.
- `HTMLRewriter.on()` throws for a selector that is nested too deeply.
- `Bun.Image`: `maxPixels` also bounds PNG metadata, `metadata()` of a
GIF reports the size that gets decoded, and a path with a NUL byte
throws. (#40526)
- `bun build --no-bundle` with the `base64` or `dataurl` loader prints
an error. (#36334)
- Bundled output for a default-exported function or class expression
changes to `var file_default = function name() {}`, as in esbuild.
`.name` is unchanged. (#46097)
- A Proxy in the prototype chain of a `node:vm` global is allowed. jsdom
30 with `runScripts` needs this.
- `bun:sqlite` commits a write that `iterate()` left open when the main
thread exits. An explicit `BEGIN` is still rolled back.
- When a Worker stops while a native addon's async work is still
running, the addon's finalizers run after that work is done, as in Node.
- Shell: `mv -n` and `cp -n` don't overwrite, and `cp` reads `R`, `v`
and `n` anywhere in a flag group.

#### Performance

- Ordinary `node:vm` contexts run at the same speed. With
`DONT_CONTEXTIFY`, stores to globals and reads of inherited names in a
hot loop cost 10 to 20 % more than on `main`.
- A `process.env` write costs 20 to 35 % less than after #46021.
- An async read into a `WebAssembly.Memory` view looks through the live
memories: about 1.5 µs more with one memory.

#### Not included

- Taken in part, so these should stay open for the rest: #36334, #38974,
#39488, #40526, #40769, #42485, #45615, #45999, #46029, #46092, #46114,
#46168, #46207, #46239.
- Needs a WebKit change: #34655 #39204 #40170 #40253 #41587 #41900
#42699 #42897 #42903 #43084 #43813 #44518 #44949 #45770 #45804 #45827.
- #44723 (covered by #46097), #41675, #42753, #38762, #41475, #46016.

### How did you verify your code works?

- Every item was reproduced first on release builds of `main`, of its
parent and of 1.4.3, and compared with Node v26 where Node has the API.
- Every fix has a test that fails on `main` and passes on a debug + ASAN
build of this branch, except where that isn't practical (huge inputs,
code that only runs on Windows or macOS).
- The regressions were checked on grids of calls against the build
before #46021 and against 1.4.3.
- Bun's own test suites and Node's `test-vm-*`, `test-http2-*` and
`test-zlib-*` files for the areas touched.
robobun added a commit that referenced this pull request Oct 21, 2026
…low-ups

main now carries the receive-buffer change, the mimalloc hook and the tests of
this branch, through #46021. This merge takes main's version of all of it and
keeps four follow-ups on top:

- With unsent data the socket closes as terminate() does, not with a reset, so
  the peer still gets what the kernel already accepted.
- The text copy for the message handler uses String::clone_utf8(..).into_js(..),
  which is fallible at every size. create_utf8_for_js still crashes for
  non-ASCII text under 64 KiB.
- The ws error says "Failed to allocate memory", Node's text for
  ERR_MEMORY_ALLOCATION_FAILED.
- The docs say that a socket with unsent data gets no Close frame.

Tests: delivered messages are checked by hash, the error handler's argument is
pinned, and there are new rows for an 8-bit text copy, a buffered compressed
message, a ping split across two reads, a socket that cannot write yet, the
close without a reset, and a subscribed socket.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment