Skip to content

Free a global object's console client together with the global - #38395

Open
robobun wants to merge 1 commit into
mainfrom
farm/5508e58e/global-object-owns-console-object
Open

Free a global object's console client together with the global#38395
robobun wants to merge 1 commit into
mainfrom
farm/5508e58e/global-object-owns-console-object

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Every new ShadowRealm() leaks the realm global's Bun::ConsoleObject (40 bytes) and the WeakPtrImpl JSC holds on it (32 bytes). LSan (with Malloc=1) reports them as Bun::ConsoleObject::operator new / Zig::GlobalObject::setConsole / Zig::deriveShadowRealmGlobalObject / JSC::ShadowRealmObject::create, on the main thread after BUN_DESTRUCT_VM_ON_EXIT teardown and after a worker's VM is torn down.
  • Cause: GlobalObject::setConsole() (src/jsc/bindings/ZigGlobalObject.cpp:1126) does setConsoleClient(new Bun::ConsoleObject(...)). JSGlobalObject::setConsoleClient takes a WeakPtr, so nothing owns the object and nothing deletes it when the global is collected.
  • The main thread's and every worker's global leak theirs the same way (72 bytes per global, the only LSan report an empty script produces under Malloc=1); those reports were hidden by the blanket leak:Zig__GlobalObject__create entry in test/leaksan.supp.

Fix

  • Zig::GlobalObject keeps the client in a std::unique_ptr<Bun::ConsoleObject> (m_consoleObject); setConsole() fills it and installs the WeakPtr, and the client is deleted when the global cell is destroyed. This covers every caller of setConsole(): Zig__GlobalObject__create (main thread and workers), Zig__GlobalObject__createForTestIsolation, BakeCreateProdGlobal, and deriveShadowRealmGlobalObject.
  • Bun::ConsoleObject gets WTF_OVERRIDE_DELETE_FOR_CHECKED_PTR. JSC::ConsoleClient is CanMakeCheckedPtr, and deleting such an object through a subclass without the override trips ASSERTION FAILED: m_didBeginDeletion || deleteException == CheckedPtrDeleteCheckException::Yes in debug builds (the first build of this change did). It also makes a CheckedPtr that outlives the client a detected error instead of a use-after-free.
  • Why this is correct: the global is the only thing that references the client (JSGlobalObject::m_consoleClient, a WeakPtr; the Rust side ignores the pointer it is handed and resolves the console through the VM), and any code that can still call into a realm keeps that realm's global alive, so the client can never be reached after the global is destroyed. Deleting it at that point is exactly how WebCore owns WorkerConsoleClient (std::unique_ptr in WorkerOrWorkletScriptController, WTF_OVERRIDE_DELETE_FOR_CHECKED_PTR on the client).
  • leak:Zig__GlobalObject__create is removed from test/leaksan.supp: with the fix an empty script and a worker script run leak-free under Malloc=1 with no suppressions at all, so the entry's only known job was hiding this leak. A sample of test files (test/cli/test/isolation.test.ts, worker, BroadcastChannel, console and jsc suites) passes under the CI LSan environment with it removed.
  • Tests: test/js/bun/jsc/shadow.test.js creates realms from setImmediate under Malloc=1 + BUN_DESTRUCT_VM_ON_EXIT=1 + detect_leaks=1, on the main thread (expects a clean exit) and inside a worker_threads worker (expects no deriveShadowRealmGlobalObject / Zig__GlobalObject__create frames in the report; the exit code cannot be asserted there until worker: free the thread's event name table when the worker thread exits #38164 frees the worker's EventNames table). Both fail on the unfixed build (git stash push -- src/ && bun bd test ...) and pass with it.
  • Also run with the fix: the ported Node test-shadow-realm*.js tests (including the GC stress, which sweeps realm globals while the VM is running), a 100-realm GC loop that calls a realm function after its ShadowRealm wrapper was collected, bun test --isolate under Malloc=1 LSan, and the production half of test/bake/dev-and-prod.test.ts.

Background

  • Console client: JSC's console object forwards every call to the JSC::ConsoleClient registered on the calling global object. Bun::ConsoleObject is Bun's implementation; it bridges into the Rust console code. Each Zig::GlobalObject installs one on itself.
  • ShadowRealm: new ShadowRealm() asks the embedder (deriveShadowRealmGlobalObject) for a fresh global object in the same VM. It is an ordinary GC cell: when the realm becomes unreachable the cell is swept and ~GlobalObject runs, long before the VM goes away. bun test --isolate retires globals the same way.
  • WeakPtr / CanMakeWeakPtr: WTF's non-owning pointer. The target owns a small refcounted WeakPtrImpl that is cleared when the target is destroyed; setConsoleClient stores one of these, so the embedder must own the client itself.
  • CanMakeCheckedPtr / WTF_OVERRIDE_DELETE_FOR_CHECKED_PTR: WTF's dangling-pointer check. The object counts outstanding CheckedPtrs; the macro replaces operator delete on the most derived class with one that marks the deletion as begun (which the base destructor asserts in debug builds) and, if any CheckedPtr is still outstanding, zeroes the object instead of freeing it.
  • Malloc=1: routes bmalloc/libpas (which backs fastMalloc and therefore this object) to the system allocator so ASAN/LSan can see those allocations. BUN_DESTRUCT_VM_ON_EXIT=1 makes exit really destroy the VM, which is what frees the globals and turns the unowned clients into reported leaks.

GlobalObject::setConsole() allocated a Bun::ConsoleObject and handed
JSGlobalObject::setConsoleClient() a WeakPtr to it, so nothing ever
deleted it. Every ShadowRealm global leaked its client (plus the
WeakPtrImpl), as did the main/worker globals; the latter were hidden by
the blanket leak:Zig__GlobalObject__create LSan suppression.

The global now owns the client through a unique_ptr, so it is deleted
when the global cell is destroyed. ConsoleClient is CanMakeCheckedPtr,
so Bun::ConsoleObject gets WTF_OVERRIDE_DELETE_FOR_CHECKED_PTR, which is
what makes deleting it legal (debug builds assert otherwise) and
catches a CheckedPtr outliving it.

Drop the Zig__GlobalObject__create suppression, whose only known job
was hiding this leak, and add Malloc=1 LSan tests for realms created on
the main thread and inside a worker.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e310d140-1a6b-44fc-9661-5e8ed1ce0f04

📥 Commits

Reviewing files that changed from the base of the PR and between 01aa7cd and 3a1d520.

📒 Files selected for processing (5)
  • src/jsc/bindings/ConsoleObject.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • test/js/bun/jsc/shadow.test.js
  • test/leaksan.supp

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

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix and tests pushed, waiting on CI.

Reproduced on the unfixed debug build with Malloc=1 BUN_DESTRUCT_VM_ON_EXIT=1 ASAN_OPTIONS=detect_leaks=1: a script that creates a ShadowRealm from setImmediate reports the realm global's Bun::ConsoleObject (allocated in Zig::GlobalObject::setConsole from Zig::deriveShadowRealmGlobalObject) plus its WeakPtrImpl, both on the main thread and after a worker that created realms has exited. An empty script reports the main global's client the same way once leak:Zig__GlobalObject__create is not suppressing it.

With this branch the same runs are leak-free (with no suppressions at all for the main-thread cases), and the new cases in test/js/bun/jsc/shadow.test.js fail with src/ stashed and pass with it.

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

I reviewed this and didn't find any bugs. Because it changes object lifetime in a GC-swept cell (ZigGlobalObject's destructor now frees the console client) and removes a broad leaksan.supp entry that was only spot-checked against the wider suite, a human look would still be worthwhile.

Checked: setConsole() is called exactly once per global (four creation-time call sites, none re-entrant); ~GlobalObject() is out-of-line in a TU that includes ConsoleObject.h, so the forward-declared unique_ptr deletes with a complete type; member destruction runs before ~JSGlobalObject destroys its WeakPtr, which is the order CanMakeWeakPtr is designed for; the new tests drain both pipes concurrently and gate on isASAN.

Extended reasoning...

Overview

The PR fixes a per-global-object leak of Bun::ConsoleObject: setConsole() previously did setConsoleClient(new Bun::ConsoleObject(...)), and since JSGlobalObject::setConsoleClient only holds a WeakPtr, nothing owned the allocation. The fix stores the client in a new std::unique_ptr<Bun::ConsoleObject> m_consoleObject on Zig::GlobalObject so it is freed when the global cell is swept, adds WTF_OVERRIDE_DELETE_FOR_CHECKED_PTR to ConsoleObject (required because ConsoleClient is CanMakeCheckedPtr), removes the leak:Zig__GlobalObject__create LSan suppression that was hiding this, and adds ASAN-gated LSan regression tests for both main-thread and worker ShadowRealm globals.

Security risks

None. This is internal memory ownership of a bridge object; no user input, parsing, or trust boundary is involved.

Level of scrutiny

High. Per REVIEW.md, native memory safety is the most-blocked category, and this touches destruction of a GC-managed cell in ZigGlobalObject — a header the file itself warns is included nearly everywhere. That said, the actual production change is two lines plus a macro and a member declaration, and it mirrors WebCore's WorkerConsoleClient ownership exactly (which the author cites). I verified: all four setConsole() callers are one-shot at global creation; ConsoleObject.h is included in ZigGlobalObject.cpp so ~GlobalObject() sees the complete type for the unique_ptr deleter; and destruction ordering (derived member m_consoleObject before base m_consoleClient WeakPtr) is the ordering CanMakeWeakPtr expects.

Other factors

The leaksan.supp removal is the piece with the widest blast radius — it was a blanket suppression on Zig__GlobalObject__create, and the author validated it against a sample of suites rather than the full matrix. If any other allocation under that frame still leaks, unrelated CI lanes will start failing. That is the right direction (strengthening rather than weakening sanitizer coverage), but it is worth a maintainer being aware of before merge. The new tests follow harness conventions well: describe.concurrent.skipIf(!isASAN || isWindows), await using on spawned processes, concurrent pipe draining, and a documented per-test timeout for LSan symbolization. The worker case's weaker not.toContain assertion is justified inline with a linked issue (#38164).

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

On the leaksan.supp removal: the local runs were a sample, but this PR's own CI is the full check. The PR-only linux-x64-asan test lane runs every test file not listed in test/no-validate-leaksan.txt with detect_leaks=1 and this branch's test/leaksan.supp (scripts/runner.node.mjs, the shouldValidateLeakSan branch), so any remaining leak under Zig__GlobalObject__create would fail that lane here rather than after merge. If it does turn one up, the entry goes back with a comment naming the allocation instead of as a blanket suppression.

On the lifetime change: the only holder of the client is the global's own WeakPtr, and the client is destroyed from ~GlobalObject on the thread that sweeps the cell (the same thread that created it), which is also where WebCore destroys WorkerConsoleClient. The ported test-shadow-realm-gc-module.js and a 100-realm GC loop that still calls into a collected realm's function were run against this branch to exercise the mid-run sweep path (details in the description).

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:05 AM PT - Aug 14th, 2026

@robobun, your commit 3a1d5204752009752641989d51f6d6d9871ee788 passed in Build #95601! 🎉


🧪   To try this PR locally:

bunx bun-pr 38395

That installs a local version of the PR into your bun-38395 executable, so you can run:

bun-38395 --bun

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant