Skip to content

jsc: let worker.terminate() preempt pure-Wasm loops - #36356

Draft
robobun wants to merge 1 commit into
mainfrom
farm/002e8c8f/wasm-loop-terminate
Draft

jsc: let worker.terminate() preempt pure-Wasm loops#36356
robobun wants to merge 1 commit into
mainfrom
farm/002e8c8f/wasm-loop-terminate

Conversation

@robobun

@robobun robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Problem

A Worker running a WebAssembly function whose body is a tight loop with no JS re-entry (e.g. (loop (br 0))) cannot be preempted by worker.terminate(): the promise never settles, no exit event fires, and the thread spins at 100% CPU until the process dies. JS for(;;){} and Wasm loops that call an imported JS function per iteration are already preemptible because they reach a JS-side trap check; Node terminates all three shapes in a few ms.

$ bun repro.mjs
js       -> terminated(code 1) 3ms
wasmcall -> terminated(code 1) 196ms
wasm     -> HUNG >15s 15001ms

Any host that isolates untrusted or third-party Wasm in a Worker cannot reclaim a spinning module: the worker is unkillable and burns a core forever. Since the worker-lifetime rework (#37075) joined worker teardown, the blast radius is larger: a parent worker calling process.exit() while a descendant spins in Wasm now wedges the whole process instead of exiting.

Cause

Root cause is in JavaScriptCore, not Bun's worker plumbing. VM::notifyNeedTermination() fires VMTraps::NeedTermination -> StackManager::requestStop() sets m_trapAwareSoftStackLimit on every registered Mirror (including each JSWebAssemblyInstance's m_stackMirror) to UINTPTR_MAX. The only Wasm-side consumer of that field was the IPInt function prologue; BBQ/OMG prologues read the non-trap-aware m_softStackLimit, and no tier reads it at loop back-edges. The VMTraps::SignalSender path only patches JS DFG/FTL CodeBlocks. A pure-Wasm loop therefore never observes the termination request.

Fix

The JSC change is in oven-sh/WebKit#372: add a trap-aware stack-limit poll at each Wasm loop head (IPInt / BBQ / OMG). One load + one predicted-not-taken branch per back-edge; the slow path services the trap under a probe so non-termination async traps (NeedStopTheWorld / NeedWatchdogCheck / NeedDebuggerBreak) resume the loop with all live state preserved, and termination unwinds via ExceptionType::Termination. That PR is now rebased onto fork main 78d45d3184 (one commit ahead of the 171babe26c this repo currently pins), so the bump stays a single commit.

This PR bumps WEBKIT_VERSION to the oven-sh/WebKit#372 preview build (autobuild-preview-pr-372-a50d1e50, full 38-asset matrix published) and adds test/js/web/workers/worker-terminate-wasm-loop.test.ts, which spawns three workers (JS loop, Wasm loop calling a JS import, pure Wasm loop) and asserts terminate() resolves with {terminated: true, code: 1, exited: 1} for each.

Verification

Built bun main (9008ae7ab) against the rebased WebKit branch as a local WebKit (linux x64, debug+ASAN):

  • worker.terminate() on the pure-Wasm loop resolves (exit event fires, process exits); unpatched hangs forever
  • the new test passes 3/3 (js / wasmcall / wasm)
  • nested tree: main -> worker -> worker spinning in pure Wasm, middle worker calls process.exit(5): exits cleanly 3/3; on current main this never exits
  • wasm-streaming.test.ts (33) and bun-build-compile-wasm.test.ts pass; worker.test.ts shows the same 3 timing-sensitive failures as an unpatched debug build of the same tree (pre-existing)

Merge sequencing: (1) oven-sh/WebKit#372 merges, (2) repin WEBKIT_VERSION here to the merged 40-hex sha, (3) merge this PR. The preview pin is CI-only.

Note: the fix is entirely in the vendored WebKit (via WEBKIT_VERSION), so the src-stash fail-before check is not mechanically applicable here; USE_SYSTEM_BUN=1 bun test on the new test file demonstrates the fail-before (wasm -> HUNG).


[decide:webkit] gate passed · iteration 2 · 2 files touched

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

Debug/ASAN (expected pass):
$ bun bd test 'test/js/web/workers/worker-terminate-wasm-loop.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/web/workers/worker-terminate-wasm-loop.test.ts
bun test v1.4.0 (b16dd4cdb)

test/js/web/workers/worker-terminate-wasm-loop.test.ts:
(pass) worker.terminate() preempts infinite loops > js [2819.36ms]
(pass) worker.terminate() preempts infinite loops > wasmcall [2802.64ms]
(pass) worker.terminate() preempts infinite loops > wasm [2995.33ms]

 3 pass
 0 fail
 9 expect() calls
Ran 3 tests across 1 file. [5.42s]
Exit: 0
diff hotspot
scripts/build/deps/webkit.ts                       |  2 +-
 .../web/workers/worker-terminate-wasm-loop.test.ts | 84 ++++++++++++++++++++++
 2 files changed, 85 insertions(+), 1 deletion(-)

gate history · 1 passed · 1 rejected · iteration 2

evidence per changed file
file                                                    reads  edits  tests
scripts/build/deps/webkit.ts                                1      3      0
test/js/web/workers/worker-terminate-wasm-loop.test.ts      0      4      0

@coderabbitai

coderabbitai Bot commented Jul 29, 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: 3db796cc-657d-4790-9b98-eb870cad828c

📥 Commits

Reviewing files that changed from the base of the PR and between 6c04f4b and b193049.

📒 Files selected for processing (2)
  • scripts/build/deps/webkit.ts
  • test/js/web/workers/worker-terminate-wasm-loop.test.ts

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

@robobun

robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:22 AM PT - Aug 9th, 2026

@robobun, your commit b16dd4cdbfdd3abf47346bfe850af19873fe08d7 passed in Build #90952! 🎉


🧪   To try this PR locally:

bunx bun-pr 36356

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

bun-36356 --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.

Beyond the two test-file nits, I checked that prebuiltUrl() / prebuiltDestDir() in scripts/build/deps/webkit.ts already handle the autobuild-preview-pr-* tag shape (they do — release-tag prefix and cache-dir keying both branch on startsWith("autobuild-")), and that download.ts has a dedicated 404 message for when the preview release disappears. The test itself is well-constructed: subprocess isolation, concurrent pipe drain, exact-value assertions on {terminated, code, exited}, and the wasm case demonstrably hangs on the pre-bump build.

The substantive change here is the WebKit bump — the actual fix lives in oven-sh/WebKit#372, which adds a trap-aware stack-limit poll at every Wasm loop back-edge across IPInt/BBQ/OMG. That's JIT-codegen and hot-path territory in the JS engine, and WEBKIT_VERSION now points at an unmerged preview tag, so this needs a maintainer's eyes on the WebKit PR and the bump sequencing rather than auto-approval.

Extended reasoning...

This PR's Bun-side diff is minimal (a version-string bump plus a new test), and the test file follows repo conventions well. But the effective change is a JavaScriptCore engine bump that pulls in a JIT codegen modification touching every Wasm loop back-edge across three tiers — that is core-runtime, performance-sensitive, and lives in an external PR (oven-sh/WebKit#372) that this review cannot inspect. Pinning WEBKIT_VERSION to an autobuild-preview-pr-* tag is a supported pattern in the build scripts, but it also means main will 404 on WebKit fetch the moment that PR merges or closes, so merge sequencing is a maintainer decision. The two inline findings are style-level nits on the test file and would not on their own block approval; the reason to defer is the engine bump, not the nits.

Comment thread test/js/web/workers/worker-terminate-wasm-loop.test.ts Outdated
Comment thread test/js/web/workers/worker-terminate-wasm-loop.test.ts
@robobun

robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

WebKit preview build 972b37c3 failed to assemble on ARM64: the IPInt loop VMTraps poll used bpbeq mem, sp, label, which offlineasm lowers to subs xzr, <scratch>, sp and ARM64 can't encode sp as Rm in SUBS (only as Rn). Fixed in oven-sh/WebKit@3cb341c2 by swapping to the logically equivalent bpaeq sp, mem, label.

Bumped WEBKIT_VERSION to the new preview tag; Bun CI will 404 on the WebKit fetch until oven-sh/WebKit#372's autobuild finishes.

@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 nit: both earlier review nits (wire error → reject, comment the warm sleep) are addressed in 5093966. Also checked that scripts/build/deps/webkit.ts handles the autobuild-preview-pr-* tag shape — prebuiltUrl skips the double-prefix and prebuiltDestDir keys on the full tag, so the mechanical bump is fine. The substantive change (Wasm loop back-edge trap poll across IPInt/BBQ/OMG) lives in oven-sh/WebKit#372 and pins main to a preview autobuild — leaving that merge-order call to a maintainer.

Extended reasoning...

The Bun-side diff is a one-line WEBKIT_VERSION bump plus a well-structured regression test; the test now incorporates both fixes from my previous review pass. The only new finding this run is a cosmetic redundant assertion. What actually needs human eyes is not in this diff: the JSC change adds a per-back-edge poll to every Wasm tier's codegen, and WEBKIT_VERSION is being pointed at an unmerged WebKit PR's preview tag — both are maintainer decisions I can't make.

Comment thread test/js/web/workers/worker-terminate-wasm-loop.test.ts Outdated
@robobun

robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed review refinements to oven-sh/WebKit@bff0b81e and bumped WEBKIT_VERSION here in b193049:

  • OMG: lower the poll as plain B3 IR (Load + Above + Branch) into a FrequencyClass::Rare block, matching FTL compileCheckTraps. The probe patchpoint's clobber set (macroClobberedGPRs + nonPreservedNonArgumentGPR0) is now confined to the rare block so the hot loop body keeps its full register budget. Compare against framePointer() since B3 has no SP value; the poisoned limit is UINTPTR_MAX so fp triggers identically.
  • BBQ: branchPtr(Below, sp, Address(...)) instead of branchPtr(Above, Address(...), sp), matching the offsetOfSoftStackLimit checks in the same file and dropping ARM64's extra mov when sp is Rm. Route the throw via recordJumpToThrowException so functions with multiple loops share one Termination sink.
  • IPInt: corrected the ipint_loop_check_vm_traps comment.

WebKit CI for the previous revision (3cb341c2) was 39/39 green; Bun CI will 404 on the prebuilt until the bff0b81e autobuild finishes.

Comment thread scripts/build/deps/webkit.ts Outdated
* From https://github.com/oven-sh/WebKit releases.
*/
export const WEBKIT_VERSION = "549170099226f816a4b204ea1d8fa102fb79eefa";
export const WEBKIT_VERSION = "autobuild-preview-pr-372-bff0b81e";

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.

🔴 WEBKIT_VERSION is pinned to autobuild-preview-pr-372-bff0b81e, an ephemeral preview release for the still-open oven-sh/WebKit#372. Per the repo's own docs (scripts/build/download.ts:278, .claude/commands/upgrade-webkit.md:34, .claude/docs/landing-prs.md:47), preview releases are deleted when the WebKit PR merges/closes, so landing this as-is will 404 every fresh build's prebuilt fetch. Before merging this PR, oven-sh/WebKit#372 must land and WEBKIT_VERSION must be repinned to the merged commit's stable 40-hex sha.

Extended reasoning...

What the pin points at

scripts/build/deps/webkit.ts:6 changes WEBKIT_VERSION from a stable 40-hex commit sha (549170099226f816…) to autobuild-preview-pr-372-bff0b81e. That is a preview release tag keyed to an unmerged oven-sh/WebKit pull request (#372). prebuiltUrl() at scripts/build/deps/webkit.ts:73-79 will resolve this to https://github.com/oven-sh/WebKit/releases/download/autobuild-preview-pr-372-bff0b81e/bun-webkit-<os>-<arch><suffix>.tar.gz for every fresh build.

Why this is a merge-blocker per the repo's own rules

Three separate places in this repo document that preview tags must never reach main:

  • .claude/docs/landing-prs.md:47 (Dependencies & vendoring): "Never merge a pin to an ephemeral artifact (preview tags, unmerged-PR builds) — swap to the merged upstream SHA and verify prebuilt artifacts exist for every platform × flavor before merge."
  • scripts/build/download.ts:278-279: "autobuild-preview-pr-* WebKit tags are the sharp edge: GitHub deletes the preview release when the PR merges or closes, so every build 404s at once."
  • .claude/commands/upgrade-webkit.md:34: "Preview mode: also note in the description that WEBKIT_VERSION points at a preview build and must be bumped to the merge-commit's autobuild-<sha> after the oven-sh/WebKit PR merges — do that bump before merging the bun PR."

The build system explicitly supports autobuild-preview-* tags (prebuiltUrl and prebuiltDestDir both branch on startsWith('autobuild-')), but that support exists so an in-flight Bun PR can run CI against an unreleased WebKit — not so main can depend on it. git log -S 'autobuild-preview' shows no preview tag has ever landed on main; only this branch uses one.

Why nothing else guards this

test/internal/source-lints/webkit-prebuilt-url.test.ts:123-125 accepts autobuild-* as a valid WEBKIT_VERSION form (it has to, so the branch itself passes CI), so no automated check catches this — human review is the only gate. The PR timeline confirms the author knows the state is temporary ("Bun CI will 404 on the prebuilt until the bff0b81e autobuild finishes"), but the PR description does not carry the "must be repinned before merge" note the workflow doc requires, and no existing review comment flags it as merge-blocking.

Step-by-step: what happens if this merges as-is

  1. This PR merges to main with WEBKIT_VERSION = "autobuild-preview-pr-372-bff0b81e".
  2. wasm: poll VMTraps at loop back-edges so pure-Wasm loops can be terminated WebKit#372 is subsequently merged (or closed, or force-pushed and re-tagged).
  3. GitHub deletes the autobuild-preview-pr-372-bff0b81e release — this is documented behavior at download.ts:278-279.
  4. Every fresh Bun build (CI, contributor clones, release pipeline) hits prebuiltUrl(cfg)https://github.com/oven-sh/WebKit/releases/download/autobuild-preview-pr-372-bff0b81e/… → HTTP 404.
  5. prebuiltDownloadError() at download.ts:282-294 fires with "WebKit preview release is gone" and the build fails. Every branch based on main is broken until someone pushes a repin.

Additionally, while the preview exists, main depends on code from an unmerged, force-pushable branch — the exact contents behind the fix are not yet on oven-sh/WebKit main.

Fix

Do not merge until:

  1. wasm: poll VMTraps at loop back-edges so pure-Wasm loops can be terminated WebKit#372 has merged, and
  2. WEBKIT_VERSION is updated to the merged commit's 40-hex sha (the stable autobuild-<sha> release), and
  3. gh release view autobuild-<sha> --repo oven-sh/WebKit confirms artifacts exist for every platform × flavor.

Then this PR is a one-line change back to the shape the doc-comment above the constant describes: "WebKit commit — determines prebuilt download URL."

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Correct, and intentional for CI verification against the unmerged JSC change. Merge sequencing is: (1) oven-sh/WebKit#372 merges, (2) this PR repins WEBKIT_VERSION to the merged 40-hex sha, (3) this PR merges. Leaving this thread open as the merge-blocker reminder.

@robobun
robobun marked this pull request as draft July 29, 2026 12:39
Bump WEBKIT_VERSION to the oven-sh/WebKit#372 preview build (rebased onto
fork main 78d45d3184 as a50d1e5072): JSC now polls the trap-aware stack
limit at every Wasm loop back-edge (IPInt/BBQ/OMG), so a Worker spinning in
a pure-Wasm loop (no JS re-entry, e.g. loop { br 0 }) observes
VM::notifyNeedTermination() instead of never reaching a trap check.

worker-terminate-wasm-loop.test.ts covers the three loop shapes (JS loop,
Wasm loop calling a JS import, pure Wasm loop) and asserts terminate()
resolves with exit code 1 for each.

Before merging, oven-sh/WebKit#372 must land and WEBKIT_VERSION must be
repinned to the merged 40-hex sha; the preview tag is CI-only.
@robobun
robobun force-pushed the farm/002e8c8f/wasm-loop-terminate branch from b193049 to b16dd4c Compare August 9, 2026 14:28
@robobun

robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

Refreshed this PR:

  • Rebased wasm: poll VMTraps at loop back-edges so pure-Wasm loops can be terminated WebKit#372 onto fork main 78d45d3184 (clean apply, squashed to a50d1e5072); its preview build completed green with the full 38-asset matrix: autobuild-preview-pr-372-a50d1e50.
  • Rebased this branch onto main (9008ae7ab) and repinned WEBKIT_VERSION to the new preview tag, resolving the conflict from Worker / worker_threads: WebCore-shaped lifetimes, joined threads, one ordered VM teardown #37075's pin bump. Flattened to a single commit.
  • The previous CI failure was a race: the build fetched the preview tarball before the WebKit autobuild had uploaded it. Assets exist this time.
  • New verification on current main: besides the three terminate shapes, the nested case (main -> worker -> worker spinning in pure Wasm, middle worker calls process.exit(5)) now exits cleanly 3/3; on current main it hangs forever because joined teardown waits on the unkillable Wasm loop.

Still blocked on oven-sh/WebKit#372 review/merge, then the repin to a stable sha (tracked in the open thread on webkit.ts).

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