diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ddf711..0d4fa00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ All notable changes to WASM-OJ are recorded here. Releases follow ## Unreleased +- 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/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..d52e009 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`. 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..a9b138a 100644 --- a/scripts/verify-browser-csp.mjs +++ b/scripts/verify-browser-csp.mjs @@ -96,6 +96,11 @@ const guessInput = secret => ({ args:["/judge/input.txt"], files:{"/judge/input. 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 = [ @@ -111,7 +116,9 @@ const interactiveFixtures = [ { 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-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..2dca29e 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: "9fe6417ded9476957ed892eecffb9210fd99aafb3b55d0272ce5c123be43c1e6", + runtimeSourceRootSha256: "8d33f355c9fb44a2f22b501694280fcf64e2c490ea1f9b4ab1195ba2849b62df", 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"; + "9e4f094c5b11450beb19bde39c17fafb3c21475c0f3064f5fa4fecd75ce8c23a"; /** 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..f3f45f0 100644 --- a/src/runner/generated/runtime-core.js +++ b/src/runner/generated/runtime-core.js @@ -775,17 +775,17 @@ function __wbg_get_imports() { return ret; }, __wbindgen_cast_0000000000000002: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Ref(NamedExternref("Array"))], shim_idx: 2393, 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: 2392, 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: 2393, 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: 2392, 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: 2394, 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: 2393, 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 bebed1b..a19d849 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:9fe6417ded9476957ed892eecffb9210fd99aafb3b55d0272ce5c123be43c1e6 +size 14585541 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 = {