Skip to content

Bump WebKit: assert the VM's API lock is held on HandleSet mutation - #36958

Open
robobun wants to merge 3 commits into
mainfrom
farm/5c0973f7/handleset-apilock-detector
Open

Bump WebKit: assert the VM's API lock is held on HandleSet mutation#36958
robobun wants to merge 3 commits into
mainfrom
farm/5c0973f7/handleset-apilock-detector

Conversation

@robobun

@robobun robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Gives the #30185 bug class (a strong handle owned by one VM, mutated from a thread that does not hold that VM's API lock) a deterministic, self-verifying detector. Three pieces:

  1. WebKit bump picking up HandleSet: assert the VM's API lock is held when mutating handles WebKit#387: HandleSet::allocate, deallocate, and writeBarrier now assert (debug builds only) that the current thread holds the owning VM's API lock, matching the existing assertion in Heap::protect/unprotect. The pin currently points at that PR's preview build (autobuild-preview-pr-387-f6049b84) and flips to the merge commit once it lands.
  2. Mirror asserts for the Rust-side half: since Strong: back bun_jsc::Strong with StrongRootBlock; free AbortSignal.timeout at wrapper GC #35849, bun_jsc::Strong routes through StrongRootBlock slots instead of JSC's HandleSet, and the "Srb" marking constraint scans that list under the same mutator-only assumption, with no guard (Strong.rs checks only that the dropping thread has a VM, not the owner VM's lock, and the C++ Bun::StrongRef deleter bypasses even that). Bun__StrongRef__new/set/delete now carry the same debug-only assertion, checked against the owner VM (the block's), so holding some other VM's lock does not pass.
  3. A liveness test for both detectors: jscInternals.crossThreadStrongHandleMutation(kind) (debug builds only) deliberately violates the contract from a spawned thread, and test/js/bun/jsc/strong-handle-thread-guard.test.ts asserts the child aborts with each guard's message. This is the piece that keeps the detectors from dying the way the old guard did: if a future WebKit bump or binding refactor drops an assertion, this test fails in the debug lanes instead of the coverage silently disappearing.

Why

These mutation paths write lists that GC marking constraints scan ("Sh" for HandleSet, "Srb" for StrongRootBlock); the API lock is what orders mutations with the scans. A JSC::Strong captured by value in a cross-thread lambda and destroyed on the other thread was exactly #30185: panic: Segmentation fault at address 0x10 in the Sh constraint, or a livelock on the torn SentinelLinkedList.

The class currently has no effective guard. The stress workload in worker_heap_snapshot_gc.test.ts was sized (#35200) to catch a reintroduced #30185 at roughly 60% per process, but after #35356 replaced per-tick GC collections with an idle timer, the same reintroduced bug produced 0 detections in 18,000 iterations (measured in #36952, which resizes the now-ineffective workload and defers a deterministic detector to this PR). Detection no longer depends on a GC scan landing in a few-instruction window: the unlocked mutation itself aborts, naming the invariant and the call site.

Verification

  • Reintroduced the canonical worker: fix cross-thread HandleSet race in getHeapSnapshot #30185 bug (by-value Strong<JSPromise> capture in getHeapSnapshot's cross-thread lambda) on top of the bump: bun bd aborts on the first worker.getHeapSnapshot() round-trip, every run:

    ASSERTION FAILED: Strong handles may only be created, written, or destroyed while holding their VM's API lock
    vendor/WebKit/Source/JavaScriptCore/heap/HandleSet.cpp(106) : void JSC::HandleSet::assertMayMutate()
    
  • The new liveness test passes with this diff (both children die with the right message) and fails without the src/ changes (the hook is gone, the child survives), on linux x64 debug+ASAN.

  • No spurious assertion failures across the Strong-heavy suites locally (debug+ASAN): worker_threads, web workers, fetch (11,232 tests), streams, Bun.spawn, dns, timers, sqlite + Bun.password, node:vm, bun shell, plugin, bun:jsc, node:http2, mock functions. The only local failures are machine artifacts that reproduce identically without this change (stress-test timeouts on a throttled container, plus two already-tracked pre-existing bugs found along the way). First CI round on the bump alone: all 26 build/link lanes green, zero assertion failures anywhere.

  • Release builds are unchanged: both assertions compile out (ASSERT_ENABLED=0), and the testing hook is not registered.

Upstream note: WebKit/WebKit@ff64aee116 replaces HandleSet with StrongSet/StrongBlock, so the fork's next upstream merge will hit a delete/modify conflict on these files and needs to port the assertion (tracked on oven-sh/WebKit#387). The liveness test is what enforces that port: if it gets dropped, the debug lanes fail.


[decide:webkit] gate passed · iteration 0 · 5 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/jsc/strong-handle-thread-guard.test.ts
bun test v1.4.0 (cce71d34a)

test/js/bun/jsc/strong-handle-thread-guard.test.ts:
29 |       env: noReportEnv,
30 |       stdio: ["ignore", "pipe", "pipe"],
31 |     });
32 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
33 | 
34 |     expect(stderr).toContain("ASSERTION FAILED");
                        ^
error: expect(received).toContain(expected)

Expected to contain: "ASSERTION FAILED"
Received: "1 | import { jscInternals } from \"bun:internal-for-testing\";\n2 | \n3 | // Violates the strong-handle thread-affinity contract on purpose; in a debug\n4 | // build the assertion must abort the process inside this call.\n5 | jscInternals.crossThreadStrongHandleMutation(process.argv[2]);\n                 ^\nTypeError: jscInternals.crossThreadStrongHandleMutation is not a function. (In 'jscInternals.crossThreadStrongHandleMutation(process.argv[2])', 'jscInternals.crossThreadStrongHandleMutation' is undefined)\n      at /work
... (truncated)

release without fix: 2 skipped
bun test v1.4.0-canary.1 (b66764ff3)

test/js/bun/jsc/strong-handle-thread-guard.test.ts:
(skip) unlocked off-thread strong mutation aborts with the guard's message
(skip) unlocked off-thread strongRef mutation aborts with the guard's message

 0 pass
 2 skip
 0 fail
Ran 2 tests across 1 file. [126.00ms]
__F:0:S:2
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/jsc/strong-handle-thread-guard.test.ts
bun test v1.4.0 (cce71d34a)

test/js/bun/jsc/strong-handle-thread-guard.test.ts:
(pass) unlocked off-thread strong mutation aborts with the guard's message [1063.33ms]
(pass) unlocked off-thread strongRef mutation aborts with the guard's message [1052.86ms]

 2 pass
 0 fail
 8 expect() calls
Ran 2 tests across 1 file. [4.13s]
__F:0:S:0

release with fix: 2 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     cce71d34a1
  features     baseline

22 deps, 106 codegen, 1175 objects in 693ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] gen ErrorCode+*.h
[2/1238] install /workspace/bun
bun install v1.4.0-canary.1 (b66764ff3)

Checked 124 installs across 170 packages (no changes) [16.00ms]
[3/1238] gen bindgenv2
[4/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (b66764ff3)

Checked 1 install across 2 packages (no changes) [1.00ms]
[5/1238] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[6/1238] fetch picohttpparser
[picohttpparser] up to date
[7/1238] fetch zlib
[zlib] up to date
[8/1238] fetch tinycc
[tinycc] up to date
[9/1237] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (b66764ff3)

Checked 129 installs across 147 packages (no changes) [9.00ms]
[10/1237] gen .bind.ts → GeneratedBindings.cpp
[11/1237] subst deps/zlib/zlib.h
[12/1237] subst deps/zlib/zconf.h
[13/1188] fetch nodejs (prebuilt)
[nodejs] up to date
[14/118
... (truncated)
diff hotspot
scripts/build/deps/webkit.ts                       |  2 +-
 src/jsc/bindings/JSCTestingHelpers.cpp             | 51 ++++++++++++++++++++++
 src/jsc/bindings/StrongRef.cpp                     | 12 ++++-
 .../bun/jsc/strong-handle-thread-guard-fixture.js  |  7 +++
 test/js/bun/jsc/strong-handle-thread-guard.test.ts | 39 +++++++++++++++++
 5 files changed, 109 insertions(+), 2 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                   reads  edits  tests
scripts/build/deps/webkit.ts                               2      4      0
src/jsc/bindings/JSCTestingHelpers.cpp                     2      3      0
src/jsc/bindings/StrongRef.cpp                             2      4      0
test/js/bun/jsc/strong-handle-thread-guard-fixture.js      0      1      0
test/js/bun/jsc/strong-handle-thread-guard.test.ts         0      1      0

Brings in oven-sh/WebKit#387: HandleSet::allocate/deallocate/writeBarrier
now ASSERT (debug builds only) that the current thread holds the owning
VM's API lock, matching the existing assertion in Heap::protect/unprotect.

This gives the #30185 bug class (a Strong captured by value in a
cross-thread lambda, destroyed on another thread, corrupting the owner
VM's strong-handle list) a deterministic detector in every debug build.
The probabilistic stress guard for that class stopped detecting after
the GC scheduling change in #35356: 0 detections in 18,000 iterations
with the bug deliberately reintroduced (measured in #36952). With this
assertion the same reintroduced bug aborts on the first cross-VM
round-trip, naming the invariant and the call site.
@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Status (robobun)

  • Layer 1, WebKit side: HandleSet: assert the VM's API lock is held when mutating handles WebKit#387 (preview green on all 38 variants; this PR pins its preview tag until it merges, then flips to the merge sha). HandleSet::allocate/deallocate/writeBarrier assert the owner VM's API lock in debug builds.
  • Layer 2, bun side: the same debug assert on Bun__StrongRef__new/set/delete, the StrongRootBlock mutation paths the Rust-side bun_jsc::Strong uses, checked against the owner VM.
  • Layer 3, liveness test: test/js/bun/jsc/strong-handle-thread-guard.test.ts spawns children that violate the contract from another thread and asserts each dies with the guard's message. Fails without the src changes, passes with them; this keeps the detectors from dying silently like the old stress guard did.
  • Verified end-to-end: reintroducing the worker: fix cross-thread HandleSet race in getHeapSnapshot #30185 by-value Strong capture aborts on the first getHeapSnapshot() round-trip. No spurious assertion failures locally across worker_threads, web workers, fetch (11k tests), streams, spawn, dns, timers, sqlite, Bun.password, node:vm, shell, plugin, bun:jsc, http2, mock (debug+ASAN).
  • CI (builds 89170 and 89207): all 26 build/link lanes green both rounds; the liveness test passes on the debug lanes; zero strong-handle assertion failures anywhere. The single red test job each round is debian 13 x64-asan with failures also present on main (reported to main-break triage); the rest is known-flaky, passed alone or on retry.
  • Blocked on: HandleSet: assert the VM's API lock is held when mutating handles WebKit#387 merge (maintainer), then the pin flip here. Upstream HandleSet was just replaced by StrongSet; the port plan is on the WebKit PR and the liveness test enforces it.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The change adds VM API-lock assertions for JSC strong handles, an assertion-only cross-thread mutation helper, debug regression tests, and a new WebKit release identifier.

Changes

Strong handle thread guards

Layer / File(s) Summary
Strong handle assertions and testing helper
src/jsc/bindings/StrongRef.cpp, src/jsc/bindings/JSCTestingHelpers.cpp
StrongRef creation, mutation, and deletion assert VM API-lock ownership. An assertion-only helper exercises cross-thread mutation for strong and strongRef handles.
Debug regression tests
test/js/bun/jsc/strong-handle-thread-guard-fixture.js, test/js/bun/jsc/strong-handle-thread-guard.test.ts
Debug tests verify VM API-lock assertion failures, nonzero exit status, and the absence of the survival marker.

WebKit release update

Layer / File(s) Summary
WebKit release pin
scripts/build/deps/webkit.ts
Updates WEBKIT_VERSION to the autobuild-preview-pr-387-f6049b84 release identifier.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly identifies the WebKit bump and the primary VM API-lock assertion change.
Description check ✅ Passed The description explains the changes, motivation, implementation, verification, test results, and release-build impact in sufficient detail.

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.

No issues found in the diff itself, but deferring to a human: this is a WebKit engine bump pinned to a preview tag for an unmerged upstream PR (oven-sh/WebKit#387), with an explicit plan to flip the pin once that lands. A maintainer should confirm CI is green across all lanes and decide whether to merge on the preview tag or wait for the final sha.

What was reviewed:

  • prebuiltUrl() and prebuiltDestDir() both already special-case the autobuild- prefix, so the non-sha version string resolves to the correct release tag and a distinct cache dir.
  • The change is debug-only on the WebKit side (release builds unchanged per the description); no bun-side code paths touched.
Extended reasoning...

Overview

Single-line change to scripts/build/deps/webkit.ts: WEBKIT_VERSION moves from commit sha e6e37cda... to autobuild-preview-pr-387-f6049b84, a preview build tag for oven-sh/WebKit#387 (adds a debug-only assertion that the VM API lock is held during HandleSet mutation).

Security risks

None. This is a build-config version pin; no runtime code, no auth/crypto/permissions surface. The upstream change is a debug-only assertion.

Level of scrutiny

High, despite the tiny diff. WebKit is the JavaScript engine — the single most load-bearing dependency in the project — and the repo's review guidance explicitly calls out dependency/vendor bumps as needing situational care. More importantly, this pins to a preview artifact of an unmerged upstream PR. The PR description itself frames this as step one of a two-step process: land the preview to run full CI, then flip to the merge sha once oven-sh/WebKit#387 merges. That workflow decision (merge on preview tag vs. wait for the final sha, and confirming all 38 CI variants are actually green) is a maintainer call, not something to auto-approve.

Other factors

  • Mechanical correctness checks out: prebuiltUrl() guards against double-prefixing (version.startsWith("autobuild-")), and prebuiltDestDir() strips the prefix to key the cache dir on preview-pr-387-f6049b84, so there's no cache collision or malformed URL.
  • No new test is expected here — the assertion is a detector that only fires when bun's C++ is wrong, and the description documents verification against reintroduced #30185 plus a clean run of the Strong-heavy suites.
  • No prior reviews from me; no outstanding human reviewer comments to address.

@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Right: the preview pin is for CI validation only, and the plan is to not merge it. Once CI here is green and oven-sh/WebKit#387 lands, this PR's pin flips to the final merge commit sha (after the main-branch autobuild publishes its artifacts), so what merges into bun points at a commit on the WebKit fork's main.

…ctors

Review of the WebKit bump surfaced two gaps:

Bun__StrongRef__new/set/delete mutate the StrongRootBlock list that the
Srb marking constraint scans, with the same API-lock requirement as
JSC's HandleSet but no assertion. Add the mirror asserts, checking the
owner VM (the block's), so a thread holding some other VM's lock still
trips them. Strong.rs's existing TLS check covers only the delete path
and only proves the thread has a VM, not the owner VM's lock; the C++
Bun::StrongRef deleter bypasses it entirely.

Neither detector had a liveness test, which is how the previous
probabilistic guard for #30185 died silently (#35356, measured in
#36952). jscInternals gains a debug-only crossThreadStrongHandleMutation
hook that violates the contract from a spawned thread, and the new test
asserts the child aborts with each guard's message. If a future WebKit
bump or binding refactor drops an assertion, the test fails instead of
the coverage silently disappearing.
Comment thread src/jsc/bindings/JSCTestingHelpers.cpp Outdated
Comment thread src/jsc/bindings/StrongRef.cpp Outdated
Comment thread src/jsc/bindings/JSCTestingHelpers.cpp
Comment thread src/jsc/bindings/StrongRef.cpp

@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 didn't find any bugs in the bun-side changes, but this shouldn't merge without a maintainer: the WebKit pin currently points at a preview PR artifact (autobuild-preview-pr-387-f6049b84), and the author's own status notes it's blocked on oven-sh/WebKit#387 landing and the pin flipping to the merge sha.

What was reviewed:

  • Bun__StrongRef__set now asserts on the block's owner VM (block->vm()) rather than the passed globalObject's VM — correct, since a wrong-VM caller is exactly what this guards.
  • All three new asserts and the test hook are gated on ASSERT_ENABLED, so release builds are unchanged.
  • The liveness test drains stdout/stderr/exited concurrently, is test.if(isDebug)-gated, and disables crash reporting for the intentional aborts.
Extended reasoning...

Overview

This PR bumps the WebKit pin to a preview build of oven-sh/WebKit#387 (which adds a debug-only currentThreadIsHoldingAPILock() assertion to HandleSet::allocate/deallocate/writeBarrier), mirrors the same debug assertion onto Bun's own StrongRootBlock mutation path in StrongRef.cpp (Bun__StrongRef__new/set/delete), and adds a debug-only bun:internal-for-testing hook plus a test that spawns a child, deliberately mutates a strong handle from a thread without the API lock, and asserts the child aborts with the guard's message. The goal is a deterministic detector for the #30185 bug class (cross-thread strong-handle mutation racing the GC's Sh/Srb marking constraints).

Security risks

None. All added code is debug-only (ASSERT_ENABLED gate on both the assertion macro and the test hook registration), and the hook is exposed only through bun:internal-for-testing. Release builds are byte-for-byte unchanged in the affected functions except for the mechanical decompose of Bun__StrongRef__set into two statements, which the optimizer collapses.

Level of scrutiny

High — this is a WebKit dependency bump coupled with GC-adjacent binding code. Even though the bun-side diff is small and debug-only, the WebKit change itself touches HandleSet, which sits directly under every JSC::Strong in the process, and I can't review that half here. Per the repo's own guidance, dependency/vendor bumps warrant maintainer review.

Other factors

The decisive factor is that the PR is self-declared as not ready to merge in its current form: WEBKIT_VERSION is pinned to autobuild-preview-pr-387-f6049b84, a preview artifact for an unmerged WebKit PR. The author's status comment says "Blocked on: oven-sh/WebKit#387 merge (maintainer), then the pin flip here", and a follow-up confirms "the preview pin is for CI validation only, and the plan is to not merge it." Merging as-is would pin bun to an artifact that may be garbage-collected once the upstream PR closes. A maintainer needs to land the WebKit PR, wait for the main-branch autobuild, and flip the pin — none of which I can do.

The bun-side code itself looks correct: the assertion in Bun__StrongRef__set checks block->vm() (the owner) rather than getVM(globalObject) (the caller's), which is the right choice for catching cross-VM misuse; the test hook joins the spawned thread before returning so no stack state outlives it (moot anyway since the process aborts first); the test follows harness conventions (bunEnv spread, concurrent pipe drain, test.if(isDebug), crash reporting disabled). CI on two rounds shows all build lanes green with no spurious assertion failures. But given the pending pin flip and the nature of WebKit bumps, this needs human sign-off.

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.

2 participants