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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,36 @@ All notable changes to WASM-OJ are recorded here. Releases follow

## Unreleased

- WebKit now stops a terminated or killed Worker that is running a metered program within well under
a millisecond, so the Web Lock liveness check reports it at once. JavaScriptCore never acts on
`Worker.terminate()` inside Wasm, only at JavaScript checkpoints such as `Atomics.wait`, so a
killed runner or interactive side kept computing until its instruction budget ran out (up to tens
of seconds) and the session ended at its wall limit. Every 2^20 units, at the next function entry
or loop iteration, the meter now calls an uncharged `wasm_oj_metering.safepoint` import, which the
browser runtime turns into such a checkpoint and the native runtime ignores. Costs, cost profiles'
cost values and the exhaustion point are unchanged; the refreshed runtime identity changes cost
profile identifiers.
- Browser runner, compiler, compiler stage (rustc, Go, Java) and interactive side Workers that die
without an `error` event, for example when the browser terminates them, now reject their
operation promptly as a runner or compiler failure. A silently killed Worker used to leave the operation waiting for its wall-time
limit, which reported the student's program as `wall-time-limit`. Each module Worker holds a Web
Lock for its lifetime and its owner treats the lock's release as a crash; without Web Locks
nothing changes. Interactive pipes now wake every 100 ms while waiting, so a terminated side
Worker stops promptly in WebKit, which otherwise keeps it blocked in `Atomics.wait`.
- 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
Expand Down
137 changes: 135 additions & 2 deletions crates/runtime-core/src/interactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ use crate::deterministic::{VirtualClock, attach_interactive_deterministic_import
use crate::filesystem::{
RuntimeProjectFilesystem, is_normalized_guest_path, runtime_project_files,
};
use crate::meter::{CostPoints, MeterState, instrument_wasm, meter_state, remaining_points};
use crate::meter::{
CostPoints, MeterState, attach_safepoint, 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,
Expand Down Expand Up @@ -121,14 +123,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 @@ -563,6 +573,7 @@ fn interactive_runtime(
clock.clone(),
startup_entropy_bytes,
);
attach_safepoint(store, &mut imports);
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)
Expand Down Expand Up @@ -1120,6 +1131,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
Loading