Skip to content

fix(runtime): meter interactive programs in-module like standalone runs - #95

Merged
JacobLinCool merged 3 commits into
wasm-oj:mainfrom
TakalaWang:fix/interactive-instruction-budget
Oct 7, 2026
Merged

JacobLinCool merged 3 commits into
wasm-oj:mainfrom
TakalaWang:fix/interactive-instruction-budget

Conversation

@TakalaWang

@TakalaWang TakalaWang commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Why

Interactive programs were metered differently from standalone runs, and the difference broke time limits:

  • Standalone runs use radix's in-module meter (a mutable global), which also charges its gas function's weighted cost (10) for every metered block.
  • interact used a host-function meter (instrument_wasm_with_host_meter + charge_gas). It left out that per-block cost, so tight loops cost up to 11× less.
  • Every metered block was also a host call that took a mutex. A tight loop ran about 18× slower than under run.

With the default 1e10 budget, a CPU-bound contestant such as for(;;); hit the wall deadline (20 s, cost null) instead of instruction-limit. Interactive judging therefore could not enforce time limits on CPU-bound contestants. Found downstream in NOJV: an infinite-looping interactive contestant was only stopped by the wall limit, while the same program under Engine.run stopped at instruction-limit in under a second.

Change

Interactive programs now use exactly the same meter as standalone runs.

  • interactive.rs
    • Contestant and interactor are instrumented with instrument_wasm, with the budget in the global's initializer.
    • spawn_metered spawns each process like spawn_exec_wasm, plus a WASIX recycle hook. Once the process has exited (return, proc_exit, trap, or the meter's own budget trap), WASIX hands its store to the hook. The hook reads the gas counter captured at instance setup and sends it over a oneshot channel.
    • WASIX reports the exit before it recycles the store, so interact awaits the exit status and the meter reading together.
    • A negative counter means instruction-limit at the full budget, as in run.
  • meter.rs: the host meter, its metering-module identities and charge_gas are removed. MeterState holds the wasmer Global on both backends and converts to the JS global only when it is read, so the browser runtime reads the recycled store the same way.
  • Browser runtime: rebuilt src/runner/generated/runtime-core_bg.wasm (an LFS object) and runtime-core.js with the repo build script, and refreshed the runtime identity pins (as in fix(runtime): preserve redirected stdio and consume QuickJS stdin #78). If you would rather regenerate the wasm yourself, the Rust change is self-contained.
  • CHANGELOG.md: entry under Unreleased. This may conflict trivially with feat(runtime): allow Python interactors in interact #93, fix(server): keep a stdin error handler after cancelling a run #94 and fix(runtime): stop waiting for stream EOF when exporting runtime files #96.

Interactive costs rise to the standalone values, and the refreshed runtime identity changes cost profiles.

Results

Median of 5 runs through Engine with the native runner:

Program run interact before interact after
C++ for(;;);, default budget 0.68 s 12.42 s (first commit); 20 s wall before this PR 0.68 s, instruction-limit
C++ bounded loop, cost 1.76e10 0.90 s 5.70 s 0.93 s
guess-the-number, 20 rounds — 71 ms 64 ms

Verification

  • Rust test interactive_contestant_is_metered_like_a_standalone_run: the same module via run and interact reports equal cost, and both stop with instruction-limit. Before the fix: 4003 vs 14013.
  • Gated server tests (WASM_OJ_RUN_JUDGE_INTEGRATION=1), 4/4 pass:
    • A compiled C program reports the same cost through Engine.run and Engine.interact. Before the fix: 20,000,099 vs 25,000,159.
    • A CPU-bound C++ contestant stops at instruction-limit well before the wall deadline.
  • cargo test, clippy (native and web), runtime:check-web, pnpm run typecheck, pnpm run lint, pnpm test and pnpm run build all pass.

Not in this PR

Found while testing; each could be its own issue:

  • An interactive program that traps is reported as exited with code 45 rather than trap.
  • Running out of budget inside a start section fails with failed to start… instead of instruction-limit.
  • In Node, the browser runtime panics with condvar wait not supported at process exit.

🤖 Generated with Claude Code

Standalone runs inject radix's mutable-global meter, which adds the
weighted cost of its own gas function (10 points) to every metered block.
Interactive programs use the host-function backend, where radix leaves
that cost to the host, and the host charge added nothing. The same tight
loop therefore cost up to 11 times less under `interact` than under `run`,
and each charge took a mutex inside a ~30 ns host call. With the default
10e9 budget, a CPU-bound contestant ran into the wall deadline instead of
its instruction budget.

The host meter now charges the in-module gas function cost that radix
reports for the same rules, so interactive and standalone costs are equal
for identical code, and it keeps its counter in atomics instead of a mutex.
The browser runtime is rebuilt and the runtime identity pins refreshed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Interactive programs used radix's host-function meter, so every metered
block was a host call. Even with the per-call cost fixed in the previous
commit, a tight loop ran about 18 times slower under `interact` than under
`run`: C++ `for(;;);` with the default 1e10 budget needed 12.4 s instead
of 0.67 s, so a slow host could still hit the wall deadline first.

Interactive programs now get the same `instrument_wasm` mutable-global
meter as standalone runs, with the budget in the global's initializer.
Each process is spawned like `spawn_exec_wasm`, plus a WASIX recycle
hook: once the process has exited (return, `proc_exit`, trap or the
meter's own budget trap), WASIX hands its store to the hook, which reads
the gas counter captured at instance setup and sends it over a oneshot
channel. WASIX reports the exit before it recycles the store, so
`interact` awaits the exit status and the meter reading together. A
negative counter is `instruction-limit` at the full budget, as in `run`.
The host meter, its metering-module identities and `charge_gas` are
removed. `MeterState` holds the wasmer `Global` on both backends and
converts to the JS global only when read, so the browser runtime reads
the recycled store the same way.

Median of 5 runs through `Engine` with the native runner; costs are
unchanged:
- C++ `for(;;);`, default budget: run 0.68 s; interact 12.42 s -> 0.68 s
- C++ bounded loop, cost 1.76e10: run 0.90 s; interact 5.70 s -> 0.93 s
- guess-the-number, 20 rounds: 71 ms -> 64 ms (costs 88423 / 68766)

The browser runtime is rebuilt and the runtime identity pins refreshed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@TakalaWang TakalaWang changed the title fix(runtime): meter interactive programs exactly like standalone runs fix(runtime): meter interactive programs in-module like standalone runs Oct 6, 2026
@TakalaWang

Copy link
Copy Markdown
Contributor Author

For context: NOJV (a downstream user) will run checkers and interactors in the browser for its Test button. It needs #93, this PR and #99 (which fixes browser interact, #98) in one release. With #99, each browser side runs as a standalone metered run. Browser interactive costs and instruction-limit stops therefore no longer depend on this PR, but this PR is still what makes server interact costs and timing match the browser. Both PRs regenerate runtime-core_bg.wasm and the identity pins, so whichever lands second needs pnpm run runtime:build and a pin refresh; I'm happy to rebase #99 after this one.

@JacobLinCool
JacobLinCool merged commit f37478f into wasm-oj:main Oct 7, 2026
1 check passed
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.

2 participants