diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ddf711..2574384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ All notable changes to WASM-OJ are recorded here. Releases follow ## Unreleased +- Fixed WebKit taking about 25 s to stop an empty C++ `for(;;);` at the default instruction budget, + so the run usually hit its wall limit instead. JavaScriptCore never enters optimized code inside a + loop of a function that has no parameters or locals, and keeps calling its tier-up slow path, so + such a metered loop ran about 20 times slower than in Chromium. Instrumentation now gives these + functions one unused local, which changes neither behaviour nor cost; WebKit stops the loop at the + budget in about 0.2 s. The refreshed runtime identity changes cost profiles. +- Interactive writes no longer fail with `EPIPE` after the other side exits or closes its stdin. The + bytes are recorded in the transcript once and dropped, and the writer keeps running, as with a + judge that keeps draining both pipes. An interactor that replies to a contestant that already + exited now reads EOF and exits with its own verdict; a CPython interactor used to exit 120 and + repeat its reply up to five times in the transcript. Reads still return the remaining buffered + bytes and then EOF. This applies to the server and the browser. The refreshed runtime identity + changes cost profiles. + ## 0.2.4 - 2026-10-09 - Fixed a host process crash (`Uncaught Error: write EPIPE`) when `ServerRunner` cancelled or diff --git a/crates/runtime-core/src/interactive.rs b/crates/runtime-core/src/interactive.rs index 77a9f29..735b747 100644 --- a/crates/runtime-core/src/interactive.rs +++ b/crates/runtime-core/src/interactive.rs @@ -121,6 +121,9 @@ struct InteractiveOutput { } impl AsyncWrite for InteractiveOutput { + /// Once the peer has closed its stdin, for example by exiting, the pipe reports a broken + /// pipe. The bytes are already in the transcript, so the write succeeds and they are dropped, + /// as when a judge keeps draining a pipe whose reader is gone. fn poll_write( mut self: Pin<&mut Self>, context: &mut Context<'_>, @@ -128,7 +131,12 @@ impl AsyncWrite for InteractiveOutput { ) -> Poll> { match Pin::new(&mut self.capture).poll_write(context, buffer) { Poll::Ready(Ok(written)) => { - Pin::new(&mut self.pipe).poll_write(context, &buffer[..written]) + match Pin::new(&mut self.pipe).poll_write(context, &buffer[..written]) { + Poll::Ready(Err(error)) if error.kind() == io::ErrorKind::BrokenPipe => { + Poll::Ready(Ok(written)) + } + result => result, + } } result => result, } @@ -1120,6 +1128,128 @@ mod tests { assert_eq!(interactive.metrics.cost, standalone.metrics.cost); } + const DRAIN_STDIN: &str = r#" + (func $drain_stdin (result i32) + (local $total i32) + (loop $again + (i32.store (i32.const 0) (i32.add (i32.const 256) (local.get $total))) + (i32.store (i32.const 4) (i32.const 64)) + (if (call $fd_read (i32.const 0) (i32.const 0) (i32.const 1) (i32.const 8)) + (then (call $exit (i32.const 3)))) + (local.set $total (i32.add (local.get $total) (i32.load (i32.const 8)))) + (br_if $again (i32.load (i32.const 8)))) + local.get $total)"#; + + fn peer_program(body: &str, data: &str) -> Vec { + wat::parse_str(format!( + r#"(module + (import "wasi_snapshot_preview1" "fd_read" + (func $fd_read (param i32 i32 i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "fd_write" + (func $fd_write (param i32 i32 i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "proc_exit" (func $exit (param i32))) + (memory (export "memory") 1) + (data (i32.const 128) "{data}") + {DRAIN_STDIN} + (func $write (param $length i32) (result i32) + (i32.store (i32.const 16) (i32.const 128)) + (i32.store (i32.const 20) (local.get $length)) + (call $fd_write (i32.const 1) (i32.const 16) (i32.const 1) (i32.const 24))) + (func (export "_start") {body}))"# + )) + .unwrap() + } + + fn interact_pair(contestant: Vec, interactor: Vec) -> crate::InteractiveResult { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(interact(InteractiveRequest { + contestant: program(contestant), + interactor: program(interactor), + determinism: DeterminismConfig { + random_seed: 7, + realtime_epoch_ms: 946_684_800_000, + clock_step_ns: 1_000_000, + }, + })) + .unwrap() + } + + #[test] + fn interactor_writes_to_an_exited_contestant_without_a_broken_pipe() { + let contestant = peer_program("(drop (call $write (i32.const 2)))", "1\\n"); + let interactor = peer_program( + r#"(local $errno i32) + (drop (call $drain_stdin)) + (local.set $errno (call $write (i32.const 8))) + (if (local.get $errno) (then (call $exit (local.get $errno)))) + (if (i32.ne (i32.load (i32.const 24)) (i32.const 8)) (then (call $exit (i32.const 4)))) + (call $exit (i32.const 42))"#, + "correct\\n", + ); + + let result = interact_pair(contestant, interactor); + + assert_eq!(result.contestant.termination, ExecutionTermination::Exited); + assert_eq!(result.contestant.code, 0); + assert_eq!(result.interactor.termination, ExecutionTermination::Exited); + assert_eq!(result.interactor.code, 42); + assert_eq!(result.contestant_to_interactor, b"1\n"); + assert_eq!(result.interactor_to_contestant, b"correct\n"); + assert_eq!(result.interactor.metrics.protocol_bytes, 8); + } + + #[test] + fn contestant_reads_buffered_bytes_then_eof_after_the_interactor_exits() { + let contestant = peer_program( + r#"(local $total i32) + (local.set $total (call $drain_stdin)) + (call $exit (select (i32.const 0) (i32.const 1) + (i32.and + (i32.eq (local.get $total) (i32.const 4)) + (i32.eq (i32.load (i32.const 256)) (i32.const 0x0a657962)))))"#, + "", + ); + let interactor = peer_program("(drop (call $write (i32.const 4)))", "bye\\n"); + + let result = interact_pair(contestant, interactor); + + assert_eq!(result.interactor.termination, ExecutionTermination::Exited); + assert_eq!(result.interactor.code, 0); + assert_eq!(result.contestant.termination, ExecutionTermination::Exited); + assert_eq!(result.contestant.code, 0); + assert_eq!(result.interactor_to_contestant, b"bye\n"); + } + + #[test] + fn contestant_writes_to_an_exited_interactor_without_a_broken_pipe() { + let contestant = peer_program( + r#"(local $round i32) + (local $errno i32) + (drop (call $drain_stdin)) + (loop $again + (local.set $errno (call $write (i32.const 2))) + (if (local.get $errno) (then (call $exit (local.get $errno)))) + (if (i32.ne (i32.load (i32.const 24)) (i32.const 2)) (then (call $exit (i32.const 4)))) + (local.set $round (i32.add (local.get $round) (i32.const 1))) + (br_if $again (i32.lt_u (local.get $round) (i32.const 3)))) + (call $exit (i32.const 42))"#, + "x\\n", + ); + let interactor = peer_program("(drop (call $write (i32.const 4)))", "bye\\n"); + + let result = interact_pair(contestant, interactor); + + assert_eq!(result.interactor.termination, ExecutionTermination::Exited); + assert_eq!(result.interactor.code, 0); + assert_eq!(result.contestant.termination, ExecutionTermination::Exited); + assert_eq!(result.contestant.code, 42); + assert_eq!(result.contestant_to_interactor, b"x\nx\nx\n"); + assert_eq!(result.interactor_to_contestant, b"bye\n"); + } + fn program(wasm: Vec) -> InteractiveProgram { InteractiveProgram { wasm, diff --git a/crates/runtime-core/src/meter.rs b/crates/runtime-core/src/meter.rs index 6ca57a7..2ffd9f5 100644 --- a/crates/runtime-core/src/meter.rs +++ b/crates/runtime-core/src/meter.rs @@ -88,13 +88,77 @@ impl std::fmt::Display for MeterInitializationError { impl std::error::Error for MeterInitializationError {} +/// Sets the meter's initial budget and gives every function that has a loop but no parameters or +/// locals one unused `i32` local. JavaScriptCore (Safari 26) never enters optimized code inside a +/// loop of such a function: its baseline tier asks to tier up on almost every iteration and keeps +/// running the slow path, so a metered empty loop runs about 20 times slower than in Chromium and +/// hits the wall deadline before its instruction budget. The local changes neither behaviour nor +/// cost. struct MeterInitializer { budget: i64, + parameterized_types: Vec, + function_types: Vec, + defined_functions: usize, } impl Reencode for MeterInitializer { type Error = MeterInitializationError; + fn parse_type_section( + &mut self, + types: &mut wasm_encoder::TypeSection, + section: wasmparser::TypeSectionReader<'_>, + ) -> Result<(), ReencodeError> { + for group in section.clone() { + for ty in group?.types() { + self.parameterized_types + .push(match &ty.composite_type.inner { + wasmparser::CompositeInnerType::Func(function) => { + !function.params().is_empty() + } + _ => true, + }); + } + } + wasm_encoder::reencode::utils::parse_type_section(self, types, section) + } + + fn parse_function_section( + &mut self, + functions: &mut wasm_encoder::FunctionSection, + section: wasmparser::FunctionSectionReader<'_>, + ) -> Result<(), ReencodeError> { + for function in section.clone() { + self.function_types.push(function?); + } + wasm_encoder::reencode::utils::parse_function_section(self, functions, section) + } + + fn parse_function_body( + &mut self, + code: &mut wasm_encoder::CodeSection, + body: wasmparser::FunctionBody<'_>, + ) -> Result<(), ReencodeError> { + let ordinal = self.defined_functions; + self.defined_functions += 1; + let parameterized = self + .function_types + .get(ordinal) + .and_then(|ty| self.parameterized_types.get(*ty as usize)) + .copied() + .unwrap_or(true); + if parameterized || body.get_locals_reader()?.get_count() != 0 || !has_loop(&body)? { + return wasm_encoder::reencode::utils::parse_function_body(self, code, body); + } + let mut function = wasm_encoder::Function::new([(1, wasm_encoder::ValType::I32)]); + let mut operators = body.get_operators_reader()?; + while !operators.eof() { + function.instruction(&self.parse_instruction(&mut operators)?); + } + code.function(&function); + Ok(()) + } + fn parse_global_section( &mut self, globals: &mut wasm_encoder::GlobalSection, @@ -128,12 +192,27 @@ impl Reencode for MeterInitializer { fn set_initial_meter_budget(wasm: &[u8], budget: i64) -> Result, String> { validate_meter_global_position(wasm)?; let mut module = wasm_encoder::Module::new(); - MeterInitializer { budget } - .parse_core_module(&mut module, wasmparser::Parser::new(0), wasm) - .map_err(|error| format!("failed to initialize weighted meter: {error}"))?; + MeterInitializer { + budget, + parameterized_types: Vec::new(), + function_types: Vec::new(), + defined_functions: 0, + } + .parse_core_module(&mut module, wasmparser::Parser::new(0), wasm) + .map_err(|error| format!("failed to initialize weighted meter: {error}"))?; Ok(module.finish()) } +fn has_loop(body: &wasmparser::FunctionBody<'_>) -> Result { + let mut operators = body.get_operators_reader()?; + while !operators.eof() { + if matches!(operators.read()?, wasmparser::Operator::Loop { .. }) { + return Ok(true); + } + } + Ok(false) +} + fn validate_meter_global_position(wasm: &[u8]) -> Result<(), String> { let mut imported_globals = 0_u32; let mut defined_globals = 0_u32; @@ -409,7 +488,7 @@ mod tests { use super::{METER_MODEL, instrument_wasm, weighted_opcode_cost}; use std::borrow::Cow; use wasm_encoder::{Encode, Section}; - use wasmparser::{ExternalKind, Parser, Payload}; + use wasmparser::{ExternalKind, Parser, Payload, ValType}; #[test] fn instrumentation_adds_the_metering_global() { @@ -488,6 +567,47 @@ mod tests { assert_eq!(weighted_opcode_cost("AtomicFence"), Some(1000)); } + #[test] + fn functions_with_a_loop_and_no_params_or_locals_get_one_unused_local() { + let wasm = wat::parse_str( + r#"(module + (memory (export "memory") 1) + (func (export "_start") (loop (br 0))) + (func (local i64) (loop (br 0))) + (func (param i32) (loop (br 0))) + (func nop) + (func (block (loop (br 1)))))"#, + ) + .unwrap(); + let metered = instrument_wasm(&wasm, 1_000_000).unwrap(); + let locals = Parser::new(0) + .parse_all(&metered.wasm) + .filter_map(Result::ok) + .filter_map(|payload| match payload { + Payload::CodeSectionEntry(body) => Some( + body.get_locals_reader() + .unwrap() + .into_iter() + .map(|local| local.unwrap()) + .collect::>(), + ), + _ => None, + }) + .collect::>(); + let i32_local = vec![(1, ValType::I32)]; + assert_eq!( + locals, + [ + i32_local.clone(), + vec![(1, ValType::I64)], + vec![], + vec![], + i32_local, + vec![], + ] + ); + } + #[test] fn reports_wark_compatible_static_operation_counts() { let wasm = wat::parse_str( diff --git a/crates/runtime-core/src/run/web_interactive.rs b/crates/runtime-core/src/run/web_interactive.rs index c16f73b..6820922 100644 --- a/crates/runtime-core/src/run/web_interactive.rs +++ b/crates/runtime-core/src/run/web_interactive.rs @@ -291,13 +291,18 @@ impl std::fmt::Debug for StreamOutput { } impl AsyncWrite for StreamOutput { + /// Drops bytes written after the peer closed its stdin, as native does; they are already in + /// the transcript. fn poll_write( mut self: Pin<&mut Self>, context: &mut Context<'_>, buffer: &[u8], ) -> Poll> { match Pin::new(&mut self.capture).poll_write(context, buffer) { - Poll::Ready(Ok(written)) => Poll::Ready(self.streams.write(&buffer[..written])), + Poll::Ready(Ok(written)) => Poll::Ready(match self.streams.write(&buffer[..written]) { + Err(error) if error.kind() == io::ErrorKind::BrokenPipe => Ok(written), + result => result, + }), result => result, } } diff --git a/docs/architecture.md b/docs/architecture.md index 9406b48..ddb911d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,7 +57,7 @@ complete browser Worker-generation boundary. Browser interaction runs each side Worker as a standalone metered run. The two sides exchange bytes through shared-memory ring buffers whose reads block with `Atomics.wait`, so neither side ever yields to the other on one thread. Each ring holds its writer's whole output budget, so a write never waits, as on the server's unbounded -pipes. A poll checks the input without blocking, so another ready subscription is reported first; if +pipes. On both hosts a write after the reader exited is dropped instead of failing. A poll checks the input without blocking, so another ready subscription is reported first; if nothing is ready, a poll with a clock advances the virtual clock to its deadline, as the server does. On both hosts a read from stdin opened with `O_NONBLOCK` waits for input instead of failing with `EAGAIN`. @@ -154,7 +154,9 @@ or toolchain source. Before instantiation the runtime validates the module, removes non-semantic debug/name sections, preserves required runtime metadata, and injects a mutable 64-bit weighted instruction meter. The -budget is present before a start section can execute. Static original-opcode counts and normalized +budget is present before a start section can execute. A function that has a loop but no parameters +or locals also gets one unused local: JavaScriptCore never optimizes such a loop and runs it about 20 +times slower than Chromium, so it would reach the wall deadline before its budget. Static original-opcode counts and normalized cost are reported separately from injected meter instructions. Contract 2 enforces: diff --git a/docs/library-contract.md b/docs/library-contract.md index 214a5fa..bd30521 100644 --- a/docs/library-contract.md +++ b/docs/library-contract.md @@ -244,6 +244,15 @@ resource policies, process-local deterministic clocks, and secret inputs mounted interactor side. Either side may be a standalone Wasm module or a runtime bundle such as CPython; runtime bundles that cannot provide streaming fd 0 are rejected for interaction. +A side that has exited, or closed its stdin, no longer reads, but its peer's writes to it still +succeed: the bytes are dropped and the peer keeps running without `EPIPE`, as with a judge that keeps +draining both programs' output until they exit. Reads from a side that has exited, or closed its +stdout, return the bytes still buffered and then EOF. An interactor can therefore reply to a +contestant that already exited, read EOF, and exit with its own verdict. `contestantToInteractor` and +`interactorToContestant` record every byte each side wrote to stdout exactly once, up to its output +limit, including bytes written after the peer exited. A transcript depends only on what its writer +wrote, not on when the reader exited. + Each case executes under the broad hard policy once. Correct output and the same normalized metrics are evaluated against ordered cumulative `baseline`, `efficient`, and `optimal` policies. The portable compute metric is `RunResult.metrics.cost`; wall time is only a safety boundary. diff --git a/scripts/verify-browser-csp.mjs b/scripts/verify-browser-csp.mjs index 5f9a545..56aade9 100644 --- a/scripts/verify-browser-csp.mjs +++ b/scripts/verify-browser-csp.mjs @@ -90,12 +90,18 @@ fixtures.push( { language:"c", label:"logical-clock-limit", source:'#include \n#include \nint main(){for(int i=0;i<10000;i++) clock();puts("42");}', input:"", termination:"logical-time-limit", resources:{logicalTimeLimitMs:1} }, { language:"c", label:"cpu-work", source:'#include \nint main(){volatile unsigned long long s=0;for(unsigned i=0;i<10000000;i++)s+=i;printf("%llu\\n",s);}', input:"", expected:"49999995000000\n" }, { language:"python", label:"memory-16mb", source:'print(42)', input:"", expected:"42\n", resources:{memoryLimitBytes:16*1024*1024} }, + { language:"cpp", label:"empty-loop-budget", source:'int main(){for(;;);}', input:"", termination:"instruction-limit", resources:{wallTimeLimitMs:15000} }, ); const guessInteractor = { language:"cpp", source:'#include \nint main(int argc,char**argv){std::FILE*f=argc>1?std::fopen(argv[1],"r"):nullptr;long long secret,guess;int limit;if(!f||std::fscanf(f,"%lld %d",&secret,&limit)!=2)return 3;for(int round=0;round");std::fflush(stdout);}return 1;}' }; const guessInput = secret => ({ args:["/judge/input.txt"], files:{"/judge/input.txt":`${secret} 25\n`} }); const guessCpp = { language:"cpp", source:'#include \n#include \nint main(){long long lo=1,hi=1<<20;std::string r;while(lo<=hi){long long mid=(lo+hi)/2;std::cout<>r))return 2;if(r=="=")return 0;if(r=="<")lo=mid+1;else hi=mid-1;}return 1;}' }; const guessC = { language:"c", source:'#include \nint main(void){long long lo=1,hi=1<<20;char r[4];while(lo<=hi){long long mid=(lo+hi)/2;printf("%lld\\n",mid);fflush(stdout);if(scanf("%3s",r)!=1)return 2;if(r[0]==\'=\')return 0;if(r[0]==\'<\')lo=mid+1;else hi=mid-1;}return 1;}' }; const readOne = { language:"c", source:'#include \nint main(void){int x;return scanf("%d",&x)==1?0:1;}' }; +const finalGuess = { language:"c", source:'#include \nint main(void){puts("7");return 0;}' }; +const secretInput = secret => ({ args:["/judge/input.txt"], files:{"/judge/input.txt":`${secret}\n`} }); +const replyInteractor = (language, afterEof) => language === "c" + ? { language, source:`#include \nint main(int argc,char**argv){FILE*f=argc>1?fopen(argv[1],"r"):NULL;long long secret,guess;if(!f||fscanf(f,"%lld",&secret)!=1)return 2;if(scanf("%lld",&guess)!=1)return 3;${afterEof ? "while(getchar()!=EOF){}" : ""}puts(guess==secret?"correct":"wrong");if(fflush(stdout)!=0)return 4;${afterEof ? "" : "if(scanf(\"%lld\",&guess)!=EOF)return 5;"}return guess==secret?42:43;}` } + : { language, source:`import sys\nsecret = int(open(sys.argv[1]).read())\nguess = int(input())\n${afterEof ? "sys.stdin.read()\n" : ""}print("correct" if guess == secret else "wrong", flush=True)\n${afterEof ? "" : "if sys.stdin.read().strip():\n sys.exit(5)\n"}sys.exit(42 if guess == secret else 43)\n` }; const exited = (process, code) => process.termination === "exited" && process.code === code; const guessed = result => exited(result.contestant, 0) && exited(result.interactor, 0) && result.interactorToContestant.endsWith("=\n"); const interactiveFixtures = [ @@ -110,8 +116,11 @@ const interactiveFixtures = [ { label:"interactive-batch", contestant:{ language:"c", source:'#include \nint main(void){long long x;for(int i=0;i<30000;i++){if(scanf("%lld",&x)!=1)return 2;printf("%lld\\n",2*x);}fflush(stdout);char b[8];if(scanf("%7s",b)!=1)return 3;return b[0]==\'o\'?0:4;}' }, interactor:{ language:"c", source:'#include \nint main(void){for(int i=0;i<30000;i++)printf("%d\\n",1000000+i);fflush(stdout);for(int i=0;i<30000;i++){long long x;if(scanf("%lld",&x)!=1)return 2;if(x!=2LL*(1000000+i))return 1;}puts("ok");fflush(stdout);return 0;}' }, options:{ contestant:{ resources:{ wallTimeLimitMs:20000 } }, interactor:{ resources:{ wallTimeLimitMs:20000 } } }, check:result => exited(result.contestant, 0) && exited(result.interactor, 0) && result.contestantToInteractor.length === 240000 && result.interactorToContestant.length === 240003 && result.interactorToContestant.endsWith("ok\n") }, { label:"interactive-poll-clockless", contestant:{ language:"c", source:'#include \n#include \nint main(void){struct pollfd p[2]={{0,POLLIN,0},{1,POLLOUT,0}};if(poll(p,2,-1)<1||!(p[1].revents&POLLOUT))return 4;puts("ping");fflush(stdout);if(poll(p,1,-1)!=1||!(p[0].revents&POLLIN))return 5;char b[8];if(scanf("%7s",b)!=1)return 2;return b[0]==\'p\'&&b[1]==\'o\'?0:3;}' }, interactor:{ language:"c", source:'#include \nint main(void){char b[8];if(scanf("%7s",b)!=1)return 2;puts("pong");fflush(stdout);return 0;}' }, options:{ contestant:{ resources:{ wallTimeLimitMs:10000 } }, interactor:{ resources:{ wallTimeLimitMs:10000 } } }, check:result => exited(result.contestant, 0) && exited(result.interactor, 0) && result.contestantToInteractor === "ping\n" && result.interactorToContestant === "pong\n" }, { label:"interactive-instruction-limit", contestant:{ language:"cpp", source:'int main(){volatile unsigned long long spin=0;for(;;)spin=spin+1;}' }, interactor:guessInteractor, options:{ contestant:{ resources:{ wallTimeLimitMs:30000 } }, interactor:{ ...guessInput(1), resources:{ wallTimeLimitMs:30000 } } }, check:result => result.contestant.termination === "instruction-limit" && result.contestant.code === 137 && exited(result.interactor, 4) }, + { label:"interactive-empty-loop-budget", contestant:{ language:"cpp", source:'int main(){for(;;);}' }, interactor:readOne, options:{ contestant:{ resources:{ wallTimeLimitMs:15000 } }, interactor:{ resources:{ wallTimeLimitMs:15000 } } }, check:(result, elapsedMs) => result.contestant.termination === "instruction-limit" && result.contestant.code === 137 && exited(result.interactor, 1) && elapsedMs < 10000 }, { label:"interactive-contestant-exits", contestant:{ language:"c", source:'int main(void){return 0;}' }, interactor:guessInteractor, options:{ interactor:guessInput(1) }, check:result => exited(result.contestant, 0) && exited(result.interactor, 4) && result.contestantToInteractor === "" }, - { label:"interactive-interactor-exits", contestant:{ language:"c", source:'#include \n#include \n#include \nint main(void){char b[8];if(scanf("%7s",b)!=1)return 2;for(int i=0;i<1000000;i++)if(write(1,"x\\n",2)<0)return errno==EPIPE?32:33;return 34;}' }, interactor:{ language:"cpp", source:'#include \nint main(){std::puts("bye");std::fflush(stdout);return 0;}' }, options:{}, check:result => exited(result.contestant, 32) && exited(result.interactor, 0) && result.interactorToContestant === "bye\n" }, + { label:"interactive-interactor-exits-eof", contestant:{ language:"c", source:'#include \n#include \nint main(void){char b[8];if(scanf("%7s",b)!=1||strcmp(b,"bye")!=0)return 2;return scanf("%7s",b)==EOF&&feof(stdin)?0:3;}' }, interactor:{ language:"c", source:'#include \nint main(void){puts("bye");return 0;}' }, options:{}, check:result => exited(result.contestant, 0) && exited(result.interactor, 0) && result.interactorToContestant === "bye\n" }, + { label:"interactive-interactor-exits", contestant:{ language:"c", source:'#include \n#include \n#include \n#include \nint main(void){char b[8];if(scanf("%7s",b)!=1||strcmp(b,"bye")!=0)return 2;if(scanf("%7s",b)!=EOF)return 3;for(int i=0;i<3;i++)if(write(1,"x\\n",2)!=2)return errno==EPIPE?32:33;return 0;}' }, interactor:{ language:"c", source:'#include \nint main(void){puts("bye");return 0;}' }, options:{}, check:result => exited(result.contestant, 0) && exited(result.interactor, 0) && result.contestantToInteractor === "x\nx\nx\n" && result.interactorToContestant === "bye\n" }, + ...[["c", true, 7], ["python", true, 8], ["c", false, 8], ["python", false, 7]].map(([language, afterEof, secret]) => ({ label:`interactive-reply-${afterEof ? "after-exit" : "race"}-${language}`, contestant:finalGuess, interactor:replyInteractor(language, afterEof), options:{ interactor:secretInput(secret) }, check:result => exited(result.contestant, 0) && exited(result.interactor, secret === 7 ? 42 : 43) && result.contestantToInteractor === "7\n" && result.interactorToContestant === (secret === 7 ? "correct\n" : "wrong\n") })), { label:"interactive-output-flood", contestant:{ language:"c", source:'#include \nint main(void){while(fputs("flood\\n",stdout)>=0&&fflush(stdout)==0){}return 0;}' }, interactor:{ language:"c", source:'#include \nint main(void){while(getchar()!=EOF){}return 0;}' }, options:{ contestant:{ resources:{ outputLimitBytes:65536 } } }, check:result => result.contestant.termination === "output-limit" && result.contestant.code === 137 && result.contestantToInteractor.length === 65536 && exited(result.interactor, 0) }, { label:"interactive-wall-time", contestant:readOne, interactor:readOne, options:{ contestant:{ resources:{ wallTimeLimitMs:2000 } }, interactor:{ resources:{ wallTimeLimitMs:2000 } } }, check:(result, elapsedMs) => result.contestant.termination === "wall-time-limit" && result.interactor.termination === "wall-time-limit" && elapsedMs >= 2000 && elapsedMs < 15000 }, { label:"interactive-cancel", contestant:readOne, interactor:readOne, options:{}, cancelAfterMs:1000, recovery:{ contestant:guessC, interactor:guessInteractor, options:{ interactor:guessInput(5) } }, check:(result, elapsedMs) => result.cancelled && elapsedMs < 10000 && guessed(result.recovery) }, diff --git a/src/core/runtime-identity.ts b/src/core/runtime-identity.ts index 6323dee..1456ac5 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: "0ae00fc646f529aafb5d1a68758220e6428f6c565e8e621eeb6c8f14ac5d4a10", - runtimeSourceRootSha256: "a7e9071511d127e3b14920558c7c38c27a1fa19d98906c8476c1df8041a9c3bb", + runtimeCoreWasmSha256: "96876c8c384b4c6489088302d2d5e9db91464fa171d0665e0e8009fb96fb9105", + runtimeSourceRootSha256: "da89f4eef0d1155e7eebeb0f0ee1d6b598e81c4d2ed2b784b299bf9181c38a72", 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 = - "e5680beae9bd832ed2b679267250ce1efc7d104dd005bb155135664b40f961fb"; + "07d2d2bf91666336edb4cff570013c32d876307eda523f98545df67ee508805a"; /** 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 4aaddfc..7d31217 100644 --- a/src/runner/generated/runtime-core.js +++ b/src/runner/generated/runtime-core.js @@ -770,7 +770,7 @@ 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: 6916, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 6920, 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; }, diff --git a/src/runner/generated/runtime-core_bg.wasm b/src/runner/generated/runtime-core_bg.wasm index bebed1b..cd6f5a9 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:0ae00fc646f529aafb5d1a68758220e6428f6c565e8e621eeb6c8f14ac5d4a10 -size 14599854 +oid sha256:96876c8c384b4c6489088302d2d5e9db91464fa171d0665e0e8009fb96fb9105 +size 14529506 diff --git a/src/server/judge.integration.test.ts b/src/server/judge.integration.test.ts index 8f9f00e..6145bf6 100644 --- a/src/server/judge.integration.test.ts +++ b/src/server/judge.integration.test.ts @@ -217,6 +217,102 @@ describe.skipIf(!enabled)("real server judge contracts", () => { }); }); + it("lets C and CPython interactors reply to a contestant that already exited", { timeout: 300_000 }, async () => { + const contestant = await compileC("final-guess", [ + "#include ", + "int main(void) {", + " puts(\"7\");", + " return 0;", + "}", + ].join("\n")); + const interactors = { + c: await compileC("c-reply-after-exit", [ + "#include ", + "int main(int argc, char **argv) {", + " long long secret = 0, guess = 0;", + " FILE *input = argc > 1 ? fopen(argv[1], \"r\") : NULL;", + " if (!input || fscanf(input, \"%lld\", &secret) != 1) return 2;", + " if (scanf(\"%lld\", &guess) != 1) return 3;", + " while (getchar() != EOF) {}", + " puts(guess == secret ? \"correct\" : \"wrong\");", + " if (fflush(stdout) != 0) return 4;", + " return guess == secret ? 42 : 43;", + "}", + ].join("\n")), + python: await compilePython("python-reply-after-exit", [ + "import sys", + "secret = int(open(sys.argv[1]).read())", + "guess = int(input())", + "sys.stdin.read()", + "print(\"correct\" if guess == secret else \"wrong\", flush=True)", + "sys.exit(42 if guess == secret else 43)", + ].join("\n")), + }; + + for (const [language, interactor] of Object.entries(interactors)) { + for (const [secret, code, reply] of [[7, 42, "correct\n"], [8, 43, "wrong\n"]] as const) { + const result = await engine.interact(contestant, interactor, { + interactor: { args: ["/judge/input.txt"], files: { "/judge/input.txt": `${secret}\n` } }, + }); + expect({ language, secret, result }).toMatchObject({ + language, + secret, + result: { + contestantToInteractor: "7\n", + interactorToContestant: reply, + contestant: { code: 0, termination: "exited" }, + interactor: { code, termination: "exited" }, + }, + }); + } + } + }); + + it("gives the contestant EOF and accepts its writes after the interactor exits", { timeout: 300_000 }, async () => { + const contestant = await compileC("write-after-interactor", [ + "#include ", + "#include ", + "#include ", + "#include ", + "int main(void) {", + " char word[8];", + " if (scanf(\"%7s\", word) != 1 || strcmp(word, \"bye\") != 0) return 2;", + " if (scanf(\"%7s\", word) != EOF || !feof(stdin)) return 3;", + " for (int i = 0; i < 3; ++i)", + " if (write(1, \"x\\n\", 2) != 2) return errno == EPIPE ? 32 : 33;", + " return 0;", + "}", + ].join("\n")); + const interactor = await compileC("early-exit", [ + "#include ", + "int main(void) {", + " puts(\"bye\");", + " return 0;", + "}", + ].join("\n")); + + const result = await engine.interact(contestant, interactor, {}); + + expect(result).toMatchObject({ + contestantToInteractor: "x\nx\nx\n", + interactorToContestant: "bye\n", + contestant: { code: 0, termination: "exited" }, + interactor: { code: 0, termination: "exited" }, + }); + }); + + async function compilePython(name: string, source: string): Promise { + const built = await engine.compile({ + projectId: `judge-integration:${name}`, + name, + language: "python", + entry: "main.py", + files: { "main.py": `${source}\n` }, + }, { cache: false }); + if (!built.success || !built.artifact) throw new Error(`Failed to compile ${name}: ${built.stderr}`); + return built.artifact; + } + async function compileC(name: string, source: string): Promise { const entry = `src/${name}.c`; const input: CompileInput = {