Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
132 changes: 131 additions & 1 deletion crates/runtime-core/src/interactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,14 +121,22 @@ 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<'_>,
buffer: &[u8],
) -> Poll<io::Result<usize>> {
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,
}
Expand Down Expand Up @@ -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<u8> {
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<u8>, interactor: Vec<u8>) -> 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<u8>) -> InteractiveProgram {
InteractiveProgram {
wasm,
Expand Down
7 changes: 6 additions & 1 deletion crates/runtime-core/src/run/web_interactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<io::Result<usize>> {
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,
}
}
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
9 changes: 9 additions & 0 deletions docs/library-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion scripts/verify-browser-csp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ const guessInput = secret => ({ args:["/judge/input.txt"], files:{"/judge/input.
const guessCpp = { language:"cpp", source:'#include <iostream>\n#include <string>\nint main(){long long lo=1,hi=1<<20;std::string r;while(lo<=hi){long long mid=(lo+hi)/2;std::cout<<mid<<std::endl;if(!(std::cin>>r))return 2;if(r=="=")return 0;if(r=="<")lo=mid+1;else hi=mid-1;}return 1;}' };
const guessC = { language:"c", source:'#include <stdio.h>\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 <stdio.h>\nint main(void){int x;return scanf("%d",&x)==1?0:1;}' };
const finalGuess = { language:"c", source:'#include <stdio.h>\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 <stdio.h>\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 = [
Expand All @@ -111,7 +116,9 @@ const interactiveFixtures = [
{ label:"interactive-poll-clockless", contestant:{ language:"c", source:'#include <poll.h>\n#include <stdio.h>\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 <stdio.h>\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 <errno.h>\n#include <stdio.h>\n#include <unistd.h>\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 <cstdio>\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 <stdio.h>\n#include <string.h>\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 <stdio.h>\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 <errno.h>\n#include <stdio.h>\n#include <string.h>\n#include <unistd.h>\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 <stdio.h>\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 <stdio.h>\nint main(void){while(fputs("flood\\n",stdout)>=0&&fflush(stdout)==0){}return 0;}' }, interactor:{ language:"c", source:'#include <stdio.h>\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) },
Expand Down
6 changes: 3 additions & 3 deletions src/core/runtime-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 {
Expand Down
Loading