Skip to content

Bump WebKit (oven-sh/WebKit#681 preview): a microtask checkpoint clears the stack its frames use - #42975

Closed
robobun wants to merge 2 commits into
mainfrom
robobun/b42f2e8d/microtask-checkpoint-clears-stack
Closed

robobun wants to merge 2 commits into
mainfrom
robobun/b42f2e8d/microtask-checkpoint-clears-stack

Conversation

@robobun

@robobun robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • An object that nothing references can survive every Bun.gc(true) made from later event loop turns. On main the new test fails 2 of 6 cases (release build) and 3 of 6 (debug ASAN build).
  • Every turn runs its jobs from a microtask checkpoint at the same stack depth. So the frame of JSC's runInternalMicrotask() is at the same address each time. A job writes only some of its slots. The stale word holds the generator of a finished async function.
  • [JSC] The conservative scan does not read stack words ASan has poisoned WebKit#673 does not cover it. The word is a normal slot, not an ASAN redzone.

Fix

Background

  • JSC scans the machine stack conservatively. A word that looks like a cell pointer keeps that cell alive, also a stale word in a live frame.
  • A microtask checkpoint drains the job queue. Bun runs one after each callback, so an await continues there.
  • sanitizeStackForVM() zeroes the stack below its caller. It cannot reach a live frame.
Notes

The pin is c28156899e, the current pin, plus the one commit of oven-sh/WebKit#681.

Scope. The object that stays is the last one that went through the slot. With default settings it goes away when other work overwrites the word, for example the collector's 1 s timer. So this is about collections that are deterministic (leak tests, code that polls a WeakRef or a FinalizationRegistry), not about unbounded growth.

The test. A fixture makes two objects in an async function that awaits one event loop turn per object. Then it calls Bun.gc(true) from later turns until the WeakRefs are empty, for at most 8 turns. Six cases: the collections are made by the module (top-level await) or by an async function, and a turn is setTimeout, setImmediate or Bun.sleep(1). Each case is a fresh process with BUN_GC_TIMER_DISABLE=1.

build result
Bun 1.4.3 canary c6b7fcb5b, release (USE_SYSTEM_BUN=1) 2 of 6 fail: both Bun.sleep cases, {"alive":2,"collections":8}
main, bun bd (debug ASAN, WebKit c28156899e with #673) 3 of 6 fail: the three module cases
this branch, bun bd 6 of 6 pass
this branch, bun bd, BUN_JSC_clearStackForMicrotaskCheckpoint=0 3 of 6 fail, as main
this branch, release build (--profile=release, the lto prebuilt) 6 of 6 pass
this branch, release build, BUN_JSC_clearStackForMicrotaskCheckpoint=0 2 of 6 fail, as the canary
Bun 1.4.3 canary a8e4e9042, Windows x64 2 of 6 fail: both Bun.sleep cases

Which case shows it moves with the compiler: with clang 21 the setTimeout cases failed on the release build too.

The two artifacts (release build c6b7fcb5b, BUN_GC_TIMER_DISABLE=1, the module case with Bun.sleep(1)):

  • generateHeapSnapshotForDebugging() after five collections: the objects are held by the function's list, held by a JSLexicalEnvironment, held by the AsyncFunctionGenerator of the finished function. The generator has no incoming edge, and no roots entry reaches any of them.
  • gdb, breakpoint on ConservativeRoots::genericAddSpan<CompositeMarkHook> during the next collection, search of the scanned span (10,432 bytes) for the five cell addresses: one hit, the generator. I walked the frame-pointer chain by hand. The word is at rbp-64 of the 272-byte frame of runInternalMicrotask(), under drainWithUseCallOnEachMicrotask() and VM::drainMicrotasks(). The job that runs is AsyncModuleExecutionResume, which does not write that slot.

Earlier sightings of this frame: #37853 (the same generator word), #41607 (darwin arm64 release build). #42877 saw ASAN redzone words of it, which oven-sh/WebKit#673 now covers.

The alternative in Bun. #37853 calls sanitizeStackForVM() once per event loop tick. I tried that on a build of this branch with the engine option off: it collects the same six cases at turn 1, and costs the same (2522 ms against 2525 ms per 1 M setImmediate turns, 2504 ms with neither). Differences: it needs something to have sanitized from below the stale word since the word was written, it clears per tick and not per checkpoint, and it also covers Bun's own frames and entries that are not checkpoints. The two do not exclude each other. The test in this PR passes with either.

Cost (Xeon 8375C, release build, clang 23): a checkpoint with one job goes from 101 ns to 122 ns in the jsc shell. In Bun, 1 M turns of await new Promise(r => setImmediate(r)) take 2531 ms without the clear and 2547 ms with it (medians of 11, same binary). 20 M awaits in one checkpoint: 602 ms and 602 ms.

Also run with bun bd on this branch: test/js/web/timers/microtask.test.js, test/js/bun/net/socket-retention.test.ts, test/js/web/fetch/fetch-stream-cancel-leak.test.ts, test/js/bun/http/serve-pending-promise-abort-leak.test.ts, test/js/web/streams/streams.test.js: all pass, and the new engine assertion (a job of a checkpoint runs inside the cleared window) does not fire.

Self-review of the engine change. It asked for five things, and oven-sh/WebKit#681 has them: a rebase onto oven-sh/WebKit#673, the scope moved from the ASAN lane to normal slots, the code comment no longer names redzones, an assertion that a job runs inside the cleared window, and the comparison with #37853 above.

The fail-before proof for this PR is the stock canary and the engine option, not a checkout of src/ from main. The fix is in the WebKit prebuilt that scripts/build/deps/webkit.ts names.


[policy-decision:webkit] gate passed · iteration 0 · 2 files touched

passes on PR (with fix)
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/js/bun/gc/microtask-checkpoint-stale-stack.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/bun/gc/microtask-checkpoint-stale-stack.test.ts
bun test v1.4.3 (c6b7fcb5b)

test/js/bun/gc/microtask-checkpoint-stale-stack.test.ts:
(pass) what a finished async function held is collected from a later event loop turn > by the module, turns via Bun.sleep [298.49ms]
(pass) what a finished async function held is collected from a later event loop turn > by an async function, turns via setImmediate [300.66ms]
(pass) what a finished async function held is collected from a later event loop turn > by an async function, turns via setTimeout [305.87ms]
(pass) what a finished async function held is collected from a later event loop turn > by the module, turns via setTimeout [410.57ms]
(pass) what a finished async function held is collected from a later event loop turn > by the module, turns via setImmediate [361.51ms]
(pass) what a finished async function held is collected from a later event loop turn > by an async function, turns via Bun.sleep [283.36ms]

 6 pass
 0 fail
 18 expect() calls
Ran 6 tests across 1 file. [2.48s]
Exit: 0
diff hotspot
scripts/build/deps/webkit.ts                       |  2 +-
 .../gc/microtask-checkpoint-stale-stack.test.ts    | 81 ++++++++++++++++++++++
 2 files changed, 82 insertions(+), 1 deletion(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                     reads  edits  tests
scripts/build/deps/webkit.ts                                 1      0     23
test/js/bun/gc/microtask-checkpoint-stale-stack.test.ts      1      5     23

…vent loop turn

Every event loop turn that resumes an async function runs a microtask
checkpoint at the same stack depth. A job writes only some slots of the
frames of the drain and of runInternalMicrotask(), so what a job of an
earlier turn left there was a root of every Bun.gc(true) made from a
later turn. The fixture makes garbage in an async function, then collects
from later turns, in the module's continuation and in an async function,
with turns via setTimeout, setImmediate and Bun.sleep.
A microtask checkpoint that has jobs to run first clears the stack that
the frames of the drain, of runInternalMicrotask() and of the job are
going to occupy. What a job of an earlier event loop turn left in those
frames is then no longer a root of a collection made from a later turn.

The pin is autobuild-preview-pr-681-98326556: c28156899e plus the one
commit of that PR.
@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Closed without merge. The reasons and the evidence are in the closing comment below.

@coderabbitai

coderabbitai Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: c5afb654-5667-419a-8c6f-8cf41f1dfa0e

📥 Commits

Reviewing files that changed from the base of the PR and between b8eacea and 008752a.

📒 Files selected for processing (2)
  • scripts/build/deps/webkit.ts
  • test/js/bun/gc/microtask-checkpoint-stale-stack.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

The change pins WebKit to a preview build containing the microtask checkpoint fix. It adds a Bun regression test that checks stale stack roots do not retain objects across event-loop turns.

Changes

Microtask checkpoint garbage collection

Layer / File(s) Summary
WebKit preview build pin
scripts/build/deps/webkit.ts
WEBKIT_VERSION now uses autobuild-preview-pr-681-98326556.
Stale stack root regression test
test/js/bun/gc/microtask-checkpoint-stale-stack.test.ts
A fresh-process test forces GC across setTimeout, setImmediate, and Bun.sleep turn boundaries. It verifies that objects from completed async functions are collected and that the process exits successfully.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 00875

The WebKit pin and its regression coverage have no actionable current-head issue identified, so the change is ready to merge after normal checks.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR addresses the coding objective in [#681] by pinning WEBKIT_VERSION to autobuild-preview-pr-681-98326556, which the PR summary identifies as the WebKit build containing the microtask checkpo…
Out of Scope Changes check ✅ Passed The changes stay within [#681]. The version update consumes the required WebKit fix. The regression test validates stale stack roots across the event-loop boundaries described by the issue. No unrelat…
Title check ✅ Passed The title clearly identifies the primary change: pinning WebKit to the preview build for WebKit#681 and the microtask checkpoint stack fix. It is specific and related to the changeset.
Description check ✅ Passed The description provides detailed context, explains the fix, identifies the preview pin, and documents verification results. It does not use the exact template headings, but it covers both required to…

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

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

Beyond the inline note on the preview-tag pin, I also checked the new test's plumbing: BUN_GC_TIMER_DISABLE is consumed by src/jsc/GarbageCollectionController.rs, each case runs in a fresh subprocess with pipes drained concurrently, and the cleanup is registered via using before the assertions — no issues there. The engine change itself (oven-sh/WebKit#681) is outside this repo, so a human should confirm it has merged and swap the pin to the merged SHA before landing.

Extended reasoning...

The only substantive Bun-side content is a version pin and a new regression test; the behavioral fix lives in an external WebKit PR that cannot be inspected from this checkout. The inline finding already covers the ephemeral pin. Separately I verified the test follows harness conventions (spread bunEnv, tempDir, describe.concurrent, Promise.all over stdout/stderr/exited, output asserted before exit code) and that the env knob it sets is actually read by the GC controller, so the test is not vacuous on that axis. The remaining risk is the upstream engine change and its merge status, which a human needs to check.

Comment thread scripts/build/deps/webkit.ts
@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

On the review of the pin: agreed. This PR must not merge while WEBKIT_VERSION is the preview tag. The order is: oven-sh/WebKit#681 merges, then I move the pin here to its autobuild-<merge sha> release, then this PR can merge. The description now states that as a precondition, and the review thread on scripts/build/deps/webkit.ts stays open until the pin is a merged sha.

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:08 PM PT - Sep 16th, 2026

❌ @robobun, your commit 008752a has 1 failures in Build #116722 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42975

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

bun-42975 --bun

@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Closing this PR. Three reasons:

  1. The pinned engine change ([JSC] A microtask checkpoint clears the stack that its frames are going to occupy WebKit#681) is not sufficient. On Windows aarch64 the new test still fails 4 of 6 cases (build 116722). A stack scan on that machine finds the stale word inside the frame of libuv's uv_run(). uv__poll() is inlined into it with an uninitialized OVERLAPPED_ENTRY overlappeds[128] (4 KB, vendor/libuv/src/win/core.c:466) that only completions write, and that frame is live for the whole tick. JSC cannot reach it. Remove libuv on Windows #42819 removes libuv on Windows.
  2. The change that covers every holder I found is one sanitizeStackForVM() per event loop tick: 6 of 6 on Linux x64, Windows x64 and Windows aarch64 in my runs, with the engine change off. That hook was already rejected in review in GarbageCollectionController: replace per-tick heap sampler with idle timer only #35356, and ai slop #33225 before it. I did not push it here.
  3. test: deflake h2 stream-release and CompressionStream backpressure tests #40966 classifies this retention as not a runtime bug, and says that a test must not assert an exact collection. The test in this PR asserts exactly that. [JSC] The conservative scan does not read stack words ASan has poisoned WebKit#673 already fixed the ASAN lane failures that started this work, and the three tests that were red there are green in the last builds I checked (116700 to 116730).

The analysis stays here and in oven-sh/WebKit#681 for reference. I closed that PR too.

@robobun robobun closed this Sep 17, 2026
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