Skip to content

fix(runtime): give loop functions without locals a local so WebKit optimizes them - #103

Open
TakalaWang wants to merge 2 commits into
wasm-oj:mainfrom
TakalaWang:fix/webkit-empty-loop
Open

TakalaWang wants to merge 2 commits into
wasm-oj:mainfrom
TakalaWang:fix/webkit-empty-loop

Conversation

@TakalaWang

@TakalaWang TakalaWang commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #102. Both PRs rebuild runtime-core_bg.wasm and refresh the runtime identity pins, and those generated files cannot be merged textually, so this branch contains #102's commit. Please review only the last commit, fix(runtime): give loop functions without locals a local so WebKit optimizes them. After #102 merges, I'll rebase this branch and rebuild the runtime.

Why

On 0.2.4 in WebKit, an empty C++ for(;;); runs until the wall limit (NOJV measured 17 s interactive and 51 s for a standard run) instead of stopping at the instruction budget like Chromium (about 2.4–4 s) and Firefox (about 2.7–5.2 s). A loop with any side effect stops quickly in WebKit.

Root cause

The meter is not optimized away, and the trap is delivered; it just arrives about 20 times later than in Chromium. The instrumented empty loop is loop (result i32) i64.const 11 call $gas br 0 end. At the default budget of 1e10 that is about 9.1e8 iterations:

Engine Empty loop (__original_main, no locals) volatile loop (one local)
Chromium 151 1.3 s 0.9 s
Firefox 153 2.8 s 2.4 s
WebKit 26.5 (Playwright) 25.4 s 0.66 s

The difference in WebKit is not the loop body but the function: JavaScriptCore never enters optimized code inside a loop of a function that has no parameters and no locals. Any loop body behaves this way, including one with a memory store. Adding one unused local, or one parameter, fixes it. Evidence from the macOS 26.6 jsc shell:

  • --verboseOSR=true on the no-locals loop (2e8 cost units) logs Consider OSREntryPlan for functionCodeIndex=5 loopIndex#0 17,991,528 times, roughly once per iteration, with an OSR-entry callee already compiled. The baseline tier's tier-up check calls into the runtime on every iteration and never enters. With one local, it is logged 7 times.
  • --useOMGJIT=false (BBQ only) runs both loops in 170 ms; by default the no-locals loop takes 2400 ms. Failing to tier up makes it 14 times slower than never trying.
  • My guess, which I have not checked against WebKit source, is that the OSR-entry scratch buffer has size zero (no locals and an empty stack at the loop header), so OSR entry is refused every time.

Minimal standalone repro (plain Wasm and JS, no forge). Save it as repro.js and run jsc repro.js (macOS ships jsc at /System/Library/Frameworks/JavaScriptCore.framework/Versions/Current/Helpers/jsc), or paste it into a Safari console:

// spin and spin_with_local are the same loop; the only difference is one unused i32 local.
const bytes = new Uint8Array([0,97,115,109,1,0,0,0,1,8,2,96,0,0,96,1,126,0,3,5,4,0,0,1,1,6,6,1,126,1,66,0,11,7,26,2,4,115,112,105,110,0,2,15,115,112,105,110,95,119,105,116,104,95,108,111,99,97,108,0,3,10,61,4,19,0,3,64,35,0,66,1,125,36,0,35,0,66,0,85,13,0,11,11,21,1,1,127,3,64,35,0,66,1,125,36,0,35,0,66,0,85,13,0,11,11,8,0,32,0,36,0,16,0,11,8,0,32,0,36,0,16,1,11]);
const { exports } = new WebAssembly.Instance(new WebAssembly.Module(bytes));
const now = typeof preciseTime === "function" ? () => preciseTime() * 1000 : () => performance.now();
const log = typeof print === "function" ? print : console.log;
for (const name of ["spin", "spin_with_local"]) {
  const started = now();
  exports[name](200000000n);
  log(`${name}: ${Math.round(now() - started)} ms`);
}
(module
  (global $fuel (mut i64) (i64.const 0))
  (func $spin
    (loop $again
      (global.set $fuel (i64.sub (global.get $fuel) (i64.const 1)))
      (br_if $again (i64.gt_s (global.get $fuel) (i64.const 0)))))
  (func $spin_with_local (local i32)
    (loop $again
      (global.set $fuel (i64.sub (global.get $fuel) (i64.const 1)))
      (br_if $again (i64.gt_s (global.get $fuel) (i64.const 0)))))
  (func (export "spin") (param i64) (global.set $fuel (local.get 0)) (call $spin))
  (func (export "spin_with_local") (param i64) (global.set $fuel (local.get 0)) (call $spin_with_local)))
spin spin_with_local
macOS 26.6 jsc 5516 ms 164 ms
macOS 26.6 jsc --useOMGJIT=false 140 ms 141 ms
Node 24 (V8) 332 ms 370 ms

The 51 s standard run. forge's wall timer is on time in WebKit: a standard run of the empty loop with wallTimeLimitMs: 3000 stops at 3.0 s of execution (4.8 s including compilation), and an interactive session with a 3 s wall stops at 3.0 s. NOJV's sample runs pass no wallTimeLimitMs, so the 60 s default applies, and each sample takes about 25.7 s in WebKit to reach the instruction budget. Two samples come to about 51 s. NOJV should also pass an explicit wall limit for sample runs; there is nothing to fix in forge's watchdog.

Change

  • MeterInitializer, the final instrumentation pass that already re-encodes the whole module to set the budget, now gives every defined function that contains a loop but has no parameters and no locals one unused i32 local. It adds no instructions, so neither behaviour nor cost changes (call_per_local_cost is 0, and the local is added after cost injection anyway). It applies to native and web alike, so both hosts still run the same module.
  • runtime-core_bg.wasm is rebuilt with pnpm run runtime:build and the identity pins are refreshed, which changes cost profiles: wasm 96876c8c…, source root da89f4ee…, identity 07d2d2bf….
  • docs/architecture.md (runtime policy) and CHANGELOG.md.

Verification

  • Unit test functions_with_a_loop_and_no_params_or_locals_get_one_unused_local: only the no-params/no-locals function with a loop (including a loop nested in a block) gets the local; functions with a local, a parameter or no loop, and the injected gas function, are unchanged. It fails on main.
  • Strict-CSP WebKit regression fixtures, run in all three browsers: empty-loop-budget (standard run) and interactive-empty-loop-budget must reach instruction-limit before a 15 s wall limit.
Fixture WebKit before WebKit after Chromium Firefox
interactive-empty-loop-budget wall-time-limit at 15.0 s instruction-limit in 0.23 s 1.3 s 2.8 s
empty-loop-budget (incl. compile) wall-time-limit at 17.9 s instruction-limit in 1.0 s 1.7 s 5.7 s
interactive-instruction-limit (volatile loop) 0.6 s 0.66 s 0.9 s 2.3 s
  • cargo test, cargo fmt --check, clippy native and web with -D warnings, pnpm run runtime:check-web, and pnpm run ci:verify (977 tests) all pass.
  • Full strict-CSP suite: Chromium 112 of 112, Firefox 112 of 112, WebKit 111 of 112. The WebKit failure is python-16mb-recursion_error, which also fails on main on this machine (WebKit 26.5 runs out of native stack first). One earlier WebKit run lost its page late in the suite to a pre-existing JavaScriptCore crash (SIGSEGV in SharedArrayBufferContents::grow ← Wasm::Memory::growShared in a Wasmer SDK Worker). main crashes the same way in 2 of 3 full WebKit runs on this machine; details in fix(browser): reject promptly when a Worker dies without an error event #104. The rerun completed.

Risks

  • Instrumented modules change by a few bytes per affected function; nothing that reads them depends on that.
  • If a later JavaScriptCore fixes OSR entry, the local is harmless and can be dropped.
  • The new runtime identity invalidates cached cost profiles.

🤖 Generated with Claude Code

…iling with EPIPE

Closes wasm-oj#101. When one side of an interact session exited or closed its stdin, its peer's next write failed with EPIPE on both hosts. A CPython interactor that replied to a contestant that had already exited died with BrokenPipeError and exit 120, and because the stdout wrapper records into the transcript before writing to the pipe, each retried flush added the reply again (five copies). Judges that run both programs natively keep draining each program's output until it exits, so the interactor reads EOF and exits with its own verdict.

InteractiveOutput (server) and StreamOutput (browser side Workers) now treat a broken pipe as a successful write: the bytes are already in the transcript and are dropped. Reads from a side that has exited still return the remaining buffered bytes and then EOF. The transcript records each byte a side writes exactly once, up to its output limit, including bytes written after the peer exited, so it depends only on what the writer wrote.

Tests cover both hosts: runtime-core unit tests for writes after either side exits and EOF after buffered bytes, server integration tests with C and CPython interactors exiting 42/43 after replying to an exited contestant, and strict-CSP browser fixtures for the same cases plus the reply-before-EOF race. runtime-core_bg.wasm is rebuilt and the runtime identity pins refreshed, which changes cost profiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…timizes them

In WebKit an empty C++ for(;;); took about 25 s to reach the default instruction budget (Chromium 1.3 s, Firefox 2.9 s), so it usually hit the wall limit first and was reported as a wall-time TLE.

The meter is not optimized away; the trap does arrive, just late. JavaScriptCore (Safari 26, also the macOS 26.6 jsc shell) never enters optimized code inside a loop of a function that has no parameters and no locals. With --verboseOSR the baseline tier logs "Consider OSREntryPlan" about once per iteration (18 million times in 2e8 cost units) although the OMG entry code exists, so every iteration takes the tier-up slow path: about 14 times slower than BBQ alone (--useOMGJIT=false) and 100 times slower than the same loop in a function with one unused local. The forge meter makes no difference; any loop body behaves the same way.

The final instrumentation pass (MeterInitializer, which already re-encodes the module to set the budget) now gives every function that contains a loop but has no parameters or locals one unused i32 local. It changes neither behaviour nor cost. WebKit now stops the empty loop at the budget in about 0.2 s; Chromium and Firefox are unchanged.

A unit test covers which functions receive the local, and the strict-CSP suite gains empty-loop fixtures for run and interact that must reach instruction-limit before a 15 s wall limit. runtime-core_bg.wasm is rebuilt and the runtime identity pins refreshed, which changes cost profiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant