Skip to content

test: make the ported Node suite run leak-clean under the ASAN runner, and fix the teardown UAFs and leaks it surfaces - #31833

Closed
cirospaciari wants to merge 23 commits into
mainfrom
claude/node-suite-asan-leak-clean
Closed

test: make the ported Node suite run leak-clean under the ASAN runner, and fix the teardown UAFs and leaks it surfaces#31833
cirospaciari wants to merge 23 commits into
mainfrom
claude/node-suite-asan-leak-clean

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jun 4, 2026

Copy link
Copy Markdown
Member

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_reject handles were all dropped during destroy() — which runs after destructOnExit has already freed the JSC HandleSet. Freeing a Strong at that point reads freed handle storage. These are now released by one release_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-tier RuntimeState, so they are reached through a new release_runtime_state_js_handles runtime hook. The helper is idempotent — every deinit() leaves its slot empty — so destroy() also calls it to cover bake::production's unwind guard, which reaches destroy() 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 a Bun::ConsoleObject and handed it to setConsoleClient(), which only stores a WeakPtr — 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 via unique_ptr. ConsoleClient is CanMakeThreadSafeCheckedPtr, and the WTF_DEPRECATED_MAKE_FAST_ALLOCATED operator delete on the subclass shadows the checked-deletion protocol, tripping the m_didBeginDeletion assert on destruction — so WTF_OVERRIDE_DELETE_FOR_CHECKED_PTR is redeclared on ConsoleObject. JSC's own JSGlobalObjectConsoleClient declares both for the same reason.

  • Every WTF::URL string getter leaked its result. URL__protocol/href/host/hostname/pathname/search/hash and friends all return a +1 BunString (Bun::toStringRef), but bun_core::String is Copy with no Drop, so callers that forgot an explicit .deref() silently leaked a StringImpl. Rather than audit each call site, the FFI declarations now return OwnedString, which is #[repr(transparent)] over String (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 existing OwnedString::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::bind cloned the name BunString (+1) for JSFunction__createFromZig, which only reads it (toWTFString()), so the clone was never deref'd — one leaked StringImpl per describe/test scope.

  • child_process stdio crash when a short stdio array is first read after exit. hasSocketsToEagerlyLoad was keyed on options.stdio.length — the raw user array — while the array Bun actually spawns with (bunStdio) is padded to length 3 by normalizeStdio. So stdio: ["pipe", "pipe"] skipped the eager load, and a stream first touched after the child exited reached constructNativeReadable without a live $bunNativePtr, throwing ASSERTION FAILED: typeof bunNativePtr === "object". Since bunStdio is always >= 3, the guard can only ever be true — it is removed rather than repointed at the padded length.

Runner / harness

  • NODE_TEST_DIR per test. common/tmpdir.js reads it, so without it every node test shared one .tmp.<id> directory and raced one test's tmpdir.refresh() (an rm -rf) against another's opens. Pointing it at the per-test tmpdir also means the existing finally { rmSync } sweeps it even when a test aborts, which matters because ASAN's abort_on_error skips the test's own exit handler. POSIX-only: there is no Windows ASAN lane, and relocating testRoot breaks path-shape assumptions in a few Windows tests.
  • DYLD_FALLBACK_LIBRARY_PATH (macOS ASAN): tests that copy process.execPath elsewhere lose the @rpath anchor for asan-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-builtins timeout 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, StructureMemoryManager bookkeeping, the SubtleCrypto wrapper ref-cycle), Rust std OnceBox mutex 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_html rather than 9selectors, so Bun's own CSS selector code stays observable) and each says why it is there rather than being fixed.

Known limitations / follow-ups

  • The Strong-handle teardown fixes ship without a regression test. JSC's HandleSet is allocated out of WebKit's FastMalloc, which is not ASAN-instrumented in our build, so freeing a Strong against 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 one release_queued_tasks_for_shutdown() (already on main, immediately above the new call) documents for the identical reason.
  • Per-worker WebCore::EventNames is 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 a ThreadGlobalData teardown pass; suppressing it beats leaving every worker test unrunnable under the leak checker in the meantime.
  • The macOS-specific suppressions were derived from macOS ASAN runs and cannot be re-verified on a Linux host.

Testing

  • test/js/node/child_process/child-process-stdio.test.js — new short stdio arrays cases. The pre-existing rows access .stdout before exit, which constructs it while the handle is still alive; those pass with and without the fix. The added row defers the first .stdout access until after exit, and that is the one that reproduces: with src/js/node/child_process.ts reverted to main, both 2-element rows fail with ASSERTION FAILED: typeof bunNativePtr === "object" while the 3-element control row passes. All 7 pass with the fix.
  • test-worker-terminate-http2-respond-with-file was previously quarantined as [ FLAKY ] for a worker entry-point-load vs terminate() 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 to test/expectations.txt.

…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.
@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

The PR changes URL string-returning APIs to use OwnedString, updates callers for ownership transfer, centralizes JSC strong-reference cleanup before teardown, retains console ownership, adjusts child-process stdio handling, and updates Node test infrastructure and leak suppressions.

URL Ownership Refactor

Layer / File(s) Summary
OwnedString URL contracts
src/jsc/URL.rs, src/url/lib.rs
URL FFI declarations, helpers, and string getters now return OwnedString.
URL caller ownership updates
src/http/lib.rs, src/install/..., src/runtime/..., src/sourcemap_jsc/..., src/CLAUDE.md
URL consumers use direct results or .into_inner(); SourceHut decoding failures now produce no match.

JSC Teardown and Ownership

Layer / File(s) Summary
Pre-teardown strong-reference release
src/jsc/VirtualMachine.rs, src/jsc/web_worker.rs, src/runtime/jsc_hooks.rs, src/sql_jsc/jsc.rs
VM and worker teardown release Rust and runtime-owned JSC strong handles before JSC destruction.
Global console client ownership
src/jsc/bindings/ConsoleObject.h, src/jsc/bindings/ZigGlobalObject.cpp, src/jsc/bindings/ZigGlobalObject.h
GlobalObject owns the installed ConsoleObject and preserves checked-pointer deletion support.
Related lifetime cleanup
src/runtime/test_runner/ScopeFunctions.rs
Host callback creation uses the provided name without cloning it.

Child Process Stdio Handling

Layer / File(s) Summary
Unconditional stdio initialization and refs
src/js/node/child_process.ts
Spawn and exit paths always touch stdio and reference available stdio items.
Short stdio array coverage
test/js/node/child_process/child-process-stdio.test.js
Tests cover short stdio arrays, output collection, stream state, and close results.

Test Infrastructure and Verification

Layer / File(s) Summary
Node runner environment and timeout handling
scripts/runner.node.mjs
Adds a special timeout, macOS ASAN library-path setup, and per-test NODE_TEST_DIR.
Leak suppression updates
test/leaksan.supp
Adds JSC and Node/Bun test leak suppression entries.

Possibly related PRs

Suggested reviewers: robobun

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: ASAN leak-cleanup work on the ported Node suite, including teardown UAF and leak fixes.
Description check ✅ Passed The description covers what the PR does and how it was verified, even though it uses custom headings instead of the template's exact section names.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator
Updated 5:40 PM PT - Aug 3rd, 2026

@robobun, your commit 703ab06 is building: #88520

Comment thread src/boringssl_sys/boringssl.zig Outdated
Comment thread src/runtime/api/bun/subprocess.rs Outdated
@robobun

robobun commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Heads up on an overlap: #31990 (fixing the Sentry worker-shutdown segfaults BUN-3DSK/BUN-3END in Bun__StrongRef__delete) independently adds the same release_runtime_state_js_handles hook and the s3_default_client release. Differences: it also wires the release into WebWorker::shutdown (the worker teardown path, where the production crashes fire and where the S3/SQL Strongs hit the same freed HandleSet), covers global_dns_data, and adds regression tests using Malloc=1 so ASAN sees the JSC heap frees. The hunks overlapping with this PR (global_exit + the hook in jsc_hooks.rs) are semantically identical, so whichever lands second rebases trivially.

alii
alii previously requested changes Jul 8, 2026

@alii alii left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::takeUnavailable 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::BufferUnavailable 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_css9selectors1069 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:1510check_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".

alii added 5 commits July 7, 2026 17:59
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.
@alii
alii dismissed their stale review July 8, 2026 01:01

Addressed by ab7a099..b340e97 (all 19 items).

@alii

alii commented Jul 8, 2026

Copy link
Copy Markdown
Member

Pushed ab7a099..b340e97 addressing all 19 items — URL getters return OwnedString (fixes SocketAddress + hosted_git_info leaks), GENERAL_NAMES_free (deleted 84 lines of trampoline), pure get_stdio + explicit takeStdio(), release_strong_refs_before_teardown() from both exit + worker, narrowed leaksan suppressions. Net +265/-251.

Comment thread src/jsc/URL.rs Outdated
Comment thread src/runtime/api/BunObject.classes.ts Outdated
Comment thread src/js/node/child_process.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Release the old URL string before reassignment.

bun_core::String is Copy and has no Drop, so the file:// branch drops the original from_js(...) ref when source_url_string is overwritten. dupe_ref() only adds another ref for path_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

📥 Commits

Reviewing files that changed from the base of the PR and between 09703da and b340e97.

📒 Files selected for processing (28)
  • scripts/runner.node.mjs
  • src/boringssl/lib.rs
  • src/boringssl_sys/boringssl.rs
  • src/http/lib.rs
  • src/install/NetworkTask.rs
  • src/install/hosted_git_info.rs
  • src/js/node/child_process.ts
  • src/jsc/URL.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/ConsoleObject.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • src/jsc/web_worker.rs
  • src/runtime/api/BunObject.classes.ts
  • src/runtime/api/BunObject.rs
  • src/runtime/api/bun/subprocess.rs
  • src/runtime/jsc_hooks.rs
  • src/runtime/socket/SocketAddress.rs
  • src/runtime/test_runner/ScopeFunctions.rs
  • src/runtime/webcore/Request.rs
  • src/runtime/webcore/fetch.rs
  • src/sourcemap_jsc/JSSourceMap.rs
  • src/sql_jsc/jsc.rs
  • src/url/lib.rs
  • test/expectations.txt
  • test/js/bun/util/bun-main.test.ts
  • test/js/node/child_process/child-process-stdio.test.js
  • test/leaksan.supp

Comment thread src/js/node/child_process.ts Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment thread src/jsc/web_worker.rs
Comment thread src/runtime/api/bun/subprocess.rs Outdated
Comment thread src/runtime/api/BunObject.classes.ts Outdated
Comment thread test/js/bun/util/bun-main.test.ts Outdated
Comment thread test/js/node/child_process/child-process-stdio.test.js
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.
@alii

alii commented Jul 8, 2026

Copy link
Copy Markdown
Member

Pushed 1b7f0fb — merges main (both CI-failing test files were stale-merge failures already fixed there: OCSP-URI assert in 034f693ab87, bake React pin in 65e12674ad8) and addresses the review threads:

  • takeStdio public / Windows path / #nativeStdio fd-0 falsy — dropped entirely; main's socket-fd/ExtraPipe::UnownedFd (from node:http: rewrite the client on net/tls + llhttp (Node http suite: ~55% → 82.3%, +182 vendored tests, client proxy support) #31587) already solves the same double-close, so subprocess.rs and BunObject.classes.ts are back to main and child_process.ts uses main's path unchanged.
  • hasSocketsToEagerlyLoad always-true — removed the guard and the dead stdioCount; both eager-load sites are now unconditional.
  • src/CLAUDE.md URL section — updated to OwnedString + .into_inner() note.
  • makeUnique for ConsoleObject — done.
  • web_worker phase-list comment — added the release_strong_refs_before_teardown() step.
  • child-process-stdio.test.js stderr drain — added child.stderr?.resume().
  • bun-main.test.ts assertion order — folded into a single combined-object toEqual (surfaces the ASAN stderr on failure while keeping stdout-before-exitCode ordering per CLAUDE.md).

Comment thread .buildkite/ci.mjs Outdated
Comment thread CLAUDE.md
Comment thread scripts/runner.node.mjs Outdated
@alii

alii commented Jul 9, 2026

Copy link
Copy Markdown
Member

Outstanding before merge: 1 unresolved review thread(s).

alii added 2 commits July 9, 2026 12:45
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.
Comment thread .claude/skills/verify/SKILL.md Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Cache ASAN path resolution outside the per-test path.

realpathSync(execPath) runs once for every Node test file on macOS ASAN, although execPath and the inherited fallback path are invariant. Resolve this once after selecting execPath and 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 win

Document the ASAN/LSan prerequisites here. BUN_DESTRUCT_VM_ON_EXIT=1 only covers teardown; the leak path also sets ASAN_OPTIONS=...detect_leaks=1 and LSAN_OPTIONS=..., and it only runs for asan builds in scripts/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

📥 Commits

Reviewing files that changed from the base of the PR and between d538d79 and f04711d.

📒 Files selected for processing (10)
  • .claude/skills/verify/SKILL.md
  • scripts/runner.node.mjs
  • src/http/lib.rs
  • src/js/node/child_process.ts
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/web_worker.rs
  • src/runtime/api/BunObject.rs
  • src/runtime/jsc_hooks.rs
  • src/runtime/webcore/fetch.rs

Comment thread .claude/skills/verify/SKILL.md Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • OwnedString return-type change — every updated caller either scope-drops or .into_inner()s; Request.rs no longer double-derefs on the pointer-equality fast path.
  • release_strong_refs_before_teardown ordering vs. destructOnExit / WebWorker::shutdown; idempotent deinit() on all released slots.
  • TEST_THREAD_ID not in the runner diff (PR description is stale) — moot because per-test NODE_TEST_DIR gives each test a unique mkdtemp root, so the .tmp.0 collision 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::StringOwnedString 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_ID missing from runner.node.mjs while the PR description still lists it — was ruled out: NODE_TEST_DIR is set to a per-test mkdtemp directory, so common/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.

Comment thread test/expectations.txt Outdated
# 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 ]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete.

Comment thread test/leaksan.supp
Comment on lines +124 to +192
# 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding leaksan supressions is the opposite of making code leak-clean

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comments

…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.
@cirospaciari cirospaciari changed the title test: run the full ported Node suite leak-clean under the ASAN runner and fix the bugs it surfaces — subprocess fd double-close, console/Strong-handle teardown UAFs, SAN/scope-function/valkey leaks, post-exit child_process streams, runner tmpdir isolation (2322 tests green) test: make the ported Node suite run leak-clean under the ASAN runner, and fix the teardown UAFs and leaks it surfaces Jul 14, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between f04711d and e06fa0a.

📒 Files selected for processing (7)
  • src/CLAUDE.md
  • src/http/lib.rs
  • src/install/NetworkTask.rs
  • src/install/hosted_git_info.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
💤 Files with no reviewable changes (1)
  • src/install/hosted_git_info.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between f04711d and e06fa0a.

📒 Files selected for processing (7)
  • src/CLAUDE.md
  • src/http/lib.rs
  • src/install/NetworkTask.rs
  • src/install/hosted_git_info.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/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/install

Repository: 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 -B3

Repository: 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 -B2

Repository: oven-sh/bun

Length of output: 195


Add the missing http::Error conversion. result.fail.map(crate::Error::from) needs impl 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.
robobun added 3 commits August 3, 2026 20:25
…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).
@cirospaciari
cirospaciari deleted the claude/node-suite-asan-leak-clean branch August 4, 2026 01:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants