Skip to content

gc: zero the stack the collector used after a synchronous collection - #41669

Open
robobun wants to merge 5 commits into
mainfrom
robobun/3fa17746/zero-collector-stack-after-sync-gc
Open

robobun wants to merge 5 commits into
mainfrom
robobun/3fa17746/zero-collector-stack-after-sync-gc

Conversation

@robobun

@robobun robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • Bun::zeroCollectorStack() (src/jsc/bindings/ZeroCollectorStack.cpp) zeroes 32 KiB of stack below its caller. Each synchronous collection site calls it afterwards: JSC__VM__runGC (post-entry, Bun.gc(true), gc()), --hot reload, the bun:jsc collectors, Isolate::LowMemoryNotification, the JSC testing helper, the inspector close path.
  • Correct because that region is dead once the collection returns. A full collection reaches about 20 KiB deep, and JSC keeps 64 KiB of native stack below JS frames (reservedZoneSize).
  • Self-reviewed: 3 concerns raised (uncovered C++ sites, a duplicate Rust copy, code in bindings.cpp), 3 addressed.
  • Verified: test/js/bun/gc/collector-stack-scrub.test.ts (new, fails on main). Also test-process-finalization.mjs, ffi/cc.test.ts, test/js/bun/gc/, test/v8.

Background

  • JSC scans the machine stack conservatively: any word that looks like a cell address keeps the cell alive.
  • JSC's sanitizeStackForVM zeroes dead stack only down to its last recorded point, which sits above the marking frames.
  • Compilers leave frame slots that the executed path never writes. They show old memory to the scan.
Notes

Reproduction: with 8 loops of the fixture in parallel, the canary profile build (d316760) fails 27 to 31 of 2000 runs and the build before #41330 fails 0 of 2000. Alone it does not fail.

How the cause was found, on that canary build:

  • require("bun:jsc").generateHeapSnapshotForDebugging() taken in the immediate after gc(): the { foo: 'bar' } node has no incoming edge and no roots entry.
  • A stopped process scanned over /proc/pid/mem: the cell address sits at exactly one live slot, rbp-0x110 in JSC::Interpreter::executeCall (the frame that runs the immediate callback, called from Bun__JSTimeout__call). objdump of executeCall shows no instruction touches -0x110(%rbp).
  • An in-process scanner (bun:ffi) on 1600 runs under load: 22 of 22 survivors had the address at that slot before gc(), 0 of 1578 non-survivors did. At module end no copy of the address existed anywhere on the live stack.
  • A poller thread watching that slot, with a SIGUSR2 handler walking the frame pointers of the main thread: every first write came from SlotVisitor::drain / drainFromShared / MarkingConstraintSolver under Heap::collectInMutatorThread ← JSC__VM__runGC ← Run::start, that is, the post-entry collection.
  • BUN_JSC_logGC=1 on the fixture: exactly two collections, the post-entry one and gc().
  • A painted-stack measurement of Bun.gc(true) and Bun.gc(false): the collector's frames reach 19.4 to 20.0 KiB below the caller.

Safety of the 32 KiB frame: main thread stacks are 8 MB or more, Bun Worker threads get 4 MB (18 MB reserve on Windows), the inspector thread 16 MB, bundler macro threads the pool default. A host function called from JS at maximum recursion still has softReservedZoneSize (128 KiB, tripled under ASAN) of native stack. clang emits __chkstk probes for the frame on Windows. Bun's __asan_default_options pins detect_stack_use_after_return=0, so the array stays on the real stack under ASAN. The empty __asm__ __volatile__ with the array as an "r" input and a memory clobber is the idiom WTF's secureZeroSpan uses. NEVER_INLINE keeps the frame below the caller under ThinLTO.

Not covered, on purpose: destroyVM collects right before the VM is torn down, so nothing scans afterwards. Bun.shrink() collects inside JSC's whenIdle callback, and a concurrent collection whose conn the mutator takes also marks on the mutator stack from stopIfNecessary. Both are inside JavaScriptCore. A JSC-side variant of this change (zero below Heap::collectInMutatorThread when it finishes) would cover them too, at the cost of a WebKit commit. This PR keeps the change in Bun.

Review: the first push scrubbed only in Rust VM::run_gc. The review pointed out the C++ sites that call collectNow/collectSync directly, so the scrub moved to C++ and is called from each of them, and the Rust copy was dropped.

Why the test checks zeros and not object survival: the survival depends on the frame layout of the binary. The debug build does not reproduce the fixture failure in 480 runs under load without the fix. The scrub is the mechanism, and the test checks it directly: after Bun.gc(true), the window 8 to 28 KiB below the caller is zero. It scans from a cc()-compiled C function, so Windows is skipped. On CI it passes on debian x64, x64-asan, alpine x64 and aarch64, and darwin aarch64. Without the fix it reports 28672 (debug) and 27800 (release) bytes of residue.

Culprit: #41330 (frame layouts). The post-entry collection itself dates from the Zig runtime.

A synchronous collection runs on the calling thread, and the marker's frames
hold the address of every cell it visits. When those frames are gone the
memory below the caller keeps the addresses. A later callee whose frame has a
slot it never writes exposes one of them to the next conservative stack scan,
and a dead object survives an explicit gc(). VM::run_gc now zeroes 32 KiB of
stack below its frame once the collection returns.
@coderabbitai

coderabbitai Bot commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 14 days. After that, they cost $0.25 per reviewed file.

Or wait 12 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 2df70929-1ec2-4ec4-a8bb-f04f8de59e92

📥 Commits

Reviewing files that changed from the base of the PR and between d316760 and 7facfd3.

📒 Files selected for processing (9)
  • src/jsc/bindings/BunDebugger.cpp
  • src/jsc/bindings/JSCTestingHelpers.cpp
  • src/jsc/bindings/ZeroCollectorStack.cpp
  • src/jsc/bindings/ZeroCollectorStack.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/bindings.cpp
  • src/jsc/bindings/v8/V8Isolate.cpp
  • src/jsc/modules/BunJSCModule.h
  • test/js/bun/gc/collector-stack-scrub.test.ts

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

@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:05 PM PT - Sep 6th, 2026

❌ @robobun, your commit 7facfd3 has 2 failures in Build #111497 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 41669

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

bun-41669 --bun

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/jsc/VM.rs Outdated
Move the scrub into C++ as Bun::zeroCollectorStack and call it from each
place that runs a synchronous collection on the calling thread: the runGC
shim behind VM::run_gc, the --hot reload collection, bun:jsc's gcAndSweep,
fullGC, edenGC and memoryUsageStatistics, the V8 LowMemoryNotification shim,
the JSC testing helper, and the inspector close path.
Comment thread src/jsc/VM.rs Outdated
Comment thread src/jsc/VM.rs Outdated
Comment thread src/jsc/bindings/ZeroCollectorStack.h Outdated
Comment thread src/jsc/bindings/ZeroCollectorStack.h Outdated
Comment thread src/jsc/bindings/ZeroCollectorStack.h

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/jsc/VM.rs Outdated
Comment thread src/jsc/bindings/bindings.cpp 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.

Code review found no issues

No high-confidence issues detected in this change.

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

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on the canary profile build at d316760: run bun --expose-gc test/js/node/test/fixtures/process/gc-not-close.mjs in 8 parallel loops, 27 to 31 of 2000 runs exit 1 with AssertionError: true !== false. The same load on the build before #41330 gives 0 of 2000. A hardware watchpoint on the stale stack slot shows the write comes from SlotVisitor::drain under JSC__VM__runGC in Run::start (details in the PR body).

Head 7facfd3: the new test/js/bun/gc/collector-stack-scrub.test.ts and test-process-finalization.mjs pass on every lane that ran them (debian x64, x64-asan, alpine x64 and aarch64, darwin aarch64, windows skipped by design). The red tests in build 111497, test/js/web/structured-clone-fastpath.test.ts (aarch64) and test/js/node/test/parallel/test-crypto-dh-leak.js (x64-asan), fail the same way on unrelated branches today (builds 111437, 111469, 111075) and are reported as main breaks. Ready for review.

This branch has not been deployed

No deployments
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