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

- 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`.

## 0.2.4 - 2026-10-09

- Fixed a host process crash (`Uncaught Error: write EPIPE`) when `ServerRunner` cancelled or
Expand Down
8 changes: 8 additions & 0 deletions docs/library-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,14 @@ nested stages with bounded lifetime. Python and JavaScript package source files
crossing a stage budget, cancellation, timeout, restart, cache clearing, disposal, or infrastructure
failure establishes a complete Worker-generation boundary.

A module Worker that dies without an `error` event, for example because the browser terminated it,
is reported like a crash: its operation rejects with a `runner-failure` or `compiler-failure`
instead of waiting for its wall-time or build deadline, so a killed Worker is never reported as a
time limit. Each Worker holds a Web Lock for its lifetime, and its owner learns of the death when
that lock frees. Without Web Locks the previous behaviour remains. In WebKit a terminated Worker
keeps its lock while it runs Wasm, so a Worker killed in the middle of a computation is reported
only when it next returns to JavaScript; Chromium reports a busy Worker about 2 s after the kill.

Wasmer secondary Workers are host implementation details. They use the SDK's supported `workerUrl`
protocol and do not grant guest thread-spawn capability. The host page must be cross-origin
isolated.
Expand Down
103 changes: 102 additions & 1 deletion scripts/verify-browser-csp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ const bootstrap = `import { createBrowserEngine, WASM_OJ_LIBCXX_PCH_HEADER } fro
window.cspViolations = []; addEventListener('securitypolicyviolation', e => window.cspViolations.push({directive:e.effectiveDirective, blockedURI:e.blockedURI, source:e.sourceFile}));
try { new Function('return 1')(); window.evalBlocked = false; } catch { window.evalBlocked = true; }
window.header = WASM_OJ_LIBCXX_PCH_HEADER;
const NativeWorker = Worker; window.createdWorkers = [];
window.Worker = class extends NativeWorker { constructor(url, options) { super(url, options); window.createdWorkers.push({ name:options?.name, worker:this }); } };
window.killWorker = name => NativeWorker.prototype.terminate.call(window.createdWorkers.findLast(entry => entry.name === name).worker);
window.engine = await createBrowserEngine({ artifactCache:false, toolchains: ${JSON.stringify(sources)} });
window.ready = true;`;
const server = createServer(async (req, res) => {
Expand Down Expand Up @@ -219,6 +222,104 @@ try {
await writeFile(path.join(output,"results.json"),JSON.stringify(record,null,2)+"\n");
console.log(JSON.stringify({ label:fixture.label, pass, elapsedMs:outcome.elapsedMs, error:outcome.error, summary }));
}
if (selected.length === 0 || selected.includes("liveness")) {
record.liveness = [];
const sources = {
readOne:{ language:"c", source:'#include <stdio.h>\nint main(void){int x;return scanf("%d",&x)==1?0:1;}' },
yieldLoop:{ language:"c", source:'#include <sched.h>\nint main(void){for(;;)sched_yield();}' },
computeLoop:{ language:"cpp", source:'int main(){volatile unsigned long long spin=0;for(;;)spin=spin+1;}' },
guessContestant:guessC,
guessInteractor,
};
const prepared = await page.evaluate(async sources => {
window.livenessBuilds = {};
for (const [name, { language, source }] of Object.entries(sources)) {
const entry = language === "cpp" ? "main.cpp" : "main.c";
const files = { [entry]:source };
if (language === "cpp") files["src/bits/stdc++.h"] = window.header;
const built = await window.engine.compile({ language, target:"wasip1", optimization:"release", entry, files, projectId:`csp-liveness-${name}` }, { cache:false });
if (!built.success || !built.artifact) throw new Error(`liveness build ${name} failed: ${built.stderr}`);
window.livenessBuilds[name] = built.artifact;
}
const summary = value => value.termination ?? (value.contestant ? `${value.contestant.termination}/${value.interactor.code}` : `compiled:${value.success}`);
window.settle = promise => promise.then(value => ({ ok:true, summary:summary(value), stderr:value.stderr, at:performance.now() }), error => ({ ok:false, error:String(error), at:performance.now() }));
const blocked = { contestant:{ resources:{ wallTimeLimitMs:20000 } }, interactor:{ resources:{ wallTimeLimitMs:20000 } } };
window.livenessOperation = operation => {
const builds = window.livenessBuilds;
if (operation === "interact") return window.engine.interact(builds.readOne, builds.readOne, blocked);
if (operation === "run-yielding") return window.engine.run(builds.yieldLoop, { resources:{ instructionBudget:1e15, wallTimeLimitMs:20000 } });
if (operation === "run-compute") return window.engine.run(builds.computeLoop, { resources:{ instructionBudget:1e15, wallTimeLimitMs:15000 } });
if (operation === "compile-rust") return window.engine.compile({ language:"rust", target:"wasip1", optimization:"release", entry:"main.rs", files:{ "main.rs":'fn main(){println!("{}", 42);}' }, projectId:"csp-liveness-rust" }, { cache:false });
return window.engine.compile({ language:"cpp", target:"wasip1", optimization:"release", entry:"main.cpp", files:{ "main.cpp":"#include <iostream>\n#include <regex>\nint main(){std::regex r(\"a+\");std::cout<<std::regex_match(\"aaa\",r)<<std::endl;}" }, projectId:"csp-liveness-compile" }, { cache:false });
};
}, sources).then(() => undefined, error => String(error));
if (prepared) record.liveness.push({ label:"liveness-preparation", pass:false, error:prepared });
const workerNamed = async name => {
const nameOf = worker => Promise.race([worker.evaluate(() => self.name).catch(() => ""), new Promise(resolve => setTimeout(resolve, 3000, ""))]);
for (let attempt = 0; attempt < 5; attempt++) {
for (const worker of page.workers().reverse()) if (await nameOf(worker) === name) return worker;
await page.waitForTimeout(500);
}
throw new Error(`The ${name} Worker is not visible to Playwright.`);
};
const nestedKiller = async (parentName, childName, settleMs = 0) => {
const parent = await workerNamed(parentName);
await parent.evaluate(() => {
if (self.livenessWorkers) return;
const Native = self.Worker; self.livenessWorkers = []; self.nativeTerminate = Native.prototype.terminate;
self.Worker = class extends Native { constructor(url, options) { super(url, options); self.livenessWorkers.push({ name:options?.name, worker:this }); } };
});
return () => parent.evaluate(async ([name, settleMs]) => {
for (let attempt = 0; attempt < 600 && !self.livenessWorkers.some(entry => entry.name === name); attempt++) await new Promise(resolve => setTimeout(resolve, 100));
await new Promise(resolve => setTimeout(resolve, settleMs));
self.nativeTerminate.call(self.livenessWorkers.findLast(entry => entry.name === name).worker);
}, [childName, settleMs]);
};
const pageKiller = name => () => page.evaluate(name => window.killWorker(name), name);
const browserName = process.env.WASM_OJ_BROWSER ?? "chromium";
const crashed = (outcome, killMs, limitMs) => /stopped without reporting an error/.test(outcome.ok ? outcome.stderr ?? "" : outcome.error) && killMs < limitMs;
const livenessCases = [
{ label:"liveness-interactive-contestant", operation:"interact", killer:() => nestedKiller("wasm-oj-runner", "wasm-oj-interactive-contestant"), check:(outcome, killMs) => crashed(outcome, killMs, 3000) && outcome.error.includes("interactive contestant Worker") },
{ label:"liveness-interactive-interactor", operation:"interact", killer:() => nestedKiller("wasm-oj-runner", "wasm-oj-interactive-interactor"), check:(outcome, killMs) => crashed(outcome, killMs, 3000) && outcome.error.includes("interactive interactor Worker") },
{ label:"liveness-runner-interact", operation:"interact", killer:async () => pageKiller("wasm-oj-runner"), check:(outcome, killMs) => crashed(outcome, killMs, 3000) },
{ label:"liveness-runner-run-yielding", operation:"run-yielding", killer:async () => pageKiller("wasm-oj-runner"), check:(outcome, killMs) => crashed(outcome, killMs, 4000) },
{ label:"liveness-runner-run-compute", operation:"run-compute", killer:async () => pageKiller("wasm-oj-runner"), check:(outcome, killMs) => browserName === "webkit" ? outcome.ok && outcome.summary === "wall-time-limit" : crashed(outcome, killMs, 4000) },
{ label:"liveness-compiler", operation:"compile", killAfterMs:300, killer:async () => pageKiller("wasm-oj-compiler"), check:(outcome, killMs) => crashed(outcome, killMs, 4000) },
{ label:"liveness-compiler-stage", operation:"compile-rust", killAfterMs:0, killer:() => nestedKiller("wasm-oj-compiler", "wasm-oj-rustc-stage", 1000), check:(outcome, killMs) => crashed(outcome, killMs, 4000) },
];
for (const fixture of prepared ? [] : livenessCases) {
console.log(`START ${fixture.label}`);
let outcome; let killMs; let error;
try {
const kill = await fixture.killer();
await page.evaluate(operation => { window.livenessPending = window.settle(window.livenessOperation(operation)); }, fixture.operation);
await page.waitForTimeout(fixture.killAfterMs ?? 1500);
await kill();
const killedAt = await page.evaluate(() => performance.now());
outcome = await page.evaluate(() => window.livenessPending);
killMs = Math.round(outcome.at - killedAt);
} catch (caught) { error = String(caught); }
const recovery = await page.evaluate(() => window.settle(window.engine.run(window.livenessBuilds.readOne, { stdin:"7\n" })));
const pass = !error && fixture.check(outcome, killMs) && recovery.summary === "exited";
record.liveness.push({ label:fixture.label, pass, killMs, outcome, error, recovery });
await writeFile(path.join(output,"results.json"),JSON.stringify(record,null,2)+"\n");
console.log(JSON.stringify({ label:fixture.label, pass, killMs, error, outcome, recovery:recovery.summary ?? recovery.error }));
}
if (!prepared) {
console.log("START liveness-no-false-positive");
const steady = await page.evaluate(async () => {
const outcomes = [];
const builds = window.livenessBuilds;
for (let index = 0; index < 20; index++) outcomes.push(await window.settle(window.engine.run(builds.readOne, { stdin:`${index}\n` })));
for (let index = 0; index < 5; index++) outcomes.push(await window.settle(window.engine.interact(builds.guessContestant, builds.guessInteractor, { interactor:{ args:["/judge/input.txt"], files:{ "/judge/input.txt":`${index + 1} 25\n` } } })));
outcomes.push(await window.settle(window.engine.compile({ language:"c", target:"wasip1", optimization:"release", entry:"main.c", files:{ "main.c":"int main(void){return 0;}" }, projectId:"csp-liveness-steady" }, { cache:false })));
return outcomes.map(outcome => outcome.summary ?? outcome.error);
});
const steadyPass = steady.length === 26 && steady.slice(0, 20).every(value => value === "exited") && steady.slice(20, 25).every(value => value === "exited/0") && steady[25] === "compiled:true";
record.liveness.push({ label:"liveness-no-false-positive", pass:steadyPass, outcomes:steady });
console.log(JSON.stringify({ label:"liveness-no-false-positive", pass:steadyPass, outcomes:steady }));
} else console.log(JSON.stringify({ label:"liveness-preparation", pass:false, error:prepared }));
}
record.capabilities = [];
for (const invoke of [false, true]) {
const wasmPath = path.join(output, `capability-${invoke}.wasm`);
Expand Down Expand Up @@ -251,5 +352,5 @@ finally {
await browser?.close();
await new Promise(resolve => server.close(resolve));
}
if(record.results.some(result=>!result.pass)||record.capabilities?.some(result=>!result.pass)||record.executionTiming?.pass===false||record.interactive?.some(result=>!result.pass))process.exitCode=1;
if(record.results.some(result=>!result.pass)||record.capabilities?.some(result=>!result.pass)||record.executionTiming?.pass===false||record.interactive?.some(result=>!result.pass)||record.liveness?.some(result=>!result.pass))process.exitCode=1;
console.log(`EVIDENCE ${path.join(output,"results.json")}`);
49 changes: 49 additions & 0 deletions src/runtime/client-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const TEST_TOOLCHAINS = Object.freeze([{
interface FakeWorker {
readonly messages: unknown[];
readonly listeners: Map<string, Set<(event: unknown) => void>>;
readonly lostListeners: Set<(error: Error) => void>;
terminated: boolean;
addEventListener(type: string, listener: (event: unknown) => void): void;
postMessage(message: unknown): void;
Expand All @@ -65,6 +66,7 @@ vi.mock("./module-worker", () => ({
const worker: FakeWorker = {
messages: [],
listeners: new Map(),
lostListeners: new Set(),
terminated: false,
addEventListener: () => undefined,
postMessage: () => undefined,
Expand All @@ -87,6 +89,9 @@ vi.mock("./module-worker", () => ({
collection.push(worker);
return worker;
},
onModuleWorkerLost(worker: FakeWorker, listener: (error: Error) => void): void {
worker.lostListeners.add(listener);
},
}));

beforeEach(() => {
Expand Down Expand Up @@ -153,6 +158,46 @@ describe("browser client lifecycle", () => {
compiler.dispose();
});

it("rejects a running execution at once when the runner Worker is lost", async () => {
vi.useFakeTimers();
const runner = new BrowserRunner({ toolchains: TEST_TOOLCHAINS, additionalCostBaselines: { [TEST_COST_PROFILE]: 0 } });
try {
const worker = workerState.runners[0]!;
respondToInitialization(worker);
await runner.ready();
const pending = runner.run(wasmArtifact(), runConfig());
await Promise.resolve();
const { requestId } = requestOfType(worker, "run");
respond(worker, { type: "progress", requestId, progress: { phase: "running", label: "guest" } });
await vi.advanceTimersByTimeAsync(10);

lose(worker, "The wasm-oj-runner Worker stopped without reporting an error.");

await expect(pending).rejects.toThrow("The wasm-oj-runner Worker stopped without reporting an error.");
expect(worker.terminated).toBe(true);
expect(workerState.runners).toHaveLength(2);
} finally {
runner.dispose();
vi.useRealTimers();
}
});

it("rejects a build at once when the compiler Worker is lost", async () => {
const compiler = new BrowserCompiler({ toolchains: TEST_TOOLCHAINS });
const worker = workerState.compilers[0]!;
respondToInitialization(worker);
await compiler.ready();
const pending = compiler.build(javascriptProject(), "cache-key");
await vi.waitFor(() => expect(requestsOfType(worker, "build")).toHaveLength(1));

lose(worker, "The wasm-oj-compiler Worker stopped without reporting an error.");

await expect(pending).rejects.toThrow("The wasm-oj-compiler Worker stopped without reporting an error.");
expect(worker.terminated).toBe(true);
expect(workerState.compilers).toHaveLength(2);
compiler.dispose();
});

it("rejects malformed direct compiler inputs before crossing the Worker boundary", async () => {
const compiler = new BrowserCompiler({ toolchains: TEST_TOOLCHAINS });
const worker = workerState.compilers[0]!;
Expand Down Expand Up @@ -711,6 +756,10 @@ function dispatch(worker: FakeWorker, type: string, event: unknown): void {
for (const listener of worker.listeners.get(type) ?? []) listener(event);
}

function lose(worker: FakeWorker, message: string): void {
for (const listener of worker.lostListeners) listener(new Error(message));
}

function respond(worker: FakeWorker, data: unknown): void {
for (const listener of worker.listeners.get("message") ?? []) listener({ data });
}
Expand Down
9 changes: 5 additions & 4 deletions src/runtime/compiler-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
maximumOutputReadyRustStages,
} from "../compiler/browser-rust-policy";
import CompilerWorkerUrl from "./compiler.worker?worker&url";
import { createModuleWorker } from "./module-worker";
import { createModuleWorker, onModuleWorkerLost } from "./module-worker";
import { prefetchBrowserToolchain } from "./toolchain-prefetch";
import { clearClangBuildGraphCache } from "../compiler/indexeddb-build-graph-cache";

Expand Down Expand Up @@ -273,13 +273,14 @@ export class BrowserCompiler implements Compiler {
worker.addEventListener("message", (event: MessageEvent<CompilerResponse>) => {
if (!this.disposed && !this.workerDormant && this.worker === worker) this.handleMessage(event.data);
});
worker.addEventListener("error", (event) => {
const error = new Error(event.message || "The compiler worker crashed.");
const crashed = (error: Error) => {
if (this.disposed || this.workerDormant || this.worker !== worker) return;
const canRecover = this.workerInitialized;
this.stopWorker(error);
if (canRecover) this.installWorker();
});
};
worker.addEventListener("error", (event) => crashed(new Error(event.message || "The compiler worker crashed.")));
onModuleWorkerLost(worker, crashed);
return worker;
}

Expand Down
8 changes: 7 additions & 1 deletion src/runtime/interactive-pipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ const WRITER_CLOSED = 2;
const READER_CLOSED = 3;
const SEQUENCE = 4;
const HEADER_BYTES = 32;
/**
* WebKit does not stop a Worker that `terminate()` catches inside `Atomics.wait` until the wait
* returns, and Chromium waits up to 2 s. Waking periodically lets a terminated side Worker stop,
* and release its liveness lock, promptly.
*/
const WAIT_SLICE_MS = 100;

/**
* The smallest ring that holds a writer's whole output budget. Only budgeted stdout bytes enter
Expand Down Expand Up @@ -49,7 +55,7 @@ class InteractivePipeEnd {
}

protected sleep(sequence: number): void {
Atomics.wait(this.header, SEQUENCE, sequence);
Atomics.wait(this.header, SEQUENCE, sequence, WAIT_SLICE_MS);
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/runtime/isolated-stage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { onModuleWorkerLost } from "./module-worker";

export type IsolatedStageResponse<Result> =
| { type: "result"; result: Result }
| { type: "shutdown-complete" }
Expand Down Expand Up @@ -51,6 +53,7 @@ export class PersistentIsolatedStage<Request, Result> {
this.worker.addEventListener("message", this.onMessage);
this.worker.addEventListener("error", this.onError);
this.worker.addEventListener("messageerror", this.onMessageError);
onModuleWorkerLost(this.worker, (error) => this.fail(error));
}

run(request: Request): Promise<Result> {
Expand Down Expand Up @@ -247,6 +250,7 @@ export function runIsolatedStage<Request, Result>(
worker.addEventListener("message", onMessage);
worker.addEventListener("error", onError);
worker.addEventListener("messageerror", onMessageError);
onModuleWorkerLost(worker, (error) => finish(() => reject(error)));
try {
worker.postMessage(request);
} catch (error) {
Expand Down
Loading