diff --git a/CHANGELOG.md b/CHANGELOG.md index 23102e4..30877b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to WASM-OJ are recorded here. Releases follow ## Unreleased +- 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. - Accept runtime-bundle interactors (for example CPython) in `Runner.interact` on the server and in the browser runner Worker. Either side of an interactive session may now be a standalone Wasm module or a runtime bundle that provides streaming fd 0. diff --git a/crates/runtime-core/src/interactive.rs b/crates/runtime-core/src/interactive.rs index 2875b99..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,6 +14,7 @@ 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; @@ -24,75 +22,17 @@ 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, - state: Mutex, -} - -#[derive(Debug)] -struct GasState { - remaining: u64, - exhausted: bool, -} - -impl GasBudget { - fn new(initial: u64) -> Self { - Self { - initial, - state: Mutex::new(GasState { - remaining: initial, - exhausted: 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; - 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, - )) - } -} - -#[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, @@ -263,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, @@ -272,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 @@ -293,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 = - 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)?; @@ -397,7 +373,8 @@ fn prepare_program( Ok(PreparedProgram { wasm, operations: metered.operations, - gas: Arc::new(GasBudget::new(program.resources.instruction_budget)), + budget: program.resources.instruction_budget, + meter: Arc::new(Mutex::new(None)), protocol, stderr, filesystem, @@ -443,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) @@ -516,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, @@ -578,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) @@ -616,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()) @@ -673,7 +646,7 @@ mod tests { use super::interact; use crate::{ DeterminismConfig, ExecutionTermination, InteractiveProgram, InteractiveRequest, - ResourcePolicy, + ResourcePolicy, RunRequest, }; use std::collections::BTreeMap; @@ -998,6 +971,95 @@ mod tests { assert_eq!(result.interactor.metrics.logical_time_ns, 0); } + #[test] + fn interactive_contestant_is_metered_like_a_standalone_run() { + 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 = |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(), + 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 { + interactor: contestant.clone(), + contestant, + 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) + }; + + 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 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 { InteractiveProgram { wasm, diff --git a/crates/runtime-core/src/meter.rs b/crates/runtime-core/src/meter.rs index 048fedd..6ca57a7 100644 --- a/crates/runtime-core/src/meter.rs +++ b/crates/runtime-core/src/meter.rs @@ -7,18 +7,13 @@ 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,32 +77,6 @@ pub fn instrument_wasm(wasm: &[u8], budget: u64) -> Result Result { - 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 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, - }) -} - #[derive(Debug)] struct MeterInitializationError(String); @@ -271,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( @@ -306,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 390b632..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: "1e297ad276694e9fb8a23afdc6ccc0b2ae56a223ced19d5cae26fe4661daa11e", - runtimeSourceRootSha256: "27c52cb1887aa8a7e0d8a238514fc8881cf86c68267e2a1691f110423692e2b6", + 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 = - "d3e44cafdc1c17a7e6bd4591ef04ec8161af805a519b5bbff437b15e5accd442"; + "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 b7899d9..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: 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: 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: 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: 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: 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: 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: 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: 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 1788821..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:1e297ad276694e9fb8a23afdc6ccc0b2ae56a223ced19d5cae26fe4661daa11e -size 14862072 +oid sha256:522924286709c7661f9c23155f3269b7679d50573c62a5ea19d4500c7b37bbef +size 14907700 diff --git a/src/server/judge.integration.test.ts b/src/server/judge.integration.test.ts index c333d74..8f9f00e 100644 --- a/src/server/judge.integration.test.ts +++ b/src/server/judge.integration.test.ts @@ -130,6 +130,53 @@ 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 execute = async (instructionBudget: number) => { + const resources = { instructionBudget, wallTimeLimitMs: 60_000 }; + const standalone = await engine.run(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 }; + }; + + 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 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" }); + }); + it("runs a CPython interactor against a compiled contestant", { timeout: 300_000 }, async () => { const contestant = await compileC("bundle-contestant", [ "#include ",