jsc: free owned FFI allocations when the post-call trap check returns Err - #34577
jsc: free owned FFI allocations when the post-call trap check returns Err#34577robobun wants to merge 4 commits into
Conversation
… Err call_check_slow / from_js_host_call_generic run the FFI closure then check for a pending exception (including VMTraps). When a worker's terminate() sets the NeedTermination trap between the C++ function's own RETURN_IF_EXCEPTION and the Rust post-call check, the wrapper returns Err and the closure's return value is dropped. For closures that return an owned heap pointer (or a +1'd BunString, which is Copy with no Drop), that drop is a no-op and the allocation leaks. Same shape #34574 fixed for JSPropertyIterator. A sweep of every call site found six with an owning return: URL::from_js (*mut WTF::URL) URL::href_from_js (BunString +1) FetchHeaders::create_from_js (*mut WebCore::FetchHeaders) FetchHeaders::clone_this (*mut WebCore::FetchHeaders) JSBigInt::to_string (BunString +1) JSValue::serialize (SerializedScriptValue* via leakRef) Each now hoists the return into a local before the check and frees it on the Err branch. The test drives FetchHeaders::create_from_js in a worker while the parent terminate()s it. createFromJS's final guard is a bare throwScope.exception() (no trap handling), so the trap window spans the entire headers->fill() loop; 200 header entries make that wide enough for the LSan report to hit reliably (3/3 fail-before, 3/3 pass-after). Malloc=1 so LSan can see the WebCore allocation.
|
Warning Review limit reached
Next review available in: 6 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (10)
Comment |
|
Updated 4:36 AM PT - Jul 18th, 2026
❌ @robobun, your commit 090ff8e has 1 failures in
🧪 To try this PR locally: bunx bun-pr 34577That installs a local version of the PR into your bun-34577 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
Beyond the inline nit, I checked that String::deref() is a no-op for the Dead tag (src/bun_core/string/mod.rs:539), so out.deref() in href_from_js/JSBigInt::to_string is safe when C++ threw before returning a +1 impl; and that each of the six destructor pairings (WebCore__FetchHeaders__deref, URL__deinit, Bun__SerializedScriptSlice__free, String::deref) matches the allocation named in the PR table, with null/DEAD guards on every Err path so nothing double-frees when C++ returned no allocation.
Extended reasoning...
Verified the Err-path cleanup at each of the six sites cannot free an allocation that was never made: the raw-pointer sites guard on non-null / NonNull::new, and the two BunString sites rely on String::deref() gating on tag == WTFStringImpl (checked in bun_core), so DEAD is a no-op. Success paths are byte-for-byte behavior-preserving. Leaving unapproved because this is FFI memory-ownership code across six JSC wrappers — worth a maintainer glance even though the pattern is mechanical.
…to test Addresses review feedback: - Six open-coded hoist/check/free-on-Err blocks replaced with one named helper, call_check_slow_owned (and its from_js_host_call_owned alias), next to the existing per-shape wrappers in TopExceptionScope.rs. The trap-window hazard is documented once on the helper and pointed at from call_check_slow_at. - Test fixture now prints 'done' to stdout once every chain finishes, and the test asserts on that plus a null signalCode. Without a positive marker a missing fixture or crash-before-LSan satisfied the purely negative assertions. exitCode cannot be asserted because Malloc=1 surfaces pre-existing per-thread singletons that make LSan exit 1.
If the worker fixture is missing, 'error' fires before 'exit' and the chain previously still advanced to 'done'; the test's stderr scan only matches the six target C++ symbols, so a MODULE_NOT_FOUND passed the assertions with zero workers ever entering create_from_js.
There was a problem hiding this comment.
No issues found after the two fixture fixes — the failed gate in 090ff8e closes the last vacuous-pass path. Deferring to a human because this adds a new ownership primitive (call_check_slow_owned) to the exception-scope machinery and rewires six FFI sites' error-path destructors; worth a maintainer's eyes on the free-closure pairings.
What was reviewed:
call_check_slow_owned: closure runs exactly once on both cfg paths incall_check_slow_at, soslot.expect(..)is sound;free(r)only onErr.- Each of the six
freeclosures null-guards before calling the destructor;BunString::deref()is a no-op on Dead/Empty tags, so the two string sites are safe when C++ returned a Dead-tagged value. - Test fixture now gates
doneon!failed, so a missing worker fixture or worker error fails thestdout: "done"assertion.
Extended reasoning...
Overview
Adds call_check_slow_owned (and its from_js_host_call_owned alias) to TopExceptionScope.rs / host_fn.rs: same shape as call_check_slow, but takes a free: impl FnOnce(R) that runs on the closure's return before propagating Err. Six call sites whose C++ callee returns an owned heap allocation (URL::from_js, URL::href_from_js, FetchHeaders::create_from_js, FetchHeaders::clone_this, JSBigInt::to_string, JSValue::serialize) are converted to the new helper with matching destructor closures. An ASAN-only LSan test in worker_threads.test.ts drives FetchHeaders::create_from_js in a tight loop while the parent terminate()s the worker, and asserts none of the six symbol names appear in the leak report.
Security risks
None identified. This is a leak fix on the worker-termination path; no user-controlled data reaches a new sink, no auth/crypto/permissions code is touched.
Level of scrutiny
High. This is native memory-safety code at the Rust/C++ FFI boundary — the repo's most-blocked review category. The change introduces a new primitive that every future "owned-return FFI wrapper" will use, and each of the six converted sites pairs a C++ allocator with a specific destructor. A wrong pairing or a free on an already-freed value would be a use-after-free reachable from worker termination. I checked each pairing:
FetchHeaders(both sites):NonNull::new→if let Some(mut p) { p.as_mut().deref() }— matchesWebCore__FetchHeaders__deref; null-guarded.URL::from_js: raw*mut→if !p.is_null() { URL__deinit(p) }— null-guarded.URL::href_from_js/JSBigInt::to_string:BunString→s.deref(). Verifiedbun_core::String::deref()only acts onTag::WTFStringImpl(src/bun_core/string/mod.rs:539), so a Dead-tagged return (whichURL__getHrefFromJSproduces on failure per its doc comment) is a no-op — no double-free.JSValue::serialize:SerializedScriptValueExternal→if !ext.handle.is_null() { Bun__SerializedScriptSlice__free(ext.handle) }— null-guarded, matches the existingDrop for SerializedScriptValue.
The helper itself smuggles R out via Option<R> and matches on call_check_slow_at's Result<()>; both cfg branches of call_check_slow_at run f() exactly once before the exception check, so the .expect(..) cannot fail.
Other factors
Two prior inline concerns from me (vacuous-pass test, then the residual missing-fixture gap) were both addressed — the parent fixture now prints done gated on !failed, and the test asserts {stdout: "done", signalCode: null}. The PR description documents a demonstrated 3/3 fail-before / 3/3 pass-after with the LSan stack. The test is ASAN-only and probabilistic (race-window based), which is unavoidable for this bug class but means non-ASAN CI won't exercise it.
I'm not approving because this adds a reusable ownership primitive to the JSC exception-scope layer and touches six FFI destructor pairings — a maintainer should sign off on the helper's shape and confirm the sweep didn't miss sibling sites (the PR description notes &mut BunString out-param sites are deferred to a follow-up).
|
CI builds #75210 and #75287 both show only pre-existing or flaky-retry failures ( The diff is green. Ready for review. |
…ll_jsc, node-fallbacks, and misc crates (#39420) Net -4,402 lines (172 files, +509 / -4,911). Everything removed has zero references across `src/`, `scripts/`, `test/`, `packages/` and the regenerated `build/debug/codegen/` output, and the removal builds: removing a Rust or C++ definition that still had a caller fails to compile or link, so a green build is the reference check for those; JS, codegen and manifest removals were additionally grepped by name (including `bun:internal-for-testing` consumers under `test/`). Most of these were first identified by the sweeps that were closed yesterday for merge conflicts (#37272, #37062, #38439, #37089, #38703). This PR re-applies the subset that still applies on current main, minus anything an open PR already deletes and minus small hunks in files that change daily (see "Left out" below), plus a few new finds. ### react_compiler (-1,166) - `validate_no_derived_computations_in_effects_exp` and its ~22 exclusive helpers/types (~1.1k lines in `validation/validate_no_derived_computations_in_effects.rs`). The pipeline only ever calls the non-`_exp` validation; the `_exp` env-config flag was parsed from fixture pragmas and read by nothing. `react-compiler-fixtures.test.ts` now lists the two pragmas as ignored instead of handled; the fixture suite still passes. - `SymbolHost` (back-compat alias of `Host`; `DESIGN.md` updated), `HirBox`, `is_use_state_type`, `default_true`. ### C++ bindings (-1,350 across 64 files) - `JSDOMConvertBufferSource.h`: the IDL typed-array specializations and `toPossiblyShared*Array` helpers for every element type no binding converts (only the Uint8Array/ArrayBuffer views are used). - `JSDOMPromiseDeferred.h/.cpp`: `resolveWithJSValue`, `resolveWithNewlyCreated`, `resolveCallbackValueWithNewlyCreated` and friends. - `IDLTypes.h`: the `IDLUnsupportedType` family, the IDB / WebGL / `ScheduledAction` wrappers and stale forward declarations; `JSDOMConvertDate.h/.cpp` (the only `IDLDate` converter) deleted along with its two includes. The 8-line `IDLDate` struct itself stays for now (see "Left out"). `JSDOMConvertScheduledAction.h`, orphaned in the same way, is already deleted by #35775, so it is not touched here. - `NetworkLoadMetrics.h`: WebKit networking-stack fields/accessors bun never reads. - Deleted files: `JSWorkerOptions.h/.cpp` (`JSWorker.cpp` builds `WorkerOptions` by hand), `JSMIMEBindings.h/.cpp` (`createMIMEBinding` had no callers; include dropped from `ZigGlobalObject.cpp`), `JSDOMIterator.cpp` (`addValueIterableMethods`). - `ncrypto.h/.cpp`: `peekError`, `BignumPointer::isZero`, `EVPKeyCtxPointer::sign`, unused copy/move `operator=` overloads and an `AsymmetricKeyEncodingConfig` constructor. - Smaller: `JSDOMOperationReturningPromise.h` (`call*ReturningOwnPromise`), `JSDOMAttribute.h` (`setPassingPropertyName`, `setStatic`), `JSDOMGuardedObject` (`DoNotRegisterWithGlobalObjectTag`), `Event`/`EventTarget` (`resetBeforeDispatch`, default-handled flags, `isNode()`), `EventEmitter::{eventTypes,eventListeners}`, `HTTPHeaderIdentifiers::identifierFor`, `JSURLPatternResult` `convertDictionary` stubs, `PerformanceTiming::monotonicTimeToIntegerMilliseconds`, `ContextDestructionObserver::protectedScriptExecutionContext`, `expectedEnumerationValues<CryptoKeyUsage>`, `BufferSource::mutableData`/`toBufferSource`, `BunString__toInt32`, `BunString::utf8ByteLength`, the `Ref<StringImpl>` overload of `toCrossThreadShareable`, `normalWorld()`, `TextEncoding(const String&)`, `JSBuffer` `createBuffer`/`constructFromEncoding` overloads, the `JSX509Certificate` `m_infoAccess` lazy property and the non-legacy branch of `computeInfoAccess` (the prototype getter reads the view directly; x509 tests pass), `BakeAdditionsToGlobalObject::wrapComponent`, `jsFunction_lsanDoLeakCheck`, and the dead `BunObject+exports.h` macro entries (together with the Rust `BunObject_callback_nanoseconds` export the `nanoseconds` entry declared; `Bun.nanoseconds` is `functionBunNanoseconds` in `BunObject.cpp`). ### install_jsc / install_types / bun:internal-for-testing (-344) - `install_jsc/dependency_jsc.rs` and `update_request_jsc.rs` deleted: their only consumers were the `npa` / `npmTag` exports of `bun:internal-for-testing`, which no test imports. The `dispatch_js2native.rs` re-exports, the `generate-js2native.ts` file-map entry and the `lsanDoLeakCheck` export (tests use `isASANEnabled`) go with them. - `install_types/lib.rs`: the `ExternalString` / `SlicedString` / `SemverString` re-export modules; nothing names those paths (everything imports the types from `bun_semver`). New in this PR. ### node-fallbacks (-400) and codegen (-392) - `util.js`: a ~270 line commented-out `util.types` block. - `package.json` / `bun.lock` / `tsconfig.json`: dependencies and path mappings nothing imports (`esbuild`, `buffer`, `events`, `util`, `url`, `process`, `path-browserify`, `os-browserify`, `timers-browserify`, `tty-browserify`, `vm-browserify`, ...). `build-fallbacks.ts` marks every builtin name external, and the remaining sources only import the packages still listed; `bun install --frozen-lockfile` in the directory is a no-op and `bundler_browser.test.ts` passes. - `src/codegen/generate-unified-source-bundles.rb`: WebKit's Ruby generator, superseded by `scripts/build/unified.ts`; nothing invokes it. ### Rust crates (-1,200 across bun_jsc, bun_runtime, css, uws_sys and leaf crates) - bun_jsc: `BuiltinName::get` + `BUILTIN_NAME_MAP`, `MarkedArrayBuffer::to_js` (the `ArrayBuffer::alloc` Uint8Array arm and its `Bun__allocUint8ArrayForCopy` binding are kept, so `alloc` keeps mirroring `create`), the `__dangerouslySetPtr` wrapper `js_class_module!` emitted into every class, `ErrorBuilder::new`, `TopExceptionScope::new`, `Task::new`, `JSCell::to_js`, `JSGlobalObject::{to_js,ref_,ctx}`, `job::{on_js_thread,off_thread}`, `AbortReason` impl, `JSPromise` settle helpers, `UUID::ZERO`, `TagPayload::get`. - bun_runtime: the `target_os = "wasi"` directory-iterator backend in `dir_iterator.rs` (no shipped target is wasi and the `bun_sys::wasi` module it imports does not exist), the `Display` impls in `assert/myers_diff.rs`, the non-unix stub and not-macos escapes in `fs_events.rs` (the file is only compiled on macOS), `ArrayBufferSink::to_js`, unused re-exports in `node.rs` / `api/bun/spawn.rs` / `ffi/mod.rs` (with `abi_type` formatters narrowed to `pub(crate)`), `Error::UnableToDecode`, `MyersDiff::Error::OutOfMemory`. The first three are new in this PR. - css: the inherent `eql` / `to_css` / `parse` forwarders whose callers all go through the `CssEql` / `ToCss` / `Parse` trait impls (`values/calc.rs` and friends), `generics::{implement_eql,parse}`, `TokenList::parse_with_options`, `CssString::parse`. - uws_sys: the `uws_loop_defer`, `uws_res_clear_corked_socket`, `uws_ws_iterate_topics` and `uws_h3_req_get_parameter` C shims plus their Rust declarations and wrappers (`Loop::{uncork,wake,next_tick,run}`), `AnyResponse::init`, `SocketGroup::is_empty`, `socket.rs` `group()` accessors and the `SocketTcp`/`SocketTls` aliases, `Opcode::Close`, `WindowsLoop`. - leaf crates: `windows_sys` constants and their `bun_sys::windows` re-exports, `zlib`/`zlib_sys` declarations (`deflateInit_`, `inflateInit_`, the `gz*` file API, legacy type aliases), `sha_hmac` deprecated-API hashers (`SHA512` raw, `RIPEMD160`, `MD5_SHA1`, `Blake2` evp), `wyhash` `HashInt` impls for u16/u64, `libarchive` commented-out Zig-era callbacks, `bun_alloc` (`AllocError::name`, `usable_size`, `BSSList::init`), `clap::Error::WriteFailed`, `csrf` error variants, `pe::Error::{InputIsSigned,InsufficientSpace}`, `md` `Setextheader`, `opaque_mut_nn`, `cares_sys` `AddrInfo_hints::is_empty`, `boringssl_sys` constants, `errno` `Mode` re-exports, `bounded_array::get`, `string::write::Result`, `SplitIterator::rest`, `OutOfRangeValue` impls, `symbol::Map::init`, `sql_jsc` re-exports. ### src/js (-32) - Unused REPL primordials entries in `internal/repl/node-primordials.js`; with that gone `SafeWeakSet` had no importer, so `internal/primordials.js` stops exporting it (new in this PR). Unused export-object entries in `internal/fs/watch.ts` and `internal/readline/interface.js`. ### scripts (-9) - `glob-sources.ts` `src/*.c` pattern (matched nothing since `asan-config.c` was deleted), the write-only `BUN_DEP_*` defines in `depVersionsHeader.ts`, the unread `kqueue` config field. ### Verification - `bun bd` (full debug build) passes. - `bun run rust:check-all`: all 11 target triples ok (covers the windows/darwin-only removals in `windows_sys`, `sys/windows`, `zlib_sys/win32.rs`, `fs_events.rs`, `windows-shim`). - `cargo fmt --check`, `cargo clippy --workspace`, clang-format on every touched C++ file, prettier, and `bun run lint` are clean. - All of `test/internal/source-lints/` (including the new `dead-symbols-react-compiler-webcore-idl-misc.test.ts` that guards these symbols, and `dead-code-escapes` against the updated `dead-code-escape-limits.json`), `react-compiler-fixtures.test.ts`, `bundler_browser.test.ts`, css, cryptohasher/hash, node:assert, zlib, url, events, websocket, inspect and x509 tests pass. `serve.test.ts` has the same 4 failures as the unmodified release build in this container (IPv6 / root port range), nothing else. ### Left out on purpose (follow-up candidates) - The pre-engine inbound path in `h2_frame_parser.rs` (~2.2k lines, still dead, #37272's diff still applies cleanly): the file has had 15 commits in the last two weeks, so it is better landed on its own. - Deletions already owned by open PRs: simdutf wrappers (#38958), `getStackTraceForThrownValue` (#37450), `validateOneOf` (#38401), the redis error variants (#34829), `URL::from_js` (#33889 / #34577), `schema::api` re-exports (#37095), `FsPath` (#39327), `NodeJSFS` `Null` impl (#38065), the deprecated selector `to_css` (#33332). - Small hunks in high-churn files (`bindings.cpp`, `ZigGlobalObject.cpp`, `Blob.rs`, `streams.rs`, `BunProcess.cpp`, the `ManifestLoad::LoadFromMemory` parameter across `src/install`, the watcher `loader` parameter), and the `#[no_mangle]` statics (`Zig_ErrorCode*`, `Bun__versions_*`) nothing on the C++ side reads. - `VM::has_termination_request` and its `JSC__VM__hasTerminationRequest` shim in `bindings.cpp`: a dead pair, kept intact here because `bindings.cpp` is the most actively edited file in the tree; both halves go together in a follow-up. - `IDLDate` in `IDLTypes.h`: its only converter is deleted here, but the struct itself is left in place so this PR's file deletions stay independent of the header edits; removing the struct is a one-hunk follow-up once the converter files are gone. - Found but not removed here: the windows shim's `ReadWithoutLaunch` mode (~110 lines, overlaps #36200), `node_quic_binding.rs` constants JS never destructures (~35 lines), the native `NodeJSFS.unwatchFile` / `FSWatcher.hasRef` / `QuicSession.silentClose` / `QuicEndpoint.ref` bindings JS never calls, never-constructed `bun_install::Error` variants, and ~160 lines of `$`-declarations in `src/js/builtins.d.ts` with no users. <!-- robobun:evidence:begin --> --- **[review]** gate passed · iteration 1 · 172 files touched <details><summary>fails on main (without fix)</summary> ```console ASAN without fix: 10 failed, 320 skipped $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/react-compiler-fixtures.test.ts test/internal/source-lints/dead-symbols-react-compiler-webcore-idl-misc.test.ts bun test v1.4.0 (8326d1b) test/internal/source-lints/dead-symbols-react-compiler-webcore-idl-misc.test.ts: 88 | // Arena box alias with zero uses (HirVec is the one HIR actually uses). 89 | ["src/react_compiler/hir/mod.rs", /\bHirBox\b/], 90 | // Type predicate whose only callers were in the removed _exp validation. 91 | ["src/react_compiler/hir/mod.rs", /\bis_use_state_type\b/], 92 | ]; 93 | expect(resurrected(checks)).toEqual([]); ^ error: expect(received).toEqual(expected) - [] + [ + "src/react_compiler/validation/validate_no_derived_computations_in_effects.rs: validate_no_derived_computations_in_effects_exp", + "src/react_compiler/hir/environment_config.rs: validate_no_derived_computations_in_effects_exp", + "src/react_compiler/program.rs: validate_no_derived_computations_in_effects_exp", + "src/react_compiler/program.rs: \bSymbolHo ... (truncated) release without fix: 10 failed, 1146 skipped bun test v1.4.0-canary.1 (21a4206) test/internal/source-lints/dead-symbols-react-compiler-webcore-idl-misc.test.ts: 88 | // Arena box alias with zero uses (HirVec is the one HIR actually uses). 89 | ["src/react_compiler/hir/mod.rs", /\bHirBox\b/], 90 | // Type predicate whose only callers were in the removed _exp validation. 91 | ["src/react_compiler/hir/mod.rs", /\bis_use_state_type\b/], 92 | ]; 93 | expect(resurrected(checks)).toEqual([]); ^ error: expect(received).toEqual(expected) - [] + [ + "src/react_compiler/validation/validate_no_derived_computations_in_effects.rs: validate_no_derived_computations_in_effects_exp", + "src/react_compiler/hir/environment_config.rs: validate_no_derived_computations_in_effects_exp", + "src/react_compiler/program.rs: validate_no_derived_computations_in_effects_exp", + "src/react_compiler/program.rs: \bSymbolHost\b", + "src/react_compiler/lib.rs: \bSymbolHost\b", + "src/react_compiler/hir/mod.rs: \bHirBox\b", + "src/react_compiler/hir/mod.rs: \bis_use_state_type\b", + ] - Expected - 1 + Received + 9 at <anonymous> (/workspace/bun/test/internal/source ... (truncated) ``` </details> <details><summary>passes on PR (with fix)</summary> ```console ASAN with fix: 320 skipped $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/react-compiler-fixtures.test.ts test/internal/source-lints/dead-symbols-react-compiler-webcore-idl-misc.test.ts bun test v1.4.0 (8326d1b) test/internal/source-lints/dead-symbols-react-compiler-webcore-idl-misc.test.ts: (pass) dead react_compiler symbols do not reappear [21.23ms] (pass) dead exe_format symbols do not reappear [2.63ms] (pass) dead bun_core / bun_alloc / bun_ast / bun_ptr items do not reappear [18.66ms] (pass) dead bun_css items do not reappear [21.91ms] (pass) dead bun_jsc items do not reappear [20.67ms] (pass) dead FFI-crate items do not reappear [30.81ms] (pass) dead Rust symbols (install, webcore, jsc, leaf crates) do not reappear [18.03ms] (pass) unused re-export names do not reappear [12.35ms] (pass) stale build-script entries do not reappear [6.80ms] (pass) dead Rust FFI wrappers and trait methods do not reappear [22.18ms] (pass) dead C++ binding helpers do not reappear [99.82ms] (pass) dead WebCore / IDL binding code does not reappear [114.33ms] (pass) dead code in install_jsc, install_t ... (truncated) release with fix: 1146 skipped $ bun scripts/build.ts --profile=release [configured] bun-profile → bun (stripped) in 641ms (unchanged) ninja: Entering directory `/workspace/bun/build/release' [1/130] install /workspace/bun/src/node-fallbacks bun install v1.4.0-canary.1 (21a4206) Checked 111 installs across 104 packages (no changes) [3.00ms] [2/130] gen node-fallbacks/react-refresh.js Bundled 1 module in 5ms react-refresh.js 4.81 KB (entry point) [3/130] gen JSBuffer.lut.h Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp [4/130] gen generated_host_exports.rs generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 241 extern-C blocks audited [5/130] gen node-fallbacks/*.js [6/130] gen cpp.rs (cppbind) [7/130] gen JS modules (bundle-modules) Preprocess modules (7887ms) Bundle modules (42ms) Postprocesss modules (259ms) Bundle Functions (695ms) Generate Code (32ms) [8.94s] Bundled "src/js" for production 2630 kb 197 internal modules 13 native modules 91 internal functions across 17 files [7/129] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu) nightly-2026-07-20-x86_64-unknow ... (truncated) ``` </details> <details><summary>diff hotspot</summary> ``` scripts/build/config.ts | 4 - scripts/build/depVersionsHeader.ts | 3 - scripts/build/source.ts | 2 +- scripts/glob-sources.ts | 1 - src/ast/symbol.rs | 8 - src/boringssl_sys/boringssl.rs | 8 - src/bun_alloc/lib.rs | 29 +- src/bun_core/bounded_array.rs | 8 - src/bun_core/fmt.rs | 17 - src/bun_core/string/immutable.rs | 7 - src/bun_core/string/mod.rs | 11 - src/bun_core/string/write.rs | 3 - src/bun_core/windows_sys.rs | 2 +- src/cares_sys/c_ares.rs | 6 - src/clap/error.rs | 9 - src/clap/lib.rs | 5 - src/codegen/generate-js2native.ts | 1 - src/codegen/generate-unified-source-bundles.rb | 392 ------- src/csrf/lib.rs | 3 - src/css/css_parser.rs | 19 +- src/css/generics.rs | 10 - src/css/lib.rs | 2 +- src/css/properties/custom.rs | 8 +- src/css/rules/mod.rs | 7 +- src/css/values/angle.rs | 4 - src/css/values/calc.rs | 132 +-- src/css/values/css_string.rs | 6 - src/css/values/time.rs | 7 - src/css_derive/lib.rs | 4 +- src/css_jsc/css_internals.rs | 11 - src/errno/darwin_errno.rs | 1 - src/errno/freebsd_errno.rs | 1 - src/errno/linux_ ... (truncated) ``` </details> **gate history** · 4 passed · 1 rejected · iteration 1 <details><summary>evidence per changed file</summary> ``` file reads edits tests scripts/build/config.ts 0 0 0 scripts/build/depVersionsHeader.ts 0 0 0 scripts/build/source.ts 0 0 0 scripts/glob-sources.ts 0 0 0 src/ast/symbol.rs 0 0 0 src/boringssl_sys/boringssl.rs 0 0 0 src/bun_alloc/lib.rs 0 0 0 src/bun_core/bounded_array.rs 0 0 0 src/bun_core/fmt.rs 0 0 0 src/bun_core/string/immutable.rs 0 0 0 src/bun_core/string/mod.rs 0 0 0 src/bun_core/string/write.rs 0 0 0 src/bun_core/windows_sys.rs 0 0 0 src/cares_sys/c_ares.rs 0 0 0 src/clap/error.rs 0 0 0 src/clap/lib.rs 0 0 0 (+ 156 more files) ``` </details> <!-- robobun:evidence:end --> --------- Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
What
call_check_slow/from_js_host_call_genericrun the FFI closure, then check for a pending exception (including VMTraps). If a worker'sterminate()sets theNeedTerminationtrap in the window between the C++ function's ownRETURN_IF_EXCEPTIONand the Rust post-call check, the wrapper returnsErrand the closure's return value is dropped. When that value is a raw heap pointer (or a +1'dBunString, which isCopywith noDrop), the drop is a no-op and the allocation leaks.Same class of bug #34574 fixed for
JSPropertyIterator, without theRef<VM>cascade: here the leak is just the one allocation per occurrence, so~VMstill runs.Cause
A sweep of every
call_check_slow/from_js_host_call_genericcall site turned up six whose closure returns an owned allocation from C++:URL::from_js*mut URLnew WTF::URL(BunString.cpp)URL__deinitURL::href_from_jsBunString+1toStringRef(BunString.cpp)String::derefFetchHeaders::create_from_js*mut FetchHeadersnew WebCore::FetchHeaders(bindings.cpp:1813)WebCore__FetchHeaders__derefFetchHeaders::clone_this*mut FetchHeadersnew WebCore::FetchHeaders(bindings.cpp:1860)WebCore__FetchHeaders__derefJSBigInt::to_stringBunString+1toStringRef(JSBigIntBinding.cpp)String::derefJSValue::serializeSerializedScriptValue*leakRef()(Serialization.cpp)Bun__SerializedScriptSlice__freeFor
FetchHeaders::create_from_jsthe window is wider than just the return instruction: the final C++ guard is a barethrowScope.exception()(no trap handling), so a trap set duringheaders->fill()is not observed until the Rust side checks, andfill()iterates every header entry.Fix
Adds
call_check_slow_owned(global, f, free)inTopExceptionScope.rs(with afrom_js_host_call_ownedalias inhost_fn.rs): same shape ascall_check_slow, butfreeruns on the closure's return beforeErris propagated. The hazard is documented once on the helper and pointed at fromcall_check_slow_at's doc comment. All six sites become a single call with the matching destructor as thefreeclosure.Verification
New ASAN-only test
terminate() during an allocating FFI wrapper does not leak the allocationinworker_threads.test.ts: a worker tight-loopsnew Response("", {headers: {200 entries}})(drivesFetchHeaders::create_from_js) while the parentterminate()s it, underMalloc=1 detect_leaks=1. 4 processes x 42 workers each; the parent fixture printsdoneonce every chain finishes and the test asserts on that plus a nullsignalCode.LSan fail-before stack (3/3 runs):
Pass-after 3/3.
worker_threads.test.ts92 pass,headers.test.ts99 pass,response.test.ts23 pass.Malloc=1also surfaces pre-existing per-thread singletons (WebCore::eventNames,Bun.main's AtomString) that are normally hidden by bmalloc, so the test assertion is scoped to the six C++ function names above rather than a blanket LSan check.A couple of sites use an
&mut BunStringout-param instead of a closure return (JSValue::get_name,jsonStringify); those have the same hazard at the caller and are left for a follow-up since the fix shape is different.no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/worker_threads/worker_threads.test.ts