test: make the ported Node suite run leak-clean under the ASAN runner, and fix the teardown UAFs and leaks it surfaces - #31833
test: make the ported Node suite run leak-clean under the ASAN runner, and fix the teardown UAFs and leaks it surfaces#31833cirospaciari wants to merge 23 commits into
Conversation
…e local ASAN runner Fixes seven native bugs surfaced by running all 2322 ported Node tests with LeakSanitizer + BUN_DESTRUCT_VM_ON_EXIT: a subprocess stdio fd double-close, ConsoleObject and Strong-handle teardown UAFs, SAN ASN1 / scope-function / valkey URL string leaks, and a post-exit child_process stream crash. Adds runner tmpdir isolation and verified leak suppressions for OS/JSC exit-time false positives.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughChangesThe PR changes URL string-returning APIs to use URL Ownership Refactor
JSC Teardown and Ownership
Child Process Stdio Handling
Test Infrastructure and Verification
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Heads up on an overlap: #31990 (fixing the Sentry worker-shutdown segfaults BUN-3DSK/BUN-3END in |
alii
left a comment
There was a problem hiding this comment.
Add one extern for BoringSSL's GENERAL_NAMES_free and delete the whole hand-rolled sk_GENERAL_NAME_pop_free + type-punned shim machinery — the shim exists only to work around a mistyped callback signature that shouldn't survive this PR.
BoringSSL exports it: vendor/boringssl/include/openssl/x509.h:308 typedef STACK_OF(GENERAL_NAME) GENERAL_NAMES; and :2087 OPENSSL_EXPORT void GENERAL_NAMES_free(GENERAL_NAMES *gens); (deep free via IMPLEMENT_ASN1_FUNCTIONS_const(GENERAL_NAMES) at crypto/x509/v3_genn.cc:73). BoringSSL uses it for exactly this — freeing SAN results — at crypto/x509/x_x509.cc:81, and its own Rust bindings at rust/bssl-x509/src/certificates.rs:153 use it in Drop. Root cause the PR does NOT fix: src/boringssl_sys/boringssl.rs:444 declares sk_GENERAL_NAME_free_func = unsafe extern "C" fn(*mut struct_stack_st_GENERAL_NAME) — wrong; the C header stack.h:402 generates typedef void (*sk_GENERAL_NAME_free_func)(GENERAL_NAME *) (element, not stack). That mistyped signature is why GENERAL_NAME_free couldn't be passed directly and why the original code passed the container-free (leaking). The PR's sk_GENERAL_NAME_element_free (boringssl.rs:480, boringssl.zig:2974) casts *mut struct_stack_st_GENERAL_NAME → *mut GENERAL_NAME inside the callback to satisfy the wrong type — a shim over the bug. Call-site census: sk_GENERAL_NAME_pop_free/sk_GENERAL_NAME_free/sk_GENERAL_NAME_call_free_func/sk_GENERAL_NAME_free_func/sk_GENERAL_NAME_element_free are used ONLY at the 4 sites this PR touches (src/boringssl/lib.rs:363, src/boringssl/boringssl.zig:180,202) — all become `GENERAL_NAMES_free(nam
Fix: Replace the fix with: (1) add pub fn GENERAL_NAMES_free(gens: *mut struct_stack_st_GENERAL_NAME); to the extern block in src/boringssl_sys/boringssl.rs and the equivalent pub extern fn GENERAL_NAMES_free(gens: ?*struct_stack_st_GENERAL_NAME) void; in boringssl.zig; (2) call sites become defer boring.GENERAL_NAMES_free(names) (Zig) and scopeguard::guard(names, |n| boring::GENERAL_NAMES_free(n)) (Rust); (3) delete sk_GENERAL_NAME_element_free, sk_GENERAL_NAME_pop_free, sk_GENERAL_NAME_call_free_func, sk_GENERAL_NAME_free, sk_GENERAL_NAME_free_func and the GENERAL_NAME_free extern from both boringssl_sys files — none have other callers. Net: ~2 lines added, ~40 lines deleted
The valkey OwnedString wrap is the third independent per-call-site fix for the same +1-BunString footgun; the URL getters themselves should return OwnedString so the type system enforces the deref, and there are live unfixed leaks of the identical class right now.
Root cause at binding layer: src/jsc/URL.rs:18-34,92-151 and src/url/lib.rs:140-184 — 12 getters (protocol/href/username/password/search/host/hostname/pathname/hash/fragment_identifier) + 5 free fns all return bun_core::String (Copy, no Drop). C++ side src/jsc/bindings/BunString.cpp:286,294 does wtfString.impl()->ref() — always +1. bun_core/string/mod.rs:1249-1253 documents String is deliberately Copy-no-Drop for FFI; OwnedString (line 1259) is the RAII wrapper and Derefs to String (line 1291-1297), so returning it is near-transparent to callers.
Footgun already hit and half-fixed in 3 files independently: (a) fetch.rs:257-259 has a comment "href_from_js returns a +1 (Bun::toStringRef)" then wraps in OwnedString; (b) hosted_git_info.rs wraps 7 sites (993,1007,1382,1443,1498,1563,1640) in OwnedString but leaves 7 sibling sites bare; (c) this PR wraps 5 more in js_valkey.rs and adds ANOTHER per-call-site comment (js_valkey.rs:559-560) documenting the API contract.
LIVE UNFIXED LEAKS of the identical class this PR misses: (1) src/runtime/socket/SocketAddress.rs:221 let host: BunString = url.host(); — never deref'd (grep confirms no host.deref() in file), used via .latin1() then dropped. (2) src/install/hosted_git_info.rs:292,322,1351,1419,1477,1531,1616 — url.pathname().to_owned_slice() / .fragment_identifier().to_owned_slice(); to_owned_slice (mod.rs:762) takes &self, c
Fix: Change src/jsc/URL.rs getters (protocol/href/username/password/search/host/hostname/pathname/hash/fragment_identifier) and the free functions (href_from_string/href_from_js/join/file_url_from_string/path_from_file_url) to return bun_core::OwnedString instead of bun_core::String; do the same in the duplicate src/url/lib.rs::whatwg impl block. OwnedString Derefs to String so most call sites need no change; the ones that do (e.g. Request.rs:977 storing the value) call .into_inner()/take. Delete the per-call-site OwnedString::new() wraps in js_valkey.rs, fetch.rs, hosted_git_info.rs. This also fixes the live leaks at SocketAddress.rs:221 and hosted_git_info.rs:292,322,1351,1419,1477,1531,1616 wi
The double-close fix mutates ownership inside a public, uncached, readonly getter — trading child_process's loud fd-UAF for a silent fd leak on the bare Bun.spawn().stdio API.
subprocess.rs:832 #[bun_jsc::host_fn(getter)] pub fn get_stdio now ends (869-874) by downgrading every ExtraPipe::OwnedFd → UnownedFd on read. The getter is NOT cached (BunObject.classes.ts:123 stdio: { getter: "getStdio" } — no cache: true, unlike terminal at :127). It is documented public API (bun.d.ts:7217 readonly stdio: [null, null, null, ...(number | null)[]]; doc at :7210 states no ownership-transfer contract) and is read directly in Bun-API tests (spawn.test.ts:832/863/885). OwnedFd is created for extra "pipe" slots via socketpair (spawn_process.rs:933 extra_fds.push(ExtraPipe::OwnedFd(fds[0]))). finalize_streams closes only OwnedFd (subprocess.rs:1215). So Bun.spawn({stdio:[0,1,2,"pipe"]}); console.log(proc.stdio) — before PR: finalize closes the socketpair end; after PR: read downgrades it, finalize skips it, fd leaks. The mutation exists solely to serve child_process.ts:1253 NetModule.connect({ fd }), a single internal consumer. A prior PR review comment already flagged this exact leak-for-UAF trade.
Fix: Move the ownership transfer to the layer that actually takes ownership. Cheapest: in child_process.ts:1253, dup() the fd before NetModule.connect({ fd: dupd }) so the Socket owns its own copy and native finalize_streams still closes the original — both sides have exactly-once close semantics and the getter stays pure. Alternative: expose an explicit method (e.g. takeStdioFd(i) or a takeStdio array-returning method) that child_process.ts calls once, keeping get_stdio a pure getter; or transfer at spawn time via the lazy: true / child_process-specific spawn option so the fd is born UnownedFd only when the caller has committed to wrapping it. At minimum, if the getter mutation s
The Strong-handle UAF fix is a hand-picked list of the 5 handles the Node suite happened to exercise; at least one more member of the same class — VirtualMachine.overridden_main, deinit()'d in destroy() at line 4464 — is left with the identical UAF, and the fix shape guarantees drift.
The bug class per the PR body: any Strong dropped in destroy() (post-destructOnExit) UAFs in Bun__StrongRef__delete. destroy() runs at src/jsc/VirtualMachine.rs:1605, after destructOnExit at :1594. The PR pre-releases rare.s3_default_client (:1586) and 4 sql_rare handles (src/runtime/jsc_hooks.rs:1458-1470). But destroy() itself (VirtualMachine.rs:4419-4476) still contains self.overridden_main.deinit() at :4464 — a strong::Optional (declared :164) populated by Bun.main = value (src/runtime/api/BunObject.rs:977-985). Under BUN_DESTRUCT_VM_ON_EXIT=1 (the mode this PR targets, VirtualMachine.rs:1374-1376), a script that assigns Bun.main and exits hits the identical Bun__StrongRef__delete UAF. Not caught because Bun.main is Bun-specific — no Node test writes it. Contrast: the many other StrongOptionals in the probe (UpgradedDuplex.rs:29-41, server/mod.rs:297 on_clienterror, NodeHTTPResponse.rs:47, napi_body.rs:2444, zlib) are NOT in this class — they live on JSC-heap objects finalized by lastChanceToFinalize() inside destructOnExit while the HandleSet is live. The at-risk set is exactly "Strongs reached from destroy()": RareData (s3 only, :290 — handled), RuntimeState (sql_rare only — handled), and VirtualMachine direct fields (overridden_main :164 — MISSED; entry_point_result.value :295 — never deinit'd in destroy() so leak-not-UAF). CHILD_SINGLETON (node_cluster_binding.rs:33
Fix: (1) Move self.overridden_main.deinit() from destroy():4464 into the pre-destructOnExit block alongside s3_default_client (or add it there and make the destroy() call idempotent-redundant). (2) Collapse the enumeration into one named method per owner — RareData::release_js_handles(), VirtualMachine::release_direct_js_handles(), plus the existing release_runtime_state_js_handles hook — all called from one contiguous block before destructOnExit, with a comment on each struct that any new Strong field must be added there. (3) Decide whether entry_point_result.value and CHILD_SINGLETON should be released in the same block for leak-cleanliness (they don't UAF, but the PR's goal is leak-cle
The 2-element-stdio post-exit crash fix ships with zero committed test — the "stdio-shape probe matrix" the PR body cites as verification is not in the diff, and no existing Bun test exercises the stdioCount < 3 lazy-load path.
PR file list (gh pr view 31833 --json files): only test/ changes are test/expectations.txt (+6, worker flake entry) and test/leaksan.supp (+50) — no *.test.* file. The fix is at src/js/node/child_process.ts:1221-1230 (constructNativeReadable → newStreamReadableFromReadableStream). Trigger predicate is src/js/node/child_process.ts:1350 const hasSocketsToEagerlyLoad = stdioCount >= 3; — only false when the user passes a ≤2-element stdio array. Grep of test/js/node/child_process/ for stdio: arrays: every occurrence is 3- or 4-element (child_process.test.ts:146,234,420,431; child_process-node.test.js:112,155,258; child-process-stdio.test.js:12,32,58,88; fixtures/ipc_fixture.js:11) — none exercises hasSocketsToEagerlyLoad = false. PR body Testing section: "a stdio-shape probe matrix (2- vs 3-element arrays × inherit/ignore/pipe) for the child_process adapter change" — described but not committed.
Fix: Commit the probe matrix the PR body already describes as a ~20-line test in test/js/node/child_process/child-process-stdio.test.js: test.each([["pipe","pipe"], ["ignore","pipe"], ["inherit","pipe"]]) → spawn a fast-exit child (e.g. bunExe(), ["-e", "1"]) with that 2-element stdio, await once(child, "exit") FIRST (guarantees post-exit), then read child.stdio[1]/child.stdout and assert it's a Readable that emits "close" (or reads empty) rather than throwing. Verify it fails with USE_SYSTEM_BUN=1 (or against a build with the one-line revert) and passes with bun bd test. Also add the 3-element control row to prove the eager-load path is unchanged.
The #[cfg(not(windows))] gate leaves Windows with the same double-close: get_stdio hands the pipe HANDLE to net.connect({ fd }) (which adopts it into a second uv_pipe_t), yet finalize_streams still uv_closes the original Buffer entry.
child_process.ts:1251-1255 — extra-pipe "pipe" case does NetModule.connect({ fd: handle.stdio[i] }) with NO platform gate. subprocess.rs:840-849 (Windows get_stdio) exposes buffer.fd() (HANDLE via uv_fileno). subprocess.rs:1202-1210 (Windows finalize_streams) still does Box::leak(buffer).close(on_pipe_close) on every StdioResult::Buffer. Listener.rs:1020-1040 → WindowsNamedPipe.rs:814-822 — net.connect({fd}) on Windows reinterprets the number as a HANDLE, make_lib_uv_owned() → uv_pipe_open, creating a second owner. The cfg gate exists only because WindowsStdioResult (spawn/process.rs:1485-1490) has no Unowned variant to flip to — not because Windows is safe. Compare js_bun_spawn_bindings.rs:1448-1476: the IPC-channel path already neutralizes the Windows slot via mem::take → Unavailable with the exact comment "so finalizeStreams can't double-close it" — the same neutralization get_stdio needs. PR body confirms testing was macOS-ASAN only.
Fix: On Windows, neutralize the exposed slots in get_stdio the same way the IPC-channel path already does at js_bun_spawn_bindings.rs:1461-1476: mem::take each StdioResult::Buffer → Unavailable after pushing its fd() (and Box::leak the pipe so JS's net.Socket becomes the sole owner via its own uv_pipe_t), OR add a WindowsStdioResult::UnownedBuffer variant that finalize_streams skips. If Windows is deliberately deferred, drop the #[cfg] silence: state in the PR body that Windows extra-pipe ownership is unfixed and open a tracked follow-up — the current diff reads as "Windows is fine" when it isn't.
The PR's own body names the root cause — hasSocketsToEagerlyLoad reads the raw pre-padding stdio.length — then fixes the downstream assertion instead; a one-line change at line 1349 fixes the whole class and keeps the direct native path.
src/js/node/child_process.ts:1349 const stdioCount = stdio.length reads the RAW user array; bunStdio (line 1333) is already padded to ≥3 by normalizeStdio (lines 1750-1753). With stdio: ["pipe","pipe"], stdioCount=2 so the guard is false, but bunStdio=["pipe","pipe","pipe"] — three real pipes exist. The guard is false ONLY for 0/1/2-element user arrays, all of which get padded with "pipe". Root-cause fix: const stdioCount = bunStdio.length (or delete the guard — bunStdio.length≥3 always). Then line 1425-1428 eager-loads synchronously right after Bun.spawn() returns, $bunNativePtr is guaranteed present, and the original direct constructNativeReadable call at line 1228 never asserts. The adapter path (newStreamReadableFromReadableStream, webstreams_adapters.ts:513-538) adds $inheritsReadableStream + validateObject + Buffer.isEncoding + validateBoolean on EVERY child_process stdout/stderr construction (hot path — line 1228 is reached from get stdout()/get stderr() at 1304-1310, not just #handleOnExit). The wrong guard also gates two OTHER behaviors the PR leaves broken for short arrays: line 1425-1428 .ref() on stdio items (keepalive semantics) and line 1380-1385 onExit nextTick eager-load — so stdio:["pipe","pipe"] still diverges from stdio:["pipe","pipe","pipe"] in ref behavior after this PR.
Fix: Change line 1349 to const stdioCount = bunStdio.length; (or drop the guard and unconditionally eager-load, since bunStdio.length is always ≥3), then revert line 1228 to the direct constructNativeReadable(value, { encoding }) call. If defense-in-depth is wanted for a hypothetical late access, add an inline if (!value.$bunNativePtr) { …destroyed Readable… } branch alongside the existing if (!value) guard at line 1216-1222 rather than routing the hot path through the full adapter's validation layer.
leak:9selectors suppresses Bun's own 6.5K-line CSS selector parser (bun_css::selectors), not just the third-party crate — the mangled substring collides across two unrelated codebases.
test/leaksan.supp:+leak:9selectors with comment "selectors crate global caches (hashbrown tables) — process-lifetime statics". LSan leak: is substring-match against every stack frame (scripts/runner.node.mjs:717 wires it via LSAN_OPTIONS suppressions=). nm build/debug/bun-debug | grep -c 9selectors → 2720 symbols; grep -c 7bun_css9selectors → 1069 of those are Bun's own bun_css::selectors module (src/css/lib.rs:65 pub mod selectors; src/css/selectors/{parser.rs 4400 LOC, selector.rs 1863 LOC, builder.rs}); the remainder are Servo's crates.io selectors reached via lol_html (Cargo.lock:2929-2941 lol_html→selectors). Sample colliding symbol: __RINvCs..._8smallvec10deallocateINtNtNtCs..._7bun_css9selectors6parser15GenericSelector.... The comment's justification is also inaccurate: Servo selectors 0.33.0 has NO lazily-allocated global caches — its only statics are compile-time constants (attr.rs:144 SELECTOR_WHITESPACE: &[char], matching.rs:31 usize, build.rs:23 phf::Set); the FxHashMaps in nth_index_cache.rs/relative_selector/*.rs are per-instance struct fields, not statics. Contrast leak:8once_box: only 13 symbols, all OnceBox<pal::Mutex|Condvar> in private std::sys::sync — that one is as narrow as it can be and only ever hides a fixed-size pthread struct. leak:bun_jsc8debugger: 161 symbols, all in one dev-only module — module-wide but bounded.
Fix: Narrow leak:9selectors to the frame that actually appears in the leak report — almost certainly lol_html's compiled-selector storage: leak:12selectors_vm (lol_html's selectors_vm module; 2133 symbols in nm, zero overlap with bun_css) or leak:8lol_html if the retaining root is lol_html itself. Re-run the failing test to capture the real allocating frame and update the comment to name it — "global caches" is wrong. 8once_box can stay as-is (private std internals, bounded to pthread structs). bun_jsc8debugger optionally narrows to 8Debugger6create per its own comment, but that's a nit.
get_stdio's OwnedFd→UnownedFd downgrade sits on the PUBLIC Bun.spawn().stdio getter, but the "JS wraps it in net.Socket" justification only holds for one internal caller — a Bun.spawn user who reads .stdio without wrapping now leaks the pipe fd.
subprocess.rs:832-833 #[bun_jsc::host_fn(getter)] pub fn get_stdio bound at BunObject.classes.ts:123-125 as stdio: { getter: "getStdio" } (no cache: true) — this IS the public Bun.spawn().stdio getter. Public contract at packages/bun-types/bun.d.ts:7210-7217: readonly stdio: [null, null, null, ...(number | null)[]] — "Entries beyond index 2 are number for \"pipe\" slots" with NO ownership-transfer language. The PR comment (subprocess.rs:864-866) cites only child_process.ts's "pipe" case; grep confirms handle.stdio is read at exactly one src/js/ site (child_process.ts:1253) but the getter is public API. spawn_process.rs:912-933 shows PosixStdio::Buffer (from JS "pipe" string, stdio.rs:281,414-415) creates a socketpair and stores parent side as ExtraPipe::OwnedFd. finalize_streams (subprocess.rs:1215-1216) closes OwnedFd but skips UnownedFd — so post-PR, Bun.spawn({stdio:[..., "pipe"]}); proc.stdio; await proc.exited; leaks the fd (pre-PR it was closed). Tests at spawn.test.ts:832,863 and bun-types/fixture/spawn.ts:71-74 demonstrate the read-for-inspection pattern (they use caller-owned fds so happen not to leak, but establish the usage shape).
Fix: Move the ownership transfer to the actual transfer point: add an internal method (e.g. a $-prefixed takeExtraStdioFd(i) on Subprocess, or a private symbol) that returns the fd AND downgrades that single slot, and call it from child_process.ts:1253 instead of handle.stdio[i]. Leave the public get_stdio getter side-effect-free so finalize_streams still closes Bun-created pipes for direct Bun.spawn users. Alternatively, if the intent is that reading .stdio DOES transfer ownership for "pipe" slots, that must be documented in bun.d.ts:7210 and covered by a Bun.spawn-native test asserting the fd is NOT closed by the subprocess after a .stdio read.
release_runtime_state_js_handles (and rare.s3_default_client.deinit()) is only called from global_exit() — Worker teardown reaches ~VM via WebWorker__teardownJSCVM without ever releasing these Strong handles, so a Worker that used Bun.sql or Bun.s3 hits the same Bun__StrongRef__delete UAF this PR fixes for the main VM.
Hook call site: src/jsc/VirtualMachine.rs:1585-1591 — inside global_exit() (defined :1506), the ONLY caller of release_runtime_state_js_handles and rare.s3_default_client.deinit(). Worker path: src/jsc/web_worker.rs:1229-1252 (step 2 pre-JSC cleanup) calls cron_clear_all_teardown / cancel_all_timers / close_all_socket_groups but NOT the new hook nor s3_default_client.deinit(); :1261 (step 3) WebWorker__teardownJSCVM → src/jsc/bindings/webcore/Worker.cpp:598 vm.derefSuppressingSaferCPPChecking() runs ~VM (comment :594 "~VM runs here"), freeing the HandleSet; :1299 (step 5) (*vm_ptr).destroy() → src/jsc/VirtualMachine.rs:4444 drop(rare) drops RareData.s3_default_client:Strong (src/jsc/rare_data.rs:290) and :4473 deinit_runtime_state → src/runtime/jsc_hooks.rs:592 drop(RuntimeState) drops sql_rare's 4× StrongOptional (src/sql_jsc/mysql/MySQLContext.rs:6-7, src/sql_jsc/postgres/PostgresSQLContext.rs:10-11) → src/jsc/Strong.rs:178-184 Drop → :239 Bun__StrongRef__delete against freed HandleSet. RuntimeState is per-thread/per-Worker (src/runtime/jsc_hooks.rs:110-115 thread_local RUNTIME_STATE), so a Worker using Bun.sql populates its own on_query_resolve_fn. rg global_exit src/jsc/web_worker.rs — no hits (only comments referring to the parent's global_exit).
Fix: Mirror the two new pre-teardown releases into web_worker.rs step 2 (right after close_all_socket_groups at :1251, before WebWorker__teardownJSCVM at :1261): if let Some(rare) = vm.rare_data.as_deref_mut() { rare.s3_default_client.deinit(); } and unsafe { (hooks.release_runtime_state_js_handles)(vm_ptr) } inside the existing if let Some(hooks) block. Alternatively, move both releases into a shared helper called from both global_exit() and worker step 2 so the two "pre-JSC-teardown Strong release" lists cannot drift again. Add a test that spawns a Worker, runs one Bun.sql query (or accesses Bun.s3 defaults), and terminate()s it under BUN_DESTRUCT_VM_ON_EXIT=1 + ASAN.
All 17 new leaksan.supp entries omit the # test/... first-seen anchor that the file's own header comment (line 113) documents as the convention for this section — the PR body says each was "verified against a live repro", so the anchors exist and were simply not written down.
test/leaksan.supp:113 declares the section convention: # file comments below are where it was first seen, not an exhaustive list. git blame shows that header and the 4 pre-existing entries below it (create_ssl_context_from_bun_options, jsc.Debugger.startJSDebuggerThread, jsSQLStatementOpenStatementFunction, WTF::RunLoop::dispatchAfter) each carry a # test/… line, contributed across 3 separate commits by 2 separate authors — an established convention, not one contributor's habit. git diff origin/main -- test/leaksan.supp shows 21 new leak: lines and 0 new # test/ lines. PR body: "17 entries, each verified against a live repro before adding". The WebCore::EventNames::operator new entry is flagged in-file and in the PR body as a probable real leak needing a "ThreadGlobalData teardown follow-up" — yet names no test that surfaces it.
Fix: Prefix each new entry in the documented section with the repro test path, matching the four entries above them — e.g. # test/js/node/test/parallel/test-crypto-subtle-... above leak:WebCore::SubtleCrypto::create. The WTF::ParkingLot::parkConditionallyImpl line (currently the only new entry with neither rationale NOR repro) needs both. The parseImportDeclaration addition in the top uncommented block can stay as-is since that block has no anchor convention.
child_process piped stdout now eagerly evaluates webstreams_adapters + Writable + Duplex (~2040 LOC) on the common path, when the fix only needs the fallback branch that is rare by the diff's own comment.
child_process.ts:1228 swaps require("internal/streams/native-readable") for require("internal/webstreams_adapters"). Tracing top-level requires: (a) child_process.ts:2-13 loads only events/os/shared/validators — no streams. (b) OLD dep native-readable.ts:8-10 loads only readable+destroy. (c) NEW dep webstreams_adapters.ts:9-18 top-level loads primordials, writable, readable, duplex, destroy, utils, shared, validators, end-of-stream. (d) readable.ts:3-28 does NOT load writable or duplex (its adapters require at :1630 is lazy webStreamsAdapters ??= require(...) — same lazy pattern in writable.ts:1099 and duplex.ts:135). Net-new module evaluation on first piped-stdout access = webstreams_adapters (765L) + writable (1122L) + duplex (153L). The call site is reached on every default spawn(): child_process.ts:1115-1122 unconditionally calls #getBunSpawnIo(1/2, …, true) in the exit handler even if the user never touched .stdout. The fast path still ends up loading native-readable anyway via webstreams_adapters.ts:37 tryTransferToNativeReadable when $bunNativePtr is present — so the adapter is pure overhead on the common path. The diff comment itself frames the missing-$bunNativePtr case as the rare one ("after the child exits (lazy spawn)").
Fix: Inline the guard at the call site so the fast path is unchanged and only the rare fallback pays for the adapter: const ptr = value.$bunNativePtr; const pipe = (ptr && ptr !== -1) ? require("internal/streams/native-readable").constructNativeReadable(value, { encoding }) : require("internal/webstreams_adapters").newStreamReadableFromReadableStream(value, { encoding });. Alternatively, relax native-readable's $assert(typeof bunNativePtr === "object") to a return-undefined and keep a single require of native-readable with a fallback — but the inline check is the minimal diff and matches the existing lazy-adapter pattern at readable.ts:1630 / writable.ts:1099 / duplex.ts:135.
leak:bun_jsc8debugger blanket-suppresses the entire 1146-line bun_jsc::debugger module — including per-timer, per-test, and per-connection paths — when a function-specific suppression already exists in the binary and a one-token narrowing is available.
test/leaksan.supp (added line): leak:bun_jsc8debugger with comment scoping intent to "Inspector/debugger server thread (bun_jsc::debugger::Debugger::create) still parked at exit". src/jsc/Debugger.rs is 1146 lines and the module contains, beyond the Debugger struct: BunFrontendDevServerAgent (line 50), free fn did_connect (762), AsyncTaskTracker (782) + free fns did_schedule_async_call/did_cancel_async_call/did_dispatch_async_call/will_dispatch_async_call (870-890), TestReporterAgent (895), LifecycleAgent (1072). did_schedule_async_call is invoked per-setTimeout from src/runtime/timer/mod.rs:170 and src/runtime/timer/timer_object_internals.rs:244 — an unbounded per-operation path whose mangled frame contains bun_jsc8debugger and is now suppressed. src/bun_bin/lib.rs:88 documents "LSAN matches by substring on a symbolized frame". src/bun_bin/lib.rs:131 already ships the narrow, correct entry baked into the binary: "leak:bun_jsc::debugger::Debugger>::start_js_debugger_thread\n" — the new module-wide entry is both broader AND redundant with it. File convention (src/bun_bin/lib.rs:112-115) requires per-entry justification and forbids blanket silencing; every neighbouring new entry in the same PR (WebCore::EventNames::operator new, WebCore::SubtleCrypto::create, JSC::JSONAtomStringCache) is function-specific.
Fix: Narrow to the Debugger struct's inherent methods — leak:bun_jsc8debugger8Debugger (mangled) or, matching the existing built-in convention at src/bun_bin/lib.rs:131, add the specific demangled frame(s) (bun_jsc::debugger::Debugger>::create and, if the built-in start_js_debugger_thread entry isn't matching under the mangled form, its mangled twin 24start_js_debugger_thread). Either keeps AsyncTaskTracker, TestReporterAgent, LifecycleAgent, BunFrontendDevServerAgent, and the free did_*_async_call fns observable. If the broader entry was added because the existing narrow built-in entry (lib.rs:131) stopped matching, say so in the comment and fix the narrow entry rather than w
The unscoped [ FLAKY ] on test-worker-terminate-http2-respond-with-file.js removes it from RELEASE CI too, but the described failure is #[cfg(debug_assertions)]-only — release loses the coverage for a panic it can never hit.
(1) src/runtime/dispatch.rs:600-609 — the "JavaScript functions were called outside of the microtask queue without draining microtasks" panic named in the PR's comment is gated by #[cfg(debug_assertions)]; release builds compile it out. (2) scripts/runner.node.mjs:2004-2019 — every expectations.txt entry becomes a hard SKIP: the filter at 2006-2007 destructures expectations but only tests modifiers, then 2011-2017 splices matching tests out of availableTests. [ FLAKY ] is not retry-to-green; it is do-not-run. (3) scripts/runner.node.mjs:383-420 + .buildkite/ci.mjs:62-74 — modifiers derive from the exec basename; release profile appends no suffix (profile !== "release" guard), so release lanes have no ASAN modifier and match the unscoped entry. (4) The test was added in #18299 "add all already-passing tests" and is picked up by isNodeTest (runner.node.mjs:1745-1759), so release CI runs it today. (5) scripts/build/profiles.ts:178-188 — the ASAN profile sets assertions: true, so [ ASAN ] is the scope where the panic can actually fire. (6) Precedent on origin/main: line 28 [ LINUX-X64-MUSL ] … [ FLAKY ] and line 149 [ DARWIN ] … [ FLAKY ] — build-specific flake is already scoped elsewhere in the same file.
Fix: Prefix the entry with [ ASAN ] to match lines 21-24 (and the origin/main [ LINUX-X64-MUSL ]/[ DARWIN ] precedent for scoped FLAKY). If the flake also bites local bun bd debug runs and the author wants that documented, keep the comment as-is — but CI's only assertions-enabled lane is ASAN, so [ ASAN ] is the correct CI scope. If the runner later grows a DEBUG modifier, that would be the more precise choice; today it does not exist for CI release lanes.
sk_GENERAL_NAME_free is now dead code whose only historical use was the bug this PR fixes, yet it stays pub with a signature that type-checks straight back into the leaking callback slot — CLAUDE.md and the file's own header both say delete it.
Zero callers post-PR: rg -n '\bsk_GENERAL_NAME_free\b' --type rust --type zig --type cpp --type c -g '!**/vendor/**' returns only the two definitions (boringssl.rs:488, boringssl.zig:2966) and three doc-comment mentions — no call sites. The PR removed its only two former callers (boringssl.zig:180/199 and lib.rs:360). Signature is byte-identical to the callback type: sk_GENERAL_NAME_free_func = unsafe extern "C" fn(*mut struct_stack_st_GENERAL_NAME) (boringssl.rs:444) vs pub unsafe extern "C" fn sk_GENERAL_NAME_free(sk: *mut struct_stack_st_GENERAL_NAME) (boringssl.rs:488), so sk_GENERAL_NAME_pop_free(n, sk_GENERAL_NAME_free) still compiles. It is the ONLY sk_*_free container-free wrapper in the entire file (rg 'sk_.*_free\b' boringssl.rs | grep -v '_func\|_element\|call_free\|pop_free' → line 488 alone), so no family-completeness defense. File header (boringssl.rs:1-6): "Hand-rolled BoringSSL FFI surface... exposes only the subset of symbols Bun's Rust crates actually consume — it is not a full bindgen dump." No #[deprecated] attribute present. Recent precedent: #31254 "Restrict crate-internal items to pub(crate) and remove dead code it exposes" touched this exact file.
Fix: Delete sk_GENERAL_NAME_free from src/boringssl_sys/boringssl.rs:487-493 and src/boringssl_sys/boringssl.zig:2966-2969 in this PR (name the deletion in the PR description per CLAUDE.md). If a maintainer insists on keeping the upstream-mirroring wrapper, add #[deprecated(note = "footgun: type-matches sk_GENERAL_NAME_free_func but leaks every element when passed to pop_free — use sk_GENERAL_NAME_element_free")] on the Rust side (and @compileError or a doc-warning on the Zig side) so re-misuse produces a compiler diagnostic rather than a silent ASAN leak.
release_runtime_state_js_handles hard-codes bun_sql_jsc's internal field layout in jsc_hooks.rs; the enumeration belongs on impl RareData in the crate that defines those fields — nothing in the crate graph prevents it.
src/runtime/jsc_hooks.rs:1458-1469 enumerates four dotted paths (state.sql_rare.{mysql,postgresql}_context.on_query_{resolve,reject}_fn.deinit()). The crate graph permits the method to live on the owning type: src/runtime/Cargo.toml:72 has bun_sql_jsc.workspace = true, and src/runtime/jsc_hooks.rs:75 already names bun_sql_jsc::jsc::RareData directly (and constructs it inline at :317). On the other side, src/sql_jsc/Cargo.toml:30 has bun_jsc.workspace = true and src/sql_jsc/jsc.rs:33 already imports StrongOptional, so deinit() is callable there — no cycle. grep -rn "impl RareData" src/sql_jsc/ returns nothing: no such method exists today. The fields being torn down are defined at src/sql_jsc/mysql/MySQLContext.rs:6-7 and src/sql_jsc/postgres/PostgresSQLContext.rs:10-11 — a different crate from where they're enumerated. src/sql_jsc/Cargo.toml also shows bun_runtime is a commented-out (not active) dep, confirming the dependency edge is one-way runtime→sql_jsc.
Fix: Add impl RareData { pub fn release_js_handles(&mut self) { … } } at src/sql_jsc/jsc.rs (next to the struct at :270), optionally cascading through per-context release_js_handles on MySQLContext/PostgresSQLContext so each struct enumerates its own Strongs. Then jsc_hooks.rs:1458-1469 collapses to state.sql_rare.release_js_handles();. Net: one edit site per new SQL backend, in the same file that defines the field.
DYLD_LIBRARY_PATH is hard-set (clobbering any parent value) on all macOS builds, but the repo's existing fix for the identical asan-dyld-shim problem uses DYLD_FALLBACK_LIBRARY_PATH — the actual fallback var — and the shim only exists on ASAN builds anyway.
scripts/runner.node.mjs:702-707 gates only on isMacOS and assigns env.DYLD_LIBRARY_PATH = dirname(realpathSync(execPath)); at :1274-1301 bunEnv = {...process.env, ...} then Object.assign(bunEnv, env) — so any inherited DYLD_LIBRARY_PATH is overwritten. Contrast: test/bundler/compile-sourcemap-internal.test.ts:51 solves the SAME "copied exe can't find asan-dyld-shim.dylib" problem with DYLD_FALLBACK_LIBRARY_PATH: dirname(bunExe()). Node's vendored convention at test/js/node/test/common/shared-lib-util.js:26-27 prepends (existing + ':' + new) rather than overwriting. scripts/build/shims.ts:225 emits the shim only when cfg.darwin && cfg.asan, so non-ASAN macOS runs set the var for a dylib that isn't there. The line-706 comment calling DYLD_LIBRARY_PATH "dyld's documented fallback" is wrong — per man dyld, DYLD_LIBRARY_PATH is searched FIRST (override); DYLD_FALLBACK_LIBRARY_PATH is the last-resort fallback.
Fix: Use DYLD_FALLBACK_LIBRARY_PATH instead (matches test/bundler/compile-sourcemap-internal.test.ts:51, cannot shadow system libs, and the "fallback" wording in the comment then becomes correct). If DYLD_LIBRARY_PATH must stay, prepend to any existing value ([dir, process.env.DYLD_LIBRARY_PATH].filter(Boolean).join(':')) and gate on basename(execPath).includes("asan") like the neighboring ASAN_OPTIONS/LSAN_OPTIONS blocks at :709/:713 so non-ASAN release runs are untouched.
TEST_THREAD_ID=testIndex works for isolation, but crashed/aborted tests leave .tmp.N in the repo checkout — the runner's own per-test tmpdir (already crash-proof-cleaned) could carry this via NODE_TEST_DIR instead, making TEST_THREAD_ID unnecessary.
test/js/node/test/common/tmpdir.js:24-31 — testRoot = NODE_TEST_DIR or __dirname/.. (repo checkout test/js/node/test/), tmpPath = ${testRoot}/.tmp.${TEST_THREAD_ID}. tmpdir.js:42-44 registers process.on('exit', onexit) so normal-exit tests DO self-clean (probe's "never cleaned" premise is wrong). Only ~208/2322 node tests require('../common/tmpdir') (grep count), not 2322. scripts/runner.node.mjs:715 sets ASAN_OPTIONS=…abort_on_error=1 → any ASAN detection is SIGABRT → exit handler skipped → .tmp.N survives in-repo (gitignored at test/js/node/test/.gitignore:8). scripts/runner.node.mjs:1271+1409 — spawnBun already creates a per-test mkdtempSync(tmpdir(), "buntmp-") and rm-rf's it in finally (survives crash/kill), and at :1290 sets TEST_TMPDIR: tmpdirPath with the comment "Used in Node.js tests" — but nothing in test/js/node/test/** reads TEST_TMPDIR; tmpdir.js reads NODE_TEST_DIR. testIndex is unstable across runs (getRelevantTests:1960-2070 applies --include/--exclude/expectations/shard/smoke-random/modified-file-sort) but that is irrelevant to within-run uniqueness, which is all isolation needs.
Fix: In spawnBun's bunEnv (scripts/runner.node.mjs:1290), change the dead TEST_TMPDIR: tmpdirPath to NODE_TEST_DIR: tmpdirPath (the var common/tmpdir.js actually reads). Each test then gets ${buntmp-XXXXXX}/.tmp.0, already unique per test, and the existing finally { rmSync(tmpdirPath) } at :1409 sweeps it even on SIGABRT/SIGKILL. TEST_THREAD_ID: String(testIndex) becomes unnecessary and can be dropped. If any test turns out to depend on tmpdir being a sibling of fixtures/, keep TEST_THREAD_ID but add a rmSync(join(testsPath,'js/node/test'), {glob '.tmp.*'}) sweep after the Promise.all.
The Zig checkX509ServerIdentity the PR body calls "the still-live Zig original" is dead porting-reference source that no build compiles — its fix has zero coverage by definition, and the two .zig hunks touch files already deleted on main.
No build.zig exists; scripts/build/rust.ts:5-6 builds the whole runtime as libbun_rust.a via cargo build -p bun_bin alone. Both src/boringssl/boringssl.zig:61 and src/boringssl/lib.rs:209 export extern "C" fn OPENSSL_memory_alloc — a duplicate-symbol link error if both were ever linked, so only Rust is. Merged PR #32621 (d451445, 2026-06-25, "Remove the .zig porting-reference sources") deleted src/boringssl/boringssl.zig and src/boringssl_sys/boringssl.zig from main and states in its body: "the 1,270 .zig files … are not built (build.zig is gone, no Cargo build.rs or include_str! reads them)". git merge-base --is-ancestor d4514457e83 HEAD on the PR branch returns false — PR #31833 predates the removal, so its two .zig hunks edit files that no longer exist on main. The only reachable caller of the identity check is src/http/lib.rs:1510 → check_x509_server_identity; every checkX509ServerIdentity caller (src/http/http.zig:139, src/http_jsc/websocket_client.zig:211, src/sql_jsc/{mysql,postgres}/*.zig, src/runtime/valkey_jsc/js_valkey.zig) sits in the same never-compiled reference tree. PR body line 14 nonetheless claims the fix was applied to "the still-live Zig original".
Fix: Drop the src/boringssl/boringssl.zig and src/boringssl_sys/boringssl.zig hunks entirely and rebase on main (post-#32621). Keep src/boringssl/lib.rs and src/boringssl_sys/boringssl.rs (the GENERAL_NAME_free binding + sk_GENERAL_NAME_element_free shim). Reword the PR body's SAN-leak bullet to say the fix is in the Rust check_x509_server_identity only — remove "still-live Zig original".
Replaces the type-punned sk_GENERAL_NAME_pop_free trampoline with BoringSSL's exported deep-free. Deletes ~70 lines of transmute/wrapper machinery (sk_GENERAL_NAME_free_func, sk_GENERAL_NAME_element_free, sk_GENERAL_NAME_free, sk_GENERAL_NAME_call_free_func, sk_GENERAL_NAME_pop_free, sk_pop_free_ex, OPENSSL_sk_free_func). Also reverts the .zig porting-reference edits — those files are removed on main and not built.
Every URL__* extern returns +1 (Bun::toStringRef); with a bare bun_core::String return type each caller had to remember an OwnedString::new() wrap or the StringImpl leaked. Three files had independently discovered and half-fixed this. Declaring the extern returns as OwnedString (repr(transparent), ABI-identical) makes Drop handle the deref everywhere. Drops the per-site OwnedString::new() wraps and the manual .deref()s that would now double-free (url::URL::from_string, Request::ensure_url). Also fixes previously-unwrapped leaks at SocketAddress::parse, hosted_git_info to_owned_slice sites, and hosted_git_info_jsc.
hasSocketsToEagerlyLoad was keyed on the raw options.stdio.length, so 2-element arrays skipped eager-load and post-exit stdout access hit the native-readable $bunNativePtr assertion. bunStdio is already padded to >=3; read that instead and drop the webstreams-adapters fallback. The extra-pipe fd double-close is fixed by moving ownership transfer out of the public .stdio getter into a takeStdio() method that child_process.ts calls once at spawn time. get_stdio stays a pure read so Bun.spawn() users who inspect .stdio don't leak fds.
…path Collapses the s3_default_client + sql_rare enumeration into release_strong_refs_before_teardown() and adds the two Strongs the Node suite couldn't reach: overridden_main (Bun.main setter) and entry_point_result.value. The same UAF class hit Worker teardown (WebWorker__teardownJSCVM frees the HandleSet before vm.destroy() drops these); call the helper there too. sql_rare's field enumeration moves onto RareData in bun_sql_jsc so new SQL backends add their handles next to the fields, not in jsc_hooks.
…env fixes - leaksan.supp: 9selectors → 8lol_html (was matching bun_css::selectors, 1069 unrelated symbols); bun_jsc8debugger → 7bun_jsc8debugger8Debugger (was matching per-timer AsyncTaskTracker paths). Add # test/... repro anchors per file convention. - expectations.txt: scope test-worker-terminate-http2-respond-with-file to [ ASAN ] — the panic is #[cfg(debug_assertions)]-only. - runner: DYLD_FALLBACK_LIBRARY_PATH (last-resort, prepended) instead of clobbering DYLD_LIBRARY_PATH on all macOS builds; gate on ASAN. Set NODE_TEST_DIR to the per-test tmpdir so aborted-test .tmp.N dirs are swept by the finally-rmSync.
|
Pushed ab7a099..b340e97 addressing all 19 items — URL getters return OwnedString (fixes SocketAddress + hosted_git_info leaks), |
There was a problem hiding this comment.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/sourcemap_jsc/JSSourceMap.rs (1)
43-70: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winRelease the old URL string before reassignment.
bun_core::StringisCopyand has noDrop, so thefile://branch drops the originalfrom_js(...)ref whensource_url_stringis overwritten.dupe_ref()only adds another ref forpath_from_file_url; it does not release the old one.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/sourcemap_jsc/JSSourceMap.rs` around lines 43 - 70, The file URL handling in JSSourceMap::from_js is leaking the original bun_string_jsc::from_js reference when source_url_string is reassigned in the file:// branch. Before overwriting source_url_string with path.into_inner(), explicitly release the old string/reference held by source_url_string, then continue using the new value from path_from_file_url; keep the fix localized to the source_url_string/source_url_slice flow in JSSourceMap.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/js/node/child_process.ts`:
- Around line 1250-1251: The stdio wrapping logic in ChildProcess’s native stdio
handling is treating fd 0 as missing because of a falsy check, which prevents
wrapping transferred descriptors. Update the check in the code that reads from
this.#nativeStdio so it only returns null when the descriptor is actually
absent, and allow fd 0 to continue into NetModule.connect({ fd }) after
takeStdio() has transferred ownership.
In `@src/jsc/bindings/ZigGlobalObject.cpp`:
- Line 1068: The console client ownership initialization in ZigGlobalObject
should use the same unique-pointer helper as the rest of the file. Update the
m_ownedConsoleClient assignment in the ZigGlobalObject setup path to construct
the Bun::ConsoleObject through makeUnique instead of a raw new wrapped in
std::unique_ptr, keeping the ownership style consistent with the other
unique-ownership initializations in ZigGlobalObject.
In `@src/jsc/web_worker.rs`:
- Line 1253: Update the shutdown-ordering comment in web_worker teardown flow to
include the new required barrier. In the code around vm.onExit(), resource
cleanup, and teardownJSCVM(), revise the phase list so it explicitly mentions
vm.release_strong_refs_before_teardown() as the step between exit
handlers/cleanup and JSC VM teardown, keeping the documented order aligned with
the actual shutdown sequence.
In `@src/runtime/api/bun/subprocess.rs`:
- Around line 856-859: The Windows stdio adoption path in subprocess handling is
leaving `take` unused, so `finalize_streams()` can still close a handle that JS
has already adopted. Update the extra-stdio branch in
`takeStdio`/`StdioResult::Buffer` handling to neutralize ownership on Windows
the same way the IPC path does, or gate this wrapping behind a Windows-specific
fallback until transfer is implemented. Make the fix symmetrical in
`subprocess.rs` so native and JS do not both own the same pipe.
In `@src/runtime/api/BunObject.classes.ts`:
- Around line 126-131: The `takeStdio` entry in `BunObject.classes.ts` is
currently user-reachable on `Bun.spawn()` results, so it must not remain a
public prototype method. Move this ownership-transfer hook behind an unforgeable
internal token or private symbol, and update the native binding so only
`child_process.ts` can call it. Keep the existing internal behavior for fd
adoption, but make sure the public API surface no longer exposes `takeStdio` to
userland.
In `@test/js/bun/util/bun-main.test.ts`:
- Around line 31-35: The ASAN regression test currently checks stdout before
validating sanitizer output, which can mask the real failure signal. In
bun-main.test.ts, update the assertion order around the proc
stdout/stderr/exitCode handling so the AddressSanitizer stderr check happens
first, then stdout, then signalCode and exitCode; keep the focus on the proc
Promise.all result and ensure sanitizer reporting is asserted before any stdout
expectation.
In `@test/js/node/child_process/child-process-stdio.test.js`:
- Around line 133-137: In the child_process stdio tests around spawn(...) and
once(child, "close"), the 2-element stdio cases are not draining child.stderr
even though fd 2 may still be piped by normalization/defaults. Update the test
setup to read or resume child.stderr alongside child.stdout before awaiting
close, using the existing spawn/once flow so any diagnostic output cannot block
the child.
---
Outside diff comments:
In `@src/sourcemap_jsc/JSSourceMap.rs`:
- Around line 43-70: The file URL handling in JSSourceMap::from_js is leaking
the original bun_string_jsc::from_js reference when source_url_string is
reassigned in the file:// branch. Before overwriting source_url_string with
path.into_inner(), explicitly release the old string/reference held by
source_url_string, then continue using the new value from path_from_file_url;
keep the fix localized to the source_url_string/source_url_slice flow in
JSSourceMap.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 33e44f5a-dcc4-4e29-94b1-08ebbdd117ac
📒 Files selected for processing (28)
scripts/runner.node.mjssrc/boringssl/lib.rssrc/boringssl_sys/boringssl.rssrc/http/lib.rssrc/install/NetworkTask.rssrc/install/hosted_git_info.rssrc/js/node/child_process.tssrc/jsc/URL.rssrc/jsc/VirtualMachine.rssrc/jsc/bindings/ConsoleObject.hsrc/jsc/bindings/ZigGlobalObject.cppsrc/jsc/bindings/ZigGlobalObject.hsrc/jsc/web_worker.rssrc/runtime/api/BunObject.classes.tssrc/runtime/api/BunObject.rssrc/runtime/api/bun/subprocess.rssrc/runtime/jsc_hooks.rssrc/runtime/socket/SocketAddress.rssrc/runtime/test_runner/ScopeFunctions.rssrc/runtime/webcore/Request.rssrc/runtime/webcore/fetch.rssrc/sourcemap_jsc/JSSourceMap.rssrc/sql_jsc/jsc.rssrc/url/lib.rstest/expectations.txttest/js/bun/util/bun-main.test.tstest/js/node/child_process/child-process-stdio.test.jstest/leaksan.supp
Drops the takeStdio ownership-transfer hook: main's socket-fd stdio type (ExtraPipe::UnownedFd) already solves the same double-close, so the subprocess.rs/BunObject.classes.ts/#nativeStdio additions are removed and child_process.ts uses main's path unchanged. The always-true hasSocketsToEagerlyLoad guard is dropped in the same pass. runner.node.mjs keeps this branch's DYLD_FALLBACK_LIBRARY_PATH and NODE_TEST_DIR additions on top of main's runOneTest refactor; the TEST_THREAD_ID hunk is dropped in favor of main's TEST_SERIAL_ID. New OwnedString::new() wraps main added around bun_url/jsc::URL calls in fetch.rs (proxy paths) and Response.rs (redirect) are unwrapped to match the OwnedString return type.
|
Pushed 1b7f0fb — merges main (both CI-failing test files were stale-merge failures already fixed there: OCSP-URI assert in
|
|
Outstanding before merge: 1 unresolved review thread(s). |
The ASAN sweep motivation only applies to POSIX lanes; relocating common/tmpdir.js's testRoot to realpath(%TEMP%) on Windows regressed test-child-process-fork-exec-path.js, test-module-circular-symlinks.js, and test-child-process-execsync.js.
# Conflicts: # scripts/runner.node.mjs
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
scripts/runner.node.mjs (1)
772-779: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winCache ASAN path resolution outside the per-test path.
realpathSync(execPath)runs once for every Node test file on macOS ASAN, althoughexecPathand the inherited fallback path are invariant. Resolve this once after selectingexecPathand reuse the value.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/runner.node.mjs` around lines 772 - 779, Cache the macOS ASAN fallback library path outside the per-test execution logic. After selecting the invariant execPath, resolve its directory once with realpathSync and build the inherited DYLD_FALLBACK_LIBRARY_PATH value; then have the per-test branch in the ASAN handling reuse that cached value instead of calling realpathSync for every test file..claude/skills/verify/SKILL.md (1)
26-30: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winDocument the ASAN/LSan prerequisites here.
BUN_DESTRUCT_VM_ON_EXIT=1only covers teardown; the leak path also setsASAN_OPTIONS=...detect_leaks=1andLSAN_OPTIONS=..., and it only runs forasanbuilds inscripts/runner.node.mjs:785-789. Either include the full env/build setup or narrow this note to teardown-only checks.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.claude/skills/verify/SKILL.md around lines 26 - 30, Update the “Memory/lifetime fixes” guidance to accurately document that leak detection requires an asan build and the relevant ASAN_OPTIONS and LSAN_OPTIONS settings, referencing the leak-path setup in the runner; otherwise narrow the note to BUN_DESTRUCT_VM_ON_EXIT teardown checks only.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.claude/skills/verify/SKILL.md:
- Around line 21-23: Update the CLI guidance in SKILL.md to document debug-build
commands instead of direct bun commands: replace bun test with bun bd test, and
use bun bd followed by the relevant command for other CLI surfaces such as
install or build. Preserve the mktemp scratch-directory and bunEnv guidance.
---
Outside diff comments:
In @.claude/skills/verify/SKILL.md:
- Around line 26-30: Update the “Memory/lifetime fixes” guidance to accurately
document that leak detection requires an asan build and the relevant
ASAN_OPTIONS and LSAN_OPTIONS settings, referencing the leak-path setup in the
runner; otherwise narrow the note to BUN_DESTRUCT_VM_ON_EXIT teardown checks
only.
In `@scripts/runner.node.mjs`:
- Around line 772-779: Cache the macOS ASAN fallback library path outside the
per-test execution logic. After selecting the invariant execPath, resolve its
directory once with realpathSync and build the inherited
DYLD_FALLBACK_LIBRARY_PATH value; then have the per-test branch in the ASAN
handling reuse that cached value instead of calling realpathSync for every test
file.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 3f5ed2e8-9813-45ff-8b67-8d68218bc816
📒 Files selected for processing (10)
.claude/skills/verify/SKILL.mdscripts/runner.node.mjssrc/http/lib.rssrc/js/node/child_process.tssrc/jsc/VirtualMachine.rssrc/jsc/bindings/ZigGlobalObject.cppsrc/jsc/web_worker.rssrc/runtime/api/BunObject.rssrc/runtime/jsc_hooks.rssrc/runtime/webcore/fetch.rs
There was a problem hiding this comment.
No new findings on f04711d — all prior review threads are resolved. Deferring to a human given the scope: FFI return-type change across ~10 URL call sites, VM/worker Strong-handle teardown ordering, and the ConsoleObject ownership change in ZigGlobalObject are all memory-safety-critical paths that warrant a maintainer's eyes.
Checked this pass:
OwnedStringreturn-type change — every updated caller either scope-drops or.into_inner()s;Request.rsno longer double-derefs on the pointer-equality fast path.release_strong_refs_before_teardownordering vs.destructOnExit/WebWorker::shutdown; idempotentdeinit()on all released slots.TEST_THREAD_IDnot in the runner diff (PR description is stale) — moot because per-testNODE_TEST_DIRgives each test a uniquemkdtemproot, so the.tmp.0collision is already avoided.
Extended reasoning...
Overview
27 files spanning several independent fixes surfaced by running the full ported Node suite under ASAN+LeakSanitizer with BUN_DESTRUCT_VM_ON_EXIT=1: an FFI return-type refactor (bun_core::String → OwnedString on all WTF::URL getters in src/jsc/URL.rs + src/url/lib.rs, with ~10 caller updates), a new pre-teardown Strong-handle release path wired into both global_exit and WebWorker::shutdown, C++ ConsoleObject ownership on ZigGlobalObject via unique_ptr + WTF_OVERRIDE_DELETE_FOR_CHECKED_PTR, a child_process.ts eager-load fix (dead-guard removal), a one-line ScopeFunctions clone-leak fix, runner env additions (NODE_TEST_DIR, DYLD_FALLBACK_LIBRARY_PATH, a timeout bump), 17 documented leaksan.supp entries, one [ FLAKY ] expectation, two test files, and a new .claude/skills/verify/SKILL.md.
Security risks
No new attack surface. The changes are lifetime/ownership corrections and test-infra plumbing. The URL getters already returned +1 refs; the OwnedString wrap makes the existing contract type-enforced rather than changing it. The leaksan.supp additions are LSAN-only and each is scoped to a specific symbol with a documented rationale.
Level of scrutiny
High. This touches the exact bug classes CLAUDE.md flags as most-blocked: JSC Strong/HandleSet teardown ordering (UAF), FFI ref-count balance across an ABI-transparent type change, and C++ CheckedPtr deletion protocol. Each of these is easy to get subtly wrong in ways ASAN catches but a diff read can miss (e.g. a caller that stored the old Copy String and now moves an OwnedString). The PR has already been through five rounds of review with real bugs found and fixed at each (destructive .stdio getter → explicit takeStdio → dropped in favor of main's #31587; the botched single-parent merge; the Windows NODE_TEST_DIR regression; the SKILL.md/CLAUDE.md contradiction). That iteration history is itself a signal that this needs a maintainer sign-off, not a bot approval.
Other factors
- All 12 prior inline threads (mine and CodeRabbit's) show resolved; the last change (f04711d) addressed my SKILL.md nit by switching probes to
bun bd. - The one candidate raised this run —
TEST_THREAD_IDmissing fromrunner.node.mjswhile the PR description still lists it — was ruled out:NODE_TEST_DIRis set to a per-testmkdtempdirectory, socommon/tmpdir.js's.tmp.${TEST_THREAD_ID||0}collision is already avoided by the unique root. The description is stale but the isolation goal is met. - CI on HEAD is still building (#71593); a maintainer will want to confirm the Windows lanes are green after the
NODE_TEST_DIR: isWindows ? undefined : ...gate.
| # timing-dependent (fails reliably on a quiet machine, passes under load). | ||
| # Scoped to ASAN — the panic is `#[cfg(debug_assertions)]`-only; release CI | ||
| # has no assertions-enabled lane other than ASAN. | ||
| [ ASAN ] test/js/node/test/parallel/test-worker-terminate-http2-respond-with-file.js [ FLAKY ] |
| # Live-thread TLS at exit: ParkingLot ThreadData / RunLoop holder of the vm | ||
| # watchdog/aux threads still parked when the process exits. | ||
| leak:WTF::ParkingLot::parkConditionallyImpl | ||
| leak:WTF::RunLoop::currentSingleton | ||
| # test/js/node/test/parallel/test-require-builtins.js | ||
| # Parser-arena identifiers pinned in the atom table at VM-destroy exit — | ||
| # covers all JSC::Parser parse productions (same family as the parse* entries above). | ||
| leak:JSC::IdentifierArena::makeIdentifier | ||
| # test/js/node/test/parallel/test-require-builtins.js | ||
| # ASCIILiteral StringImpl wrapper for internal module names; pinned for process lifetime. | ||
| leak:Bun::InternalModuleRegistry::createInternalModuleById | ||
| # test/js/node/test/parallel/test-net-dns-lookup.js | ||
| # macOS libdispatch/XPC continuation cached inside dns_configuration_free while | ||
| # c-ares reads the system resolver config — OS-internal, not reachable by us. | ||
| leak:ares_init_sysconfig_macos | ||
| # test/js/node/test/parallel/test-fs-watch.js | ||
| # FSEvents watcher thread (std::thread spawn block) still running at exit. | ||
| leak:FSEventsLoop | ||
| # test/js/node/test/parallel/test-tls-connect-simple.js | ||
| # Apple CoreAnalytics XPC telemetry triggered inside SecTrustCopyAnchorCertificates / | ||
| # system CA reads — OS-internal dispatch continuation. | ||
| leak:CoreAnalytics | ||
| # test/js/node/test/parallel/test-assert-checktag.js | ||
| # ASCIILiteral StringImpl wrapper created while formatting a stack frame's source | ||
| # URL on the exit path; same class as the InternalModuleRegistry entry above. | ||
| leak:Zig::sourceURL | ||
| # test/js/node/test/parallel/test-tls-connect-simple.js | ||
| # Apple Security.framework keychain internals reached from our run_once system | ||
| # root-CA load — cached for process lifetime by design. | ||
| leak:us_get_root_system_cert_instances | ||
| # test/js/node/test/parallel/test-shadow-realm-gc.js | ||
| # JSC structure-heap bookkeeping (BitVector in StructureMemoryManager); grows | ||
| # once per structure block and lives for the VM's lifetime. | ||
| leak:JSC::StructureMemoryManager::tryMallocStructureBlock | ||
| # test/js/node/test/parallel/test-tls-connect-simple.js | ||
| # libsystem_info per-thread user-info cache (getpwuid via CFPreferences inside | ||
| # Security.framework) — OS-internal thread-local storage. | ||
| leak:LI_get_thread_info | ||
| # test/js/node/test/parallel/test-child-process-stdio-inherit.js | ||
| # backtrace_symbols() buffer malloc'd inside debug-only stack-trace dumps | ||
| # (fd-UAF warning path); diagnostics memory, never freed by design. | ||
| leak:backtrace_symbols | ||
| # test/js/node/test/parallel/test-require-builtins.js | ||
| # Per-VM JSON atom cache entry pinned in the atom table at VM-destroy exit | ||
| # (same family as IdentifierArena::makeIdentifier above). | ||
| leak:JSC::JSONAtomStringCache | ||
| # test/js/node/test/parallel/test-inspector-enabled.js | ||
| # Inspector/debugger server thread still parked at exit — live-thread | ||
| # allocation. Narrowed to the `Debugger` struct's inherent methods so | ||
| # `AsyncTaskTracker`/`TestReporterAgent`/per-timer paths stay observable. | ||
| leak:7bun_jsc8debugger8Debugger | ||
| # test/js/node/test/parallel/test-worker-message-port.js | ||
| # Per-worker WebCore::EventNames not reclaimed when a Worker thread exits — | ||
| # bounded by live worker count at exit; needs a ThreadGlobalData teardown | ||
| # follow-up rather than blocking every worker test locally. | ||
| leak:WebCore::EventNames::operator new | ||
| # test/js/node/test/parallel/test-http-agent-keepalive.js | ||
| # lol_html's compiled-selector storage (`selectors_vm`) reached via | ||
| # HTMLRewriter — process-lifetime once compiled. Narrowed from `9selectors` | ||
| # so `bun_css::selectors` (Bun's own CSS parser) stays observable. | ||
| leak:8lol_html | ||
| # test/js/node/test/parallel/test-crypto-subtle-zero-length.js | ||
| # crypto.subtle lazy property: SubtleCrypto impl pinned by its JS wrapper at | ||
| # VM-destroy exit. Same JSC-owned ref-cycle class as RunLoop::dispatchAfter. | ||
| leak:WebCore::SubtleCrypto::create | ||
| # test/js/node/test/parallel/test-require-builtins.js | ||
| # Rust std lazily-allocated pthread mutex storage (sys::sync::once_box) — | ||
| # intentionally never freed; one block per static mutex. | ||
| leak:8once_box |
There was a problem hiding this comment.
Adding leaksan supressions is the opposite of making code leak-clean
…n-leak-clean # Conflicts: # .claude/skills/verify/SKILL.md # src/http/lib.rs # src/jsc/web_worker.rs
The worker entry-point load vs terminate() race this entry quarantined no longer reproduces: 30/30 clean runs under the ASAN debug build with debug assertions enabled.
… stdio regression test destroy() lost its overridden_main release when that moved into release_strong_refs_before_teardown(); bake::production's unwind guard reaches destroy() with the JSC VM still live and no prior release, so it leaked the handle. The call is idempotent, so the paths that already released before teardown are unaffected. The short-stdio test accessed .stdout before exit, which constructs it while the handle is alive - it passed with and without the fix. Add a row that defers the first access until after exit, which is the case that throws.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/install/NetworkTask.rs`:
- Around line 269-276: Add an impl From<http::Error> for crate::Error so
result.fail.map(crate::Error::from) in NetworkTask’s TarballStream::on_chunk
call compiles. Follow the existing crate::Error conversion patterns and preserve
the current error propagation behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 8204b6ae-3998-4119-b6b2-b7b9be66446f
📒 Files selected for processing (7)
src/CLAUDE.mdsrc/http/lib.rssrc/install/NetworkTask.rssrc/install/hosted_git_info.rssrc/jsc/VirtualMachine.rssrc/jsc/bindings/ZigGlobalObject.cppsrc/jsc/bindings/ZigGlobalObject.h
💤 Files with no reviewable changes (1)
- src/install/hosted_git_info.rs
There was a problem hiding this comment.
Caution
Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/install/NetworkTask.rs`:
- Around line 269-276: Add an impl From<http::Error> for crate::Error so
result.fail.map(crate::Error::from) in NetworkTask’s TarballStream::on_chunk
call compiles. Follow the existing crate::Error conversion patterns and preserve
the current error propagation behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 8204b6ae-3998-4119-b6b2-b7b9be66446f
📒 Files selected for processing (7)
src/CLAUDE.mdsrc/http/lib.rssrc/install/NetworkTask.rssrc/install/hosted_git_info.rssrc/jsc/VirtualMachine.rssrc/jsc/bindings/ZigGlobalObject.cppsrc/jsc/bindings/ZigGlobalObject.h
💤 Files with no reviewable changes (1)
- src/install/hosted_git_info.rs
🛑 Comments failed to post (1)
src/install/NetworkTask.rs (1)
269-276: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash # Description: locate the install crate's Error type and confirm it converts from http::Error. fd -e rs . src/install --exec grep -l "pub enum Error" {} \; rg -nP 'impl\s+From<\s*http::Error\s*>\s+for\s+(crate::)?Error' src/installRepository: oven-sh/bun
Length of output: 170
🏁 Script executed:
#!/bin/bash set -euo pipefail echo "== src/install/error.rs ==" sed -n '1,220p' src/install/error.rs echo echo "== HTTPClientResult definition ==" rg -n "type HTTPClientResult|struct HTTPClientResult|enum HTTPClientResult|fail:" src/http src/install -g '*.rs' -A4 -B4 echo echo "== Error conversions in src/install ==" rg -n "impl\s+From<.*Error.*>\s+for\s+Error|impl\s+From<http::Error>|impl\s+From<crate::Error>" src/install -g '*.rs' -A3 -B3Repository: oven-sh/bun
Length of output: 32294
🏁 Script executed:
#!/bin/bash set -euo pipefail echo "== Search for From<http::Error> across src ==" rg -n "From<\s*http::Error\s*>" src -g '*.rs' -A3 -B3 echo echo "== Search for any install::Error conversion sites ==" rg -n "crate::Error::from\(|impl\s+From<.*>\s+for\s+Error" src/install -g '*.rs' -A2 -B2Repository: oven-sh/bun
Length of output: 195
Add the missing
http::Errorconversion.result.fail.map(crate::Error::from)needsimpl From<http::Error> for crate::Error; otherwise this path won’t compile.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/install/NetworkTask.rs` around lines 269 - 276, Add an impl From<http::Error> for crate::Error so result.fail.map(crate::Error::from) in NetworkTask’s TarballStream::on_chunk call compiles. Follow the existing crate::Error conversion patterns and preserve the current error propagation behavior.
It passes with the Strong-handle release reverted, so it does not guard the fix. JSC's HandleSet comes from WebKit's FastMalloc, which our ASAN build does not instrument, so releasing a Strong against a torn-down HandleSet produces no report to assert on. A test that passes either way is worse than none.
…n-leak-clean # Conflicts: # scripts/runner.node.mjs # src/CLAUDE.md # src/http/lib.rs # src/install/hosted_git_info.rs # src/jsc/URL.rs # src/jsc/VirtualMachine.rs # src/jsc/web_worker.rs # src/url/lib.rs # test/js/node/child_process/child-process-stdio.test.js
On Windows the pipe EOF lags the child 'exit' event, so stdout.readableEnded is still false when the test checked it. Drive the stream to completion with resume()+finished() before asserting — this is deterministic on all platforms and still covers the regression (first post-exit .stdout access must not throw).
Makes the ported Node.js test suite run leak-clean under the local ASAN debug runner (LeakSanitizer +
BUN_DESTRUCT_VM_ON_EXIT=1), and fixes the native bugs that doing so surfaced."Leak-clean" here means the run is driven with
BUN_DESTRUCT_VM_ON_EXIT=1, which makes the VM actually tear itself down at exit instead of_exiting. That teardown is what exposes both the leaks and the use-after-frees below: normally the process dies before anything gets released, so none of this is observable.Bug fixes
Strong handles released after the JSC VM is torn down (use-after-free).
Bun.main's override, the entry-point result,RareData.s3_default_client, and the SQL contexts'on_query_resolve/on_query_rejecthandles were all dropped duringdestroy()— which runs afterdestructOnExithas already freed the JSC HandleSet. Freeing aStrongat that point reads freed handle storage. These are now released by onerelease_strong_refs_before_teardown()helper called before JSC teardown on both the main-VM (global_exit) and Worker paths. The SQL handles live in the higher-tierRuntimeState, so they are reached through a newrelease_runtime_state_js_handlesruntime hook. The helper is idempotent — everydeinit()leaves its slot empty — sodestroy()also calls it to coverbake::production's unwind guard, which reachesdestroy()with the JSC VM still live and no prior release. See the caveat in "Known limitations" about testing this.Every global object leaked its
ConsoleObject.setConsole()allocated aBun::ConsoleObjectand handed it tosetConsoleClient(), which only stores aWeakPtr— nothing owned it, so it and its buffered messages leaked. This is per-global, and ShadowRealm globals are created and destroyed many times per process. The global now owns it viaunique_ptr.ConsoleClientisCanMakeThreadSafeCheckedPtr, and theWTF_DEPRECATED_MAKE_FAST_ALLOCATEDoperator delete on the subclass shadows the checked-deletion protocol, tripping them_didBeginDeletionassert on destruction — soWTF_OVERRIDE_DELETE_FOR_CHECKED_PTRis redeclared onConsoleObject. JSC's ownJSGlobalObjectConsoleClientdeclares both for the same reason.Every WTF::URL string getter leaked its result.
URL__protocol/href/host/hostname/pathname/search/hashand friends all return a +1BunString(Bun::toStringRef), butbun_core::StringisCopywith noDrop, so callers that forgot an explicit.deref()silently leaked aStringImpl. Rather than audit each call site, the FFI declarations now returnOwnedString, which is#[repr(transparent)]overString(ABI-identical) and derefs on scope exit. Every caller gets the release for free, and the handful of sites that genuinely transfer the +1 onward say so with.into_inner(). This removes the existingOwnedString::new(...)wrappers that were doing this by hand, and fixes the sites that were missing one (SocketAddress,hosted_git_info).Per-scope-function name leak in the test runner.
ScopeFunctions::bindcloned the nameBunString(+1) forJSFunction__createFromZig, which only reads it (toWTFString()), so the clone was never deref'd — one leakedStringImplperdescribe/testscope.child_processstdio crash when a shortstdioarray is first read after exit.hasSocketsToEagerlyLoadwas keyed onoptions.stdio.length— the raw user array — while the array Bun actually spawns with (bunStdio) is padded to length 3 bynormalizeStdio. Sostdio: ["pipe", "pipe"]skipped the eager load, and a stream first touched after the child exited reachedconstructNativeReadablewithout a live$bunNativePtr, throwingASSERTION FAILED: typeof bunNativePtr === "object". SincebunStdiois always >= 3, the guard can only ever be true — it is removed rather than repointed at the padded length.Runner / harness
NODE_TEST_DIRper test.common/tmpdir.jsreads it, so without it every node test shared one.tmp.<id>directory and raced one test'stmpdir.refresh()(anrm -rf) against another's opens. Pointing it at the per-test tmpdir also means the existingfinally { rmSync }sweeps it even when a test aborts, which matters because ASAN'sabort_on_errorskips the test's own exit handler. POSIX-only: there is no Windows ASAN lane, and relocatingtestRootbreaks path-shape assumptions in a few Windows tests.DYLD_FALLBACK_LIBRARY_PATH(macOS ASAN): tests that copyprocess.execPathelsewhere lose the@rpathanchor forasan-dyld-shim.dylib. The runner supplies the build dir as dyld's last-resort search path, prepended rather than clobbering any inherited value.test-require-builtinstimeout 60s -> 120s: it requires every builtin module and takes ~60s alone under a local ASAN debug build, right at the old limit.Leak suppressions (
test/leaksan.supp)19 entries, each with an inline rationale and the test that produced it: macOS framework exit-time caches and live-thread TLS (ParkingLot/RunLoop, CoreAnalytics XPC, libsystem_info, FSEvents, Security.framework keychain), JSC structures pinned for the VM's lifetime at destruct-on-exit (parser-arena identifiers,
JSONAtomStringCache,StructureMemoryManagerbookkeeping, theSubtleCryptowrapper ref-cycle), Rust stdOnceBoxmutex storage, and lol-html's compiled-selector storage.These are additive to a file the ASAN CI lane already uses, so they do reduce leak coverage. They are deliberately narrowed (e.g.
8lol_htmlrather than9selectors, so Bun's own CSS selector code stays observable) and each says why it is there rather than being fixed.Known limitations / follow-ups
Strongagainst the torn-down HandleSet does not produce a report on a Linux ASAN debug build — I could not write a test that fails on the unfixed binary. I removed the smoke test I had written for it rather than land one that passes with the fix reverted. The change rests on the ordering argument instead, which is the same onerelease_queued_tasks_for_shutdown()(already on main, immediately above the new call) documents for the identical reason.WebCore::EventNamesis not reclaimed when a Worker thread exits, and is suppressed rather than fixed. It is bounded by the live worker count at exit and wants aThreadGlobalDatateardown pass; suppressing it beats leaving every worker test unrunnable under the leak checker in the meantime.Testing
test/js/node/child_process/child-process-stdio.test.js— newshort stdio arrayscases. The pre-existing rows access.stdoutbefore exit, which constructs it while the handle is still alive; those pass with and without the fix. The added row defers the first.stdoutaccess until afterexit, and that is the one that reproduces: withsrc/js/node/child_process.tsreverted to main, both 2-element rows fail withASSERTION FAILED: typeof bunNativePtr === "object"while the 3-element control row passes. All 7 pass with the fix.test-worker-terminate-http2-respond-with-filewas previously quarantined as[ FLAKY ]for a worker entry-point-load vsterminate()debug panic. It no longer reproduces (30/30 clean under the ASAN debug build with assertions enabled) — main's worker_threads work in node:worker_threads: +48 Node.js tests passing — MessagePort, stdio, SHARE_ENV, exit codes, transfer semantics, postMessageToThread + inspector #31216 reworked that terminate path — so the entry is gone and this PR adds nothing totest/expectations.txt.