From 2c9591344e17245928a84620aad0234acd63d707 Mon Sep 17 00:00:00 2001 From: TakalaWang Date: Tue, 6 Oct 2026 20:19:50 +0800 Subject: [PATCH 1/2] fix(runtime): meter interactive programs exactly like standalone runs 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 --- CHANGELOG.md | 9 ++ crates/runtime-core/src/interactive.rs | 130 ++++++++++++++++------ crates/runtime-core/src/meter.rs | 24 +++- src/core/runtime-identity.ts | 6 +- src/runner/generated/runtime-core.js | 8 +- src/runner/generated/runtime-core_bg.wasm | 4 +- src/server/judge.integration.test.ts | 26 +++++ 7 files changed, 159 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d204d..b70b1c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to WASM-OJ are recorded here. Releases follow [Semantic Versioning](https://semver.org/) and the contract/package versioning policy in [the versioning policy](docs/versioning.md). +## Unreleased + +- Fixed interactive metering so contestants and interactors are charged exactly what a + standalone run charges for the same code. The host meter omitted the per-block cost of the + in-module gas function, so tight loops cost up to 11 times less and CPU-bound contestants ran + until the wall deadline instead of stopping at their instruction budget. Interactive charges + are also about twice as fast. Interactive costs rise to the standalone values, and the refreshed + runtime identity changes cost profiles. + ## 0.2.3 - 2026-10-05 - Download pinned browser toolchain assets before the compiler timeout starts, so slow diff --git a/crates/runtime-core/src/interactive.rs b/crates/runtime-core/src/interactive.rs index 2875b99..d269daa 100644 --- a/crates/runtime-core/src/interactive.rs +++ b/crates/runtime-core/src/interactive.rs @@ -20,6 +20,7 @@ use crate::{ use std::collections::{BTreeMap, HashMap}; use std::io; use std::pin::Pin; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::task::{Context, Poll}; use tokio::io::{AsyncRead, AsyncSeek, AsyncWrite, ReadBuf}; @@ -38,49 +39,43 @@ use wasmer_wasix::{ #[derive(Debug)] struct GasBudget { initial: u64, - state: Mutex, -} - -#[derive(Debug)] -struct GasState { - remaining: u64, - exhausted: bool, + call_cost: u64, + remaining: AtomicU64, + exhausted: AtomicBool, } impl GasBudget { - fn new(initial: u64) -> Self { + fn new(initial: u64, call_cost: u64) -> Self { Self { initial, - state: Mutex::new(GasState { - remaining: initial, - exhausted: false, - }), + call_cost, + remaining: AtomicU64::new(initial), + exhausted: AtomicBool::new(false), } } fn charge(&self, amount: u64) -> Result<(), RuntimeError> { - let mut state = self - .state - .lock() - .map_err(|error| RuntimeError::new(error.to_string()))?; - if amount > state.remaining { - state.remaining = 0; - state.exhausted = true; + let amount = amount.saturating_add(self.call_cost); + if self + .remaining + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |remaining| { + remaining.checked_sub(amount) + }) + .is_err() + { + self.remaining.store(0, Ordering::Relaxed); + self.exhausted.store(true, Ordering::Relaxed); return Err(RuntimeError::new("WASM-OJ instruction budget exhausted")); } - state.remaining -= amount; Ok(()) } - fn metrics(&self) -> Result<(u64, bool), RunError> { - let state = self - .state - .lock() - .map_err(|error| RunError::Runtime(error.to_string()))?; - Ok(( - self.initial.saturating_sub(state.remaining), - state.exhausted, - )) + fn metrics(&self) -> (u64, bool) { + ( + self.initial + .saturating_sub(self.remaining.load(Ordering::Relaxed)), + self.exhausted.load(Ordering::Relaxed), + ) } } @@ -383,7 +378,7 @@ fn prepare_program( ) -> Result { let limited = enforce_memory_limit(&program.wasm, program.resources.memory_limit_bytes) .map_err(RunError::Compile)?; - let metered = + let (metered, call_cost) = instrument_wasm_with_host_meter(&limited, metering_module).map_err(RunError::Compile)?; let executable = defer_start_section(&metered.wasm).map_err(RunError::Compile)?; let wasm = @@ -397,7 +392,10 @@ fn prepare_program( Ok(PreparedProgram { wasm, operations: metered.operations, - gas: Arc::new(GasBudget::new(program.resources.instruction_budget)), + gas: Arc::new(GasBudget::new( + program.resources.instruction_budget, + call_cost, + )), protocol, stderr, filesystem, @@ -448,7 +446,7 @@ fn process_result( ) -> Result { let stderr = prepared.stderr.bytes(); let output_exceeded = prepared.protocol.exceeded() || prepared.stderr.exceeded(); - let (cost, exhausted) = prepared.gas.metrics()?; + let (cost, exhausted) = prepared.gas.metrics(); let logical_time_exceeded = prepared.clock.limit_exceeded()?; let (code, termination) = if prepared.filesystem.quota_exceeded() { (137, ExecutionTermination::FilesystemLimit) @@ -673,7 +671,7 @@ mod tests { use super::interact; use crate::{ DeterminismConfig, ExecutionTermination, InteractiveProgram, InteractiveRequest, - ResourcePolicy, + ResourcePolicy, RunRequest, }; use std::collections::BTreeMap; @@ -998,6 +996,72 @@ mod tests { assert_eq!(result.interactor.metrics.logical_time_ns, 0); } + #[test] + fn interactive_contestant_is_metered_like_a_standalone_run() { + let looping = wat::parse_str( + r#"(module + (memory (export "memory") 1) + (func (export "_start") + (local $remaining i32) + i32.const 1000 local.set $remaining + (loop $again + local.get $remaining i32.const 1 i32.sub local.tee $remaining + br_if $again)))"#, + ) + .unwrap(); + let idle = + wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "_start")))"#) + .unwrap(); + let determinism = DeterminismConfig { + random_seed: 7, + realtime_epoch_ms: 946_684_800_000, + clock_step_ns: 1_000_000, + }; + let execute = |instruction_budget: u64| { + let mut contestant = program(looping.clone()); + contestant.resources.instruction_budget = instruction_budget; + let standalone = crate::run(RunRequest { + wasm: contestant.wasm.clone(), + args: Vec::new(), + env: BTreeMap::new(), + stdin: Vec::new(), + files: BTreeMap::new(), + output_paths: Vec::new(), + cwd: contestant.cwd.clone(), + startup_entropy_bytes: 0, + determinism: determinism.clone(), + resources: contestant.resources.clone(), + }) + .unwrap(); + let interactive = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(interact(InteractiveRequest { + contestant, + interactor: program(idle.clone()), + determinism: determinism.clone(), + })) + .unwrap(); + (standalone, interactive.contestant) + }; + + let (standalone, interactive) = execute(1_000_000); + assert_eq!(standalone.termination, ExecutionTermination::Exited); + assert_eq!(interactive.termination, ExecutionTermination::Exited); + assert_eq!(interactive.metrics.cost, standalone.metrics.cost); + + let (standalone, interactive) = execute(standalone.metrics.cost - 1); + assert_eq!( + standalone.termination, + ExecutionTermination::InstructionLimit + ); + assert_eq!( + interactive.termination, + ExecutionTermination::InstructionLimit + ); + } + fn program(wasm: Vec) -> InteractiveProgram { InteractiveProgram { wasm, diff --git a/crates/runtime-core/src/meter.rs b/crates/runtime-core/src/meter.rs index 048fedd..d024990 100644 --- a/crates/runtime-core/src/meter.rs +++ b/crates/runtime-core/src/meter.rs @@ -1,7 +1,7 @@ #[cfg(target_arch = "wasm32")] use js_sys::{BigInt, WebAssembly}; use meter_wasmparser::Operator; -use radix_wasm_instrument::gas_metering::{self, MemoryGrowCost, Rules}; +use radix_wasm_instrument::gas_metering::{self, Backend, GasMeter, MemoryGrowCost, Rules}; use radix_wasm_instrument::utils::module_info::ModuleInfo; use std::borrow::Cow; use std::collections::BTreeMap; @@ -85,15 +85,24 @@ pub fn instrument_wasm(wasm: &[u8], budget: u64) -> Result Result { +) -> Result<(InstrumentedModule, u64), String> { let runtime_sections = runtime_custom_sections(wasm)?; let executable = canonicalize_custom_sections(wasm)?; let operations = inspect_weighted_opcodes(&executable)?; let mut module = ModuleInfo::new(&executable) .map_err(|error| format!("failed to parse module for weighted metering: {error}"))?; + let GasMeter::Internal { + cost: call_cost, .. + } = gas_metering::mutable_global::Injector::new(METERING_MODULE, GAS_COUNTER_NAME) + .gas_meter(&mut module, &WeightedRules) + else { + return Err("weighted metering has no in-module gas function cost".to_string()); + }; let backend = gas_metering::host_function::Injector::new(metering_module, HOST_GAS_FUNCTION); let mut metered = gas_metering::inject(&mut module, backend, &WeightedRules) .map_err(|error| format!("failed to inject weighted host metering: {error}"))?; @@ -105,10 +114,13 @@ pub(crate) fn instrument_wasm_with_host_meter( metered.push(section.id()); section.encode(&mut metered); } - Ok(InstrumentedModule { - wasm: metered, - operations, - }) + Ok(( + InstrumentedModule { + wasm: metered, + operations, + }, + call_cost, + )) } #[derive(Debug)] diff --git a/src/core/runtime-identity.ts b/src/core/runtime-identity.ts index 390b632..ec4ad45 100644 --- a/src/core/runtime-identity.ts +++ b/src/core/runtime-identity.ts @@ -3,8 +3,8 @@ import { sha256Hex } from "./sha256.ts"; /** Executable runtime components covered by deterministic cost calibration. */ export const WASM_OJ_RUNTIME_COMPONENTS = Object.freeze({ - runtimeCoreWasmSha256: "1e297ad276694e9fb8a23afdc6ccc0b2ae56a223ced19d5cae26fe4661daa11e", - runtimeSourceRootSha256: "27c52cb1887aa8a7e0d8a238514fc8881cf86c68267e2a1691f110423692e2b6", + runtimeCoreWasmSha256: "323189cea65215555af54b0d696ad18bf7b265f7dd357de15dee9e71f5d1e88c", + runtimeSourceRootSha256: "a4bacdaa371de8277e777f98c2fd99f86c8068cbed301ca1ed12678925fe1f47", wasmerNativeVersion: "7.2.1", wasmerSdkVersion: "0.10.0", wasmerSdkWasmSha256: "49a6646209f5ab5e7c737eac33407d87d9a9959ac83e5ecaaab9261b2323589e", @@ -17,7 +17,7 @@ export const WASM_OJ_RUNTIME_COMPONENTS = Object.freeze({ * identity is admitted into a calibrated release. */ export const WASM_OJ_RUNTIME_IDENTITY_SHA256 = - "d3e44cafdc1c17a7e6bd4591ef04ec8161af805a519b5bbff437b15e5accd442"; + "e12c2290e2ec27f3fc6b2fa664b4ec691a5cdf0e0563763da223f0e0468db9b3"; /** Exact canonical serialization hashed by `WASM_OJ_RUNTIME_IDENTITY_SHA256`. */ export function runtimeIdentityBytes(): Uint8Array { diff --git a/src/runner/generated/runtime-core.js b/src/runner/generated/runtime-core.js index b7899d9..18cba72 100644 --- a/src/runner/generated/runtime-core.js +++ b/src/runner/generated/runtime-core.js @@ -753,22 +753,22 @@ function __wbg_get_imports() { return ret; }, __wbindgen_cast_0000000000000001: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 6988, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 6979, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen_68458880a41dd4bb___convert__closures_____invoke___wasm_bindgen_68458880a41dd4bb___JsValue__core_9b3796e30d99ddb7___result__Result_____wasm_bindgen_68458880a41dd4bb___JsError___true_); return ret; }, __wbindgen_cast_0000000000000002: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Ref(NamedExternref("Array"))], shim_idx: 2456, ret: Result(Externref), inner_ret: Some(Result(Externref)) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Ref(NamedExternref("Array"))], shim_idx: 2454, ret: Result(Externref), inner_ret: Some(Result(Externref)) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen_68458880a41dd4bb___convert__closures________invoke___js_sys_c11fba41208799d1___Array__core_9b3796e30d99ddb7___result__Result_js_sys_c11fba41208799d1___Array__wasm_bindgen_68458880a41dd4bb___JsValue___true_); return ret; }, __wbindgen_cast_0000000000000003: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Ref(NamedExternref("Array"))], shim_idx: 2456, ret: Result(NamedExternref("Array")), inner_ret: Some(Result(NamedExternref("Array"))) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Ref(NamedExternref("Array"))], shim_idx: 2454, ret: Result(NamedExternref("Array")), inner_ret: Some(Result(NamedExternref("Array"))) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen_68458880a41dd4bb___convert__closures________invoke___js_sys_c11fba41208799d1___Array__core_9b3796e30d99ddb7___result__Result_js_sys_c11fba41208799d1___Array__wasm_bindgen_68458880a41dd4bb___JsValue___true__2); return ret; }, __wbindgen_cast_0000000000000004: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Ref(NamedExternref("Array"))], shim_idx: 2457, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Ref(NamedExternref("Array"))], shim_idx: 2455, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen_68458880a41dd4bb___convert__closures________invoke___js_sys_c11fba41208799d1___Array__core_9b3796e30d99ddb7___result__Result_____wasm_bindgen_68458880a41dd4bb___JsValue___true_); return ret; }, diff --git a/src/runner/generated/runtime-core_bg.wasm b/src/runner/generated/runtime-core_bg.wasm index 1788821..0ee3823 100644 --- a/src/runner/generated/runtime-core_bg.wasm +++ b/src/runner/generated/runtime-core_bg.wasm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1e297ad276694e9fb8a23afdc6ccc0b2ae56a223ced19d5cae26fe4661daa11e -size 14862072 +oid sha256:323189cea65215555af54b0d696ad18bf7b265f7dd357de15dee9e71f5d1e88c +size 14861164 diff --git a/src/server/judge.integration.test.ts b/src/server/judge.integration.test.ts index 5b7c69e..e1c2847 100644 --- a/src/server/judge.integration.test.ts +++ b/src/server/judge.integration.test.ts @@ -130,6 +130,32 @@ describe.skipIf(!enabled)("real server judge contracts", () => { }); }); + it("meters an interactive contestant exactly like a standalone run", { timeout: 300_000 }, async () => { + const contestant = await compileC("spinner", [ + "int main(void) {", + " volatile unsigned long long sink = 0;", + " for (unsigned long long i = 0; i < 3000000ULL; ++i) sink = sink + i;", + " return (int)(sink & 1);", + "}", + ].join("\n")); + const interactor = await compileC("silent-interactor", "int main(void) { return 0; }"); + const execute = async (instructionBudget: number) => { + const resources = { instructionBudget, wallTimeLimitMs: 60_000 }; + const standalone = await engine.run(contestant, { resources }); + const interactive = await engine.interact(contestant, interactor, { contestant: { resources } }); + return { standalone, interactive: interactive.contestant }; + }; + + const unlimited = await execute(1_000_000_000); + expect(unlimited.standalone).toMatchObject({ code: 0, termination: "exited" }); + expect(unlimited.interactive).toMatchObject({ code: 0, termination: "exited" }); + expect(unlimited.interactive.metrics.cost).toBe(unlimited.standalone.metrics.cost); + + const limited = await execute(unlimited.standalone.metrics.cost! - 1); + expect(limited.standalone.termination).toBe("instruction-limit"); + expect(limited.interactive.termination).toBe("instruction-limit"); + }); + async function compileC(name: string, source: string): Promise { const entry = `src/${name}.c`; const input: CompileInput = { From b99b5882dd9ee8b07342e9fda2fcb4afa17bf463 Mon Sep 17 00:00:00 2001 From: TakalaWang Date: Tue, 6 Oct 2026 22:23:41 +0800 Subject: [PATCH 2/2] perf(runtime): meter interactive programs in-module like standalone runs 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 --- CHANGELOG.md | 13 +- crates/runtime-core/src/interactive.rs | 274 +++++++++++----------- crates/runtime-core/src/meter.rs | 77 +----- crates/runtime-core/src/run/native.rs | 2 +- crates/runtime-core/src/run/web.rs | 2 +- src/core/runtime-identity.ts | 6 +- src/runner/generated/runtime-core.js | 8 +- src/runner/generated/runtime-core_bg.wasm | 4 +- src/server/judge.integration.test.ts | 31 ++- 9 files changed, 189 insertions(+), 228 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b70b1c8..e5dbc2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,13 @@ All notable changes to WASM-OJ are recorded here. Releases follow ## Unreleased -- Fixed interactive metering so contestants and interactors are charged exactly what a - standalone run charges for the same code. The host meter omitted the per-block cost of the - in-module gas function, so tight loops cost up to 11 times less and CPU-bound contestants ran - until the wall deadline instead of stopping at their instruction budget. Interactive charges - are also about twice as fast. Interactive costs rise to the standalone values, and the refreshed - runtime identity changes cost profiles. +- Interactive contestants and interactors now use the same in-module instruction meter as + standalone runs, and the runtime reads each program's counter when it exits. They are charged + exactly what a standalone run charges for the same code; the previous host meter left out the + meter's own per-block cost, so tight loops cost up to 11 times less. They also run as fast as + standalone runs; a tight loop used to run about 18 times slower under `interact`, so CPU-bound + contestants hit the wall deadline before their instruction budget. Interactive costs rise to the + standalone values, and the refreshed runtime identity changes cost profiles. ## 0.2.3 - 2026-10-05 diff --git a/crates/runtime-core/src/interactive.rs b/crates/runtime-core/src/interactive.rs index d269daa..252b8ac 100644 --- a/crates/runtime-core/src/interactive.rs +++ b/crates/runtime-core/src/interactive.rs @@ -3,10 +3,7 @@ use crate::deterministic::{VirtualClock, attach_interactive_deterministic_import use crate::filesystem::{ RuntimeProjectFilesystem, is_normalized_guest_path, runtime_project_files, }; -use crate::meter::{ - CONTESTANT_METERING_MODULE, HOST_GAS_FUNCTION, INTERACTOR_METERING_MODULE, - instrument_wasm_with_host_meter, -}; +use crate::meter::{CostPoints, MeterState, instrument_wasm, meter_state, remaining_points}; use crate::module_imports::attach_declared_memory_imports; use crate::module_policy::{ DEFERRED_START_EXPORT, defer_start_section, enforce_memory_limit, @@ -17,77 +14,25 @@ use crate::{ ExecutionTermination, InteractiveMetrics, InteractiveProcessResult, InteractiveProgram, InteractiveRequest, InteractiveResult, RunError, }; +use futures::channel::oneshot; use std::collections::{BTreeMap, HashMap}; use std::io; use std::pin::Pin; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::task::{Context, Poll}; use tokio::io::{AsyncRead, AsyncSeek, AsyncWrite, ReadBuf}; use virtual_fs::{FsError, Pipe, PipeRx, PipeTx, VirtualFile}; -use wasmer::{ - AsStoreMut, Engine, Function, FunctionEnv, FunctionEnvMut, Imports, Memory, RuntimeError, -}; +use wasmer::{AsStoreMut, Engine, Imports, Memory}; use wasmer_types::StoreId; -use wasmer_wasix::bin_factory::spawn_exec_wasm; +use wasmer_wasix::bin_factory::{run_exec, spawn_load_module}; +use wasmer_wasix::os::task::TaskJoinHandle; use wasmer_wasix::runtime::module_cache::{self, HashedModuleData, ModuleCache}; +use wasmer_wasix::runtime::task_manager::TaskWasm; use wasmer_wasix::{ PluggableRuntime, Runtime, WasiEnv, WasiFunctionEnv, WasiRuntimeError, WasiVersion, generate_import_object_from_env, }; -#[derive(Debug)] -struct GasBudget { - initial: u64, - call_cost: u64, - remaining: AtomicU64, - exhausted: AtomicBool, -} - -impl GasBudget { - fn new(initial: u64, call_cost: u64) -> Self { - Self { - initial, - call_cost, - remaining: AtomicU64::new(initial), - exhausted: AtomicBool::new(false), - } - } - - fn charge(&self, amount: u64) -> Result<(), RuntimeError> { - let amount = amount.saturating_add(self.call_cost); - if self - .remaining - .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |remaining| { - remaining.checked_sub(amount) - }) - .is_err() - { - self.remaining.store(0, Ordering::Relaxed); - self.exhausted.store(true, Ordering::Relaxed); - return Err(RuntimeError::new("WASM-OJ instruction budget exhausted")); - } - Ok(()) - } - - fn metrics(&self) -> (u64, bool) { - ( - self.initial - .saturating_sub(self.remaining.load(Ordering::Relaxed)), - self.exhausted.load(Ordering::Relaxed), - ) - } -} - -#[derive(Clone)] -struct GasEnv(Arc); - -fn charge_gas(env: FunctionEnvMut, amount: i64) -> Result<(), RuntimeError> { - let amount = u64::try_from(amount) - .map_err(|_| RuntimeError::new("WASM-OJ received a negative instruction charge"))?; - env.data().0.charge(amount) -} - #[derive(Debug)] struct InteractiveInput { pipe: PipeRx, @@ -258,7 +203,8 @@ impl VirtualFile for InteractiveOutput { struct PreparedProgram { wasm: Vec, operations: BTreeMap, - gas: Arc, + budget: u64, + meter: Arc>>, protocol: OutputCapture, stderr: OutputCapture, filesystem: RuntimeProjectFilesystem, @@ -267,16 +213,8 @@ struct PreparedProgram { pub async fn interact(request: InteractiveRequest) -> Result { validate_request(&request)?; - let contestant = prepare_program( - &request.contestant, - CONTESTANT_METERING_MODULE, - &request.determinism, - )?; - let interactor = prepare_program( - &request.interactor, - INTERACTOR_METERING_MODULE, - &request.determinism, - )?; + let contestant = prepare_program(&request.contestant, &request.determinism)?; + let interactor = prepare_program(&request.interactor, &request.determinism)?; let maximum_memory = request .contestant .resources @@ -288,7 +226,7 @@ pub async fn interact(request: InteractiveRequest) -> Result Result Result Result, +) -> Result< + ( + TaskJoinHandle, + oneshot::Receiver>, + ), + RunError, +> { + let module = spawn_load_module(name, HashedModuleData::new(program.wasm.clone()), runtime) + .await + .map_err(|error| RunError::Compile(format!("failed to start {name}: {error}")))?; + let finished = env.thread.join_handle(); + let meter = program.meter.clone(); + let (points, received) = oneshot::channel(); + let task = TaskWasm::new(Box::new(run_exec), env, module, true, true).with_recycle(Box::new( + move |mut exited| { + let read = meter + .lock() + .map_err(|error| error.to_string()) + .and_then(|meter| { + meter + .clone() + .ok_or_else(|| "meter is unavailable".to_string()) + }) + .and_then(|meter| remaining_points(&mut exited.store, &meter)); + let _ = points.send(read); + }, + )); + runtime + .task_manager() + .task_wasm(task) + .map_err(|error| RunError::Compile(format!("failed to start {name}: {error}")))?; + Ok((finished, received)) +} + fn prepare_program( program: &InteractiveProgram, - metering_module: &'static str, determinism: &crate::DeterminismConfig, ) -> Result { let limited = enforce_memory_limit(&program.wasm, program.resources.memory_limit_bytes) .map_err(RunError::Compile)?; - let (metered, call_cost) = - instrument_wasm_with_host_meter(&limited, metering_module).map_err(RunError::Compile)?; + let metered = instrument_wasm(&limited, program.resources.instruction_budget) + .map_err(RunError::Compile)?; let executable = defer_start_section(&metered.wasm).map_err(RunError::Compile)?; let wasm = rewrite_interactive_deterministic_imports(&executable.wasm).map_err(RunError::Compile)?; @@ -392,10 +373,8 @@ fn prepare_program( Ok(PreparedProgram { wasm, operations: metered.operations, - gas: Arc::new(GasBudget::new( - program.resources.instruction_budget, - call_cost, - )), + budget: program.resources.instruction_budget, + meter: Arc::new(Mutex::new(None)), protocol, stderr, filesystem, @@ -441,12 +420,20 @@ fn build_environment( fn process_result( status: Result>, + points: Result, oneshot::Canceled>, prepared: PreparedProgram, protocol_bytes: usize, ) -> Result { let stderr = prepared.stderr.bytes(); let output_exceeded = prepared.protocol.exceeded() || prepared.stderr.exceeded(); - let (cost, exhausted) = prepared.gas.metrics(); + let points = points + .map_err(|_| RunError::Runtime("interactive process exited without its meter".to_string()))? + .map_err(RunError::Runtime)?; + let exhausted = points == CostPoints::Exhausted; + let cost = match points { + CostPoints::Remaining(points) => prepared.budget.saturating_sub(points), + CostPoints::Exhausted => prepared.budget, + }; let logical_time_exceeded = prepared.clock.limit_exceeded()?; let (code, termination) = if prepared.filesystem.quota_exceeded() { (137, ExecutionTermination::FilesystemLimit) @@ -514,7 +501,7 @@ fn validate_request(request: &InteractiveRequest) -> Result<(), RunError> { fn interactive_runtime( engine: Engine, determinism: &crate::DeterminismConfig, - gas: Arc, + meter: Arc>>, clock: VirtualClock, startup_entropy_bytes: u64, module_cache: Arc, @@ -576,22 +563,6 @@ fn interactive_runtime( clock.clone(), startup_entropy_bytes, ); - let metering_module = module - .imports() - .find_map(|import| match import.module() { - CONTESTANT_METERING_MODULE => Some(CONTESTANT_METERING_MODULE), - INTERACTOR_METERING_MODULE => Some(INTERACTOR_METERING_MODULE), - _ => None, - }) - .ok_or_else(|| { - io::Error::other("interactive module has no WASM-OJ metering identity") - })?; - let gas_env = FunctionEnv::new(&mut *store, GasEnv(gas.clone())); - imports.define( - metering_module, - HOST_GAS_FUNCTION, - Function::new_typed_with_env(&mut *store, &gas_env, charge_gas), - ); attach_capability_denials(store, module, &mut imports).map_err(io::Error::other)?; attach_declared_memory_imports(store, module, &mut imports).map_err(io::Error::other)?; Ok(imports) @@ -614,6 +585,10 @@ fn interactive_runtime( .memory .lock() .map_err(|error| io::Error::other(error.to_string()))? = Some(memory); + *meter + .lock() + .map_err(|error| io::Error::other(error.to_string()))? = + Some(meter_state(instance).map_err(io::Error::other)?); pending .wasi .initialize(&mut *store, instance.clone()) @@ -998,27 +973,28 @@ mod tests { #[test] fn interactive_contestant_is_metered_like_a_standalone_run() { - let looping = wat::parse_str( - r#"(module - (memory (export "memory") 1) - (func (export "_start") - (local $remaining i32) - i32.const 1000 local.set $remaining - (loop $again - local.get $remaining i32.const 1 i32.sub local.tee $remaining - br_if $again)))"#, - ) - .unwrap(); - let idle = - wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "_start")))"#) - .unwrap(); + let looping = |ending: &str| { + wat::parse_str(format!( + r#"(module + (import "wasi_snapshot_preview1" "proc_exit" (func $exit (param i32))) + (memory (export "memory") 1) + (func (export "_start") + (local $remaining i32) + i32.const 1000 local.set $remaining + (loop $again + local.get $remaining i32.const 1 i32.sub local.tee $remaining + br_if $again) + {ending}))"# + )) + .unwrap() + }; let determinism = DeterminismConfig { random_seed: 7, realtime_epoch_ms: 946_684_800_000, clock_step_ns: 1_000_000, }; - let execute = |instruction_budget: u64| { - let mut contestant = program(looping.clone()); + let execute = |wasm: &[u8], instruction_budget: u64| { + let mut contestant = program(wasm.to_vec()); contestant.resources.instruction_budget = instruction_budget; let standalone = crate::run(RunRequest { wasm: contestant.wasm.clone(), @@ -1038,28 +1014,50 @@ mod tests { .build() .unwrap() .block_on(interact(InteractiveRequest { + interactor: contestant.clone(), contestant, - interactor: program(idle.clone()), determinism: determinism.clone(), })) .unwrap(); + assert_eq!(interactive.interactor.code, interactive.contestant.code); + assert_eq!( + interactive.interactor.termination, + interactive.contestant.termination + ); + assert_eq!( + interactive.interactor.metrics.cost, + interactive.contestant.metrics.cost + ); (standalone, interactive.contestant) }; - let (standalone, interactive) = execute(1_000_000); - assert_eq!(standalone.termination, ExecutionTermination::Exited); - assert_eq!(interactive.termination, ExecutionTermination::Exited); - assert_eq!(interactive.metrics.cost, standalone.metrics.cost); + for (ending, code) in [("", 0), ("i32.const 3 call $exit", 3)] { + let wasm = looping(ending); + let (standalone, interactive) = execute(&wasm, 1_000_000); + assert_eq!(standalone.termination, ExecutionTermination::Exited); + assert_eq!(standalone.code, code); + assert_eq!(interactive.termination, ExecutionTermination::Exited); + assert_eq!(interactive.code, code); + assert_eq!(interactive.metrics.cost, standalone.metrics.cost); - let (standalone, interactive) = execute(standalone.metrics.cost - 1); - assert_eq!( - standalone.termination, - ExecutionTermination::InstructionLimit - ); - assert_eq!( - interactive.termination, - ExecutionTermination::InstructionLimit - ); + let budget = standalone.metrics.cost - 1; + let (standalone, interactive) = execute(&wasm, budget); + assert_eq!( + standalone.termination, + ExecutionTermination::InstructionLimit + ); + assert_eq!( + interactive.termination, + ExecutionTermination::InstructionLimit + ); + assert_eq!(interactive.code, 137); + assert_eq!(interactive.metrics.cost, budget); + assert_eq!(interactive.metrics.cost, standalone.metrics.cost); + } + + let (standalone, interactive) = execute(&looping("unreachable"), 1_000_000); + assert_eq!(standalone.termination, ExecutionTermination::Trap); + assert_eq!(interactive.metrics.cost, standalone.metrics.cost); } fn program(wasm: Vec) -> InteractiveProgram { diff --git a/crates/runtime-core/src/meter.rs b/crates/runtime-core/src/meter.rs index d024990..6ca57a7 100644 --- a/crates/runtime-core/src/meter.rs +++ b/crates/runtime-core/src/meter.rs @@ -1,24 +1,19 @@ #[cfg(target_arch = "wasm32")] use js_sys::{BigInt, WebAssembly}; use meter_wasmparser::Operator; -use radix_wasm_instrument::gas_metering::{self, Backend, GasMeter, MemoryGrowCost, Rules}; +use radix_wasm_instrument::gas_metering::{self, MemoryGrowCost, Rules}; use radix_wasm_instrument::utils::module_info::ModuleInfo; use std::borrow::Cow; use std::collections::BTreeMap; use wasm_encoder::reencode::{Error as ReencodeError, Reencode}; use wasm_encoder::{Encode, Section}; -#[cfg(not(target_arch = "wasm32"))] -use wasmer::Global; #[cfg(target_arch = "wasm32")] use wasmer::js::AsJs; -use wasmer::{AsStoreMut, Instance}; +use wasmer::{AsStoreMut, Global, Instance}; pub const METER_MODEL: &str = "weighted"; const METERING_MODULE: &str = "wasm_oj_metering"; const GAS_COUNTER_NAME: &str = "gas_counter"; -pub(crate) const CONTESTANT_METERING_MODULE: &str = "wasm_oj_contestant_metering"; -pub(crate) const INTERACTOR_METERING_MODULE: &str = "wasm_oj_interactor_metering"; -pub(crate) const HOST_GAS_FUNCTION: &str = "charge"; #[derive(Debug)] pub struct InstrumentedModule { @@ -53,10 +48,7 @@ pub enum CostPoints { #[derive(Clone, Debug)] pub struct MeterState { - #[cfg(not(target_arch = "wasm32"))] gas_counter: Global, - #[cfg(target_arch = "wasm32")] - gas_counter: WebAssembly::Global, } pub fn instrument_wasm(wasm: &[u8], budget: u64) -> Result { @@ -85,44 +77,6 @@ pub fn instrument_wasm(wasm: &[u8], budget: u64) -> Result Result<(InstrumentedModule, u64), String> { - let runtime_sections = runtime_custom_sections(wasm)?; - let executable = canonicalize_custom_sections(wasm)?; - let operations = inspect_weighted_opcodes(&executable)?; - let mut module = ModuleInfo::new(&executable) - .map_err(|error| format!("failed to parse module for weighted metering: {error}"))?; - let GasMeter::Internal { - cost: call_cost, .. - } = gas_metering::mutable_global::Injector::new(METERING_MODULE, GAS_COUNTER_NAME) - .gas_meter(&mut module, &WeightedRules) - else { - return Err("weighted metering has no in-module gas function cost".to_string()); - }; - let backend = gas_metering::host_function::Injector::new(metering_module, HOST_GAS_FUNCTION); - let mut metered = gas_metering::inject(&mut module, backend, &WeightedRules) - .map_err(|error| format!("failed to inject weighted host metering: {error}"))?; - for (name, data) in runtime_sections { - let section = wasm_encoder::CustomSection { - name: Cow::Owned(name), - data: Cow::Owned(data), - }; - metered.push(section.id()); - section.encode(&mut metered); - } - Ok(( - InstrumentedModule { - wasm: metered, - operations, - }, - call_cost, - )) -} - #[derive(Debug)] struct MeterInitializationError(String); @@ -283,26 +237,13 @@ fn inspect_weighted_opcodes(wasm: &[u8]) -> Result, String Ok(operations) } -pub fn meter_state(store: &mut impl AsStoreMut, instance: &Instance) -> Result { - let gas_counter = instance +pub fn meter_state(instance: &Instance) -> Result { + instance .exports .get_global(GAS_COUNTER_NAME) - .map_err(|error| format!("instrumented module does not export its meter: {error}"))? - .clone(); - - #[cfg(not(target_arch = "wasm32"))] - { - let _ = store; - Ok(MeterState { gas_counter }) - } - - #[cfg(target_arch = "wasm32")] - { - let js_global: WebAssembly::Global = gas_counter.as_jsvalue(store).into(); - Ok(MeterState { - gas_counter: js_global, - }) - } + .cloned() + .map(|gas_counter| MeterState { gas_counter }) + .map_err(|error| format!("instrumented module does not export its meter: {error}")) } pub fn remaining_points( @@ -318,8 +259,8 @@ pub fn remaining_points( #[cfg(target_arch = "wasm32")] let value = { - let _ = store; - i64::try_from(BigInt::from(meter.gas_counter.value())) + let gas_counter: WebAssembly::Global = meter.gas_counter.as_jsvalue(store).into(); + i64::try_from(BigInt::from(gas_counter.value())) .map_err(|_| "metering global is outside the signed 64-bit range".to_string())? }; diff --git a/crates/runtime-core/src/run/native.rs b/crates/runtime-core/src/run/native.rs index 8d9fd1d..2267ae5 100644 --- a/crates/runtime-core/src/run/native.rs +++ b/crates/runtime-core/src/run/native.rs @@ -111,7 +111,7 @@ fn run_in_runtime(request: RunRequest) -> Result { let instance = Instance::new(&mut store, &module, &imports) .map_err(|error| RunError::Compile(format!("failed to instantiate module: {error}")))?; - let meter = meter_state(&mut store, &instance).map_err(RunError::Runtime)?; + let meter = meter_state(&instance).map_err(RunError::Runtime)?; let guest_memory = instance .exports .get_memory("memory") diff --git a/crates/runtime-core/src/run/web.rs b/crates/runtime-core/src/run/web.rs index fbf8767..a7d6d95 100644 --- a/crates/runtime-core/src/run/web.rs +++ b/crates/runtime-core/src/run/web.rs @@ -87,7 +87,7 @@ pub fn run( attach_imported_memory(&mut store, &module, &mut imports).map_err(RunError::Compile)?; let instance = Instance::new(&mut store, &module, &imports) .map_err(|error| RunError::Compile(format!("failed to instantiate module: {error}")))?; - let meter = meter_state(&mut store, &instance).map_err(RunError::Runtime)?; + let meter = meter_state(&instance).map_err(RunError::Runtime)?; let guest_memory = instance .exports .get_memory("memory") diff --git a/src/core/runtime-identity.ts b/src/core/runtime-identity.ts index ec4ad45..85ef21a 100644 --- a/src/core/runtime-identity.ts +++ b/src/core/runtime-identity.ts @@ -3,8 +3,8 @@ import { sha256Hex } from "./sha256.ts"; /** Executable runtime components covered by deterministic cost calibration. */ export const WASM_OJ_RUNTIME_COMPONENTS = Object.freeze({ - runtimeCoreWasmSha256: "323189cea65215555af54b0d696ad18bf7b265f7dd357de15dee9e71f5d1e88c", - runtimeSourceRootSha256: "a4bacdaa371de8277e777f98c2fd99f86c8068cbed301ca1ed12678925fe1f47", + runtimeCoreWasmSha256: "522924286709c7661f9c23155f3269b7679d50573c62a5ea19d4500c7b37bbef", + runtimeSourceRootSha256: "0493153cecc1ca16349a8bb5ee1a84a1eb3a5aebf34b1e290630cf646a39a3bc", wasmerNativeVersion: "7.2.1", wasmerSdkVersion: "0.10.0", wasmerSdkWasmSha256: "49a6646209f5ab5e7c737eac33407d87d9a9959ac83e5ecaaab9261b2323589e", @@ -17,7 +17,7 @@ export const WASM_OJ_RUNTIME_COMPONENTS = Object.freeze({ * identity is admitted into a calibrated release. */ export const WASM_OJ_RUNTIME_IDENTITY_SHA256 = - "e12c2290e2ec27f3fc6b2fa664b4ec691a5cdf0e0563763da223f0e0468db9b3"; + "98e10a30ed5f406536c70464a7300bab96c0f13b43fe15f0f2d11894ec051299"; /** Exact canonical serialization hashed by `WASM_OJ_RUNTIME_IDENTITY_SHA256`. */ export function runtimeIdentityBytes(): Uint8Array { diff --git a/src/runner/generated/runtime-core.js b/src/runner/generated/runtime-core.js index 18cba72..773a163 100644 --- a/src/runner/generated/runtime-core.js +++ b/src/runner/generated/runtime-core.js @@ -753,22 +753,22 @@ function __wbg_get_imports() { return ret; }, __wbindgen_cast_0000000000000001: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 6979, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 6970, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen_68458880a41dd4bb___convert__closures_____invoke___wasm_bindgen_68458880a41dd4bb___JsValue__core_9b3796e30d99ddb7___result__Result_____wasm_bindgen_68458880a41dd4bb___JsError___true_); return ret; }, __wbindgen_cast_0000000000000002: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Ref(NamedExternref("Array"))], shim_idx: 2454, ret: Result(Externref), inner_ret: Some(Result(Externref)) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Ref(NamedExternref("Array"))], shim_idx: 2445, ret: Result(Externref), inner_ret: Some(Result(Externref)) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen_68458880a41dd4bb___convert__closures________invoke___js_sys_c11fba41208799d1___Array__core_9b3796e30d99ddb7___result__Result_js_sys_c11fba41208799d1___Array__wasm_bindgen_68458880a41dd4bb___JsValue___true_); return ret; }, __wbindgen_cast_0000000000000003: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Ref(NamedExternref("Array"))], shim_idx: 2454, ret: Result(NamedExternref("Array")), inner_ret: Some(Result(NamedExternref("Array"))) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Ref(NamedExternref("Array"))], shim_idx: 2445, ret: Result(NamedExternref("Array")), inner_ret: Some(Result(NamedExternref("Array"))) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen_68458880a41dd4bb___convert__closures________invoke___js_sys_c11fba41208799d1___Array__core_9b3796e30d99ddb7___result__Result_js_sys_c11fba41208799d1___Array__wasm_bindgen_68458880a41dd4bb___JsValue___true__2); return ret; }, __wbindgen_cast_0000000000000004: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Ref(NamedExternref("Array"))], shim_idx: 2455, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Ref(NamedExternref("Array"))], shim_idx: 2446, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen_68458880a41dd4bb___convert__closures________invoke___js_sys_c11fba41208799d1___Array__core_9b3796e30d99ddb7___result__Result_____wasm_bindgen_68458880a41dd4bb___JsValue___true_); return ret; }, diff --git a/src/runner/generated/runtime-core_bg.wasm b/src/runner/generated/runtime-core_bg.wasm index 0ee3823..dd82d58 100644 --- a/src/runner/generated/runtime-core_bg.wasm +++ b/src/runner/generated/runtime-core_bg.wasm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:323189cea65215555af54b0d696ad18bf7b265f7dd357de15dee9e71f5d1e88c -size 14861164 +oid sha256:522924286709c7661f9c23155f3269b7679d50573c62a5ea19d4500c7b37bbef +size 14907700 diff --git a/src/server/judge.integration.test.ts b/src/server/judge.integration.test.ts index e1c2847..1e3cb00 100644 --- a/src/server/judge.integration.test.ts +++ b/src/server/judge.integration.test.ts @@ -138,11 +138,18 @@ describe.skipIf(!enabled)("real server judge contracts", () => { " return (int)(sink & 1);", "}", ].join("\n")); - const interactor = await compileC("silent-interactor", "int main(void) { return 0; }"); const execute = async (instructionBudget: number) => { const resources = { instructionBudget, wallTimeLimitMs: 60_000 }; const standalone = await engine.run(contestant, { resources }); - const interactive = await engine.interact(contestant, interactor, { contestant: { resources } }); + const interactive = await engine.interact(contestant, contestant, { + contestant: { resources }, + interactor: { resources }, + }); + expect(interactive.interactor).toMatchObject({ + code: interactive.contestant.code, + termination: interactive.contestant.termination, + metrics: { cost: interactive.contestant.metrics.cost }, + }); return { standalone, interactive: interactive.contestant }; }; @@ -151,9 +158,23 @@ describe.skipIf(!enabled)("real server judge contracts", () => { expect(unlimited.interactive).toMatchObject({ code: 0, termination: "exited" }); expect(unlimited.interactive.metrics.cost).toBe(unlimited.standalone.metrics.cost); - const limited = await execute(unlimited.standalone.metrics.cost! - 1); - expect(limited.standalone.termination).toBe("instruction-limit"); - expect(limited.interactive.termination).toBe("instruction-limit"); + const budget = unlimited.standalone.metrics.cost! - 1; + const limited = await execute(budget); + expect(limited.standalone).toMatchObject({ code: 137, termination: "instruction-limit" }); + expect(limited.interactive).toMatchObject({ code: 137, termination: "instruction-limit" }); + expect(limited.interactive.metrics.cost).toBe(budget); + expect(limited.interactive.metrics.cost).toBe(limited.standalone.metrics.cost); + }); + + it("stops a CPU-bound interactive contestant at the default budget well before the wall deadline", { timeout: 300_000 }, async () => { + const contestant = await compileC("forever", "int main(void) { for (;;); }"); + const interactor = await compileC("silent-interactor", "int main(void) { return 0; }"); + const resources = { wallTimeLimitMs: 6_000 }; + + const result = await engine.interact(contestant, interactor, { contestant: { resources }, interactor: { resources } }); + + expect(result.contestant).toMatchObject({ code: 137, termination: "instruction-limit" }); + expect(result.interactor).toMatchObject({ code: 0, termination: "exited" }); }); async function compileC(name: string, source: string): Promise {