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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
128 changes: 124 additions & 4 deletions crates/runtime-core/src/meter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>,
function_types: Vec<u32>,
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<Self::Error>> {
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<Self::Error>> {
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<Self::Error>> {
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,
Expand Down Expand Up @@ -128,12 +192,27 @@ impl Reencode for MeterInitializer {
fn set_initial_meter_budget(wasm: &[u8], budget: i64) -> Result<Vec<u8>, 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<bool, wasmparser::BinaryReaderError> {
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;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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::<Vec<_>>(),
),
_ => None,
})
.collect::<Vec<_>>();
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(
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
6 changes: 4 additions & 2 deletions 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 Expand Up @@ -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:
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
Loading