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

- Fixed WebKit page crashes during browser compiles (`SIGSEGV` in
`JSC::SharedArrayBufferContents::grow`; the CSP suite lost its page in 7 of 10 full WebKit runs).
JavaScriptCore crashes when a shared `WebAssembly.Memory` grows while a Worker whose instance
imported it is being torn down. The Wasmer SDK terminates one of its thread Workers whenever a
WASIX thread or process ends and starts the next in a new Worker that grows their shared memory,
six to nine times per C compile. The compiler, rustc-stage and runner Workers now hold the SDK's
terminations until the build or run ends and wait 250 ms after the last one before the next.

## 0.2.4 - 2026-10-09

- Fixed a host process crash (`Uncaught Error: write EPIPE`) when `ServerRunner` cancelled or
Expand Down
6 changes: 5 additions & 1 deletion docs/library-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,11 @@ failure establishes a complete Worker-generation boundary.

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.
isolated. The SDK terminates a secondary Worker whenever a WASIX thread or process ends; WASM-OJ
holds those terminations until the current build or run ends, and starts the next one only after
250 ms without a termination. JavaScriptCore crashes the page when a shared `WebAssembly.Memory`
grows while a Worker whose instance imported it is being torn down, and the SDK's Workers share one
memory that grows throughout a build.

## Server execution boundary

Expand Down
14 changes: 13 additions & 1 deletion scripts/verify-browser-csp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,18 @@ 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("sdk-worker-churn")) {
console.log("START sdk-worker-churn");
const churn = await page.evaluate(async () => {
for (let index = 0; index < 30; index++) {
const build = await window.engine.compile({ language:"c", target:"wasip1", optimization:"release", entry:"main.c", files:{ "main.c":`int main(void){return ${index};}` }, projectId:`csp-sdk-churn-${index}` }, { cache:false });
if (!build.success) return { compiles:index, error:build.stderr };
}
return { compiles:30 };
}).catch((error) => ({ error:String(error) }));
record.sdkWorkerChurn = { pass:churn.compiles === 30, ...churn };
console.log(JSON.stringify({ label:"sdk-worker-churn", ...record.sdkWorkerChurn }));
}
record.capabilities = [];
for (const invoke of [false, true]) {
const wasmPath = path.join(output, `capability-${invoke}.wasm`);
Expand Down Expand Up @@ -251,5 +263,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.sdkWorkerChurn?.pass===false)process.exitCode=1;
console.log(`EVIDENCE ${path.join(output,"results.json")}`);
11 changes: 10 additions & 1 deletion src/runtime/compiler.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import JavaStageWorkerUrl from "./java-stage.worker?worker&url";
import type { JavaCompileRequest, JavaCompileResult, JavaStageRequest } from "@/src/compiler/java-toolchain";
import { JAVA_COMPILE_TIMEOUT_MS } from "@/src/compiler/java-toolchain";
import { PersistentIsolatedStage } from "./isolated-stage";
import { OwnedWorkerRegistry, type WorkerConstructorHost } from "./owned-worker-registry";
import {
createModuleWorker,
createModuleWorkerBootstrap,
Expand All @@ -70,6 +71,10 @@ let toolchainSources: readonly BrowserToolchainSource[] | undefined;
let runtime: Runtime | undefined;
let runtimeInitialization: Promise<void> | undefined;
let wasmerThreadWorkerBootstrap: ModuleWorkerBootstrap | undefined;
const wasmerThreadWorkers = new OwnedWorkerRegistry(globalThis as unknown as WorkerConstructorHost, {
owns: (scriptUrl) => scriptUrl === wasmerThreadWorkerBootstrap?.url,
});
let wasmerThreadWorkersInstalled = false;
let rustStage: PersistentIsolatedStage<RustcStageRequest, RustCompileResult> | undefined;
let goStage: PersistentIsolatedStage<GoStageRequest, GoCompileResult> | undefined;
let javaStage: PersistentIsolatedStage<JavaStageRequest, JavaCompileResult> | undefined;
Expand Down Expand Up @@ -237,6 +242,10 @@ async function ensureOuterRuntime(requestId: string): Promise<void> {
const bootstrap = createModuleWorkerBootstrap(new URL(wasmerThreadWorkerUrl, workerBaseUrl));
wasmerThreadWorkerBootstrap = bootstrap;
try {
if (!wasmerThreadWorkersInstalled) {
wasmerThreadWorkers.install();
wasmerThreadWorkersInstalled = true;
}
await init({
log: "warn",
module: new URL(wasmerWasmUrl, workerBaseUrl),
Expand Down Expand Up @@ -316,7 +325,7 @@ scope.addEventListener("message", (event: MessageEvent<CompilerRequest>) => {
post({
type: "build-result",
requestId: request.requestId,
result: await build(request),
result: await wasmerThreadWorkers.run(() => build(request)),
});
break;
case "quiesce":
Expand Down
98 changes: 94 additions & 4 deletions src/runtime/owned-worker-registry.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { OwnedWorkerRegistry, type WorkerConstructorHost } from "./owned-worker-registry";

class FakeWorker {
readonly url: string | URL;
readonly options: WorkerOptions | undefined;
readonly terminate = vi.fn();
terminations = 0;

constructor(url: string | URL, options?: WorkerOptions) {
this.url = url;
this.options = options;
}

terminate(): void {
this.terminations += 1;
}
}

function hostWithOwnConstructor(): WorkerConstructorHost {
return { Worker: FakeWorker as unknown as typeof Worker };
}

describe("OwnedWorkerRegistry", () => {
afterEach(() => {
vi.useRealTimers();
});

it("tracks dependency-created Workers and restores the exact constructor", () => {
const host = hostWithOwnConstructor();
const original = host.Worker;
Expand All @@ -33,8 +41,8 @@ describe("OwnedWorkerRegistry", () => {
registry.terminateAll();

expect(host.Worker).toBe(original);
expect(first.terminate).toHaveBeenCalledOnce();
expect(second.terminate).toHaveBeenCalledOnce();
expect(first.terminations).toBe(1);
expect(second.terminations).toBe(1);
expect(registry.size).toBe(0);
});

Expand All @@ -51,6 +59,21 @@ describe("OwnedWorkerRegistry", () => {
expect(host.Worker).toBe(prototype.Worker);
});

it("wraps the constructor that is current when it is installed", () => {
const host = hostWithOwnConstructor();
const registry = new OwnedWorkerRegistry(host);
const replaced = class extends FakeWorker {} as unknown as typeof Worker;
host.Worker = replaced;

registry.install();
const worker = new host.Worker("late.js");
registry.terminateAll();

expect(worker).toBeInstanceOf(replaced);
expect((worker as unknown as FakeWorker).terminations).toBe(1);
expect(host.Worker).toBe(replaced);
});

it("fails closed when another owner replaces the constructor", () => {
const host = hostWithOwnConstructor();
const registry = new OwnedWorkerRegistry(host);
Expand All @@ -61,4 +84,71 @@ describe("OwnedWorkerRegistry", () => {
"The Worker constructor changed while nested Worker ownership was active.",
);
});

it("defers the dependency's terminations until its operations end", async () => {
const host = hostWithOwnConstructor();
const registry = new OwnedWorkerRegistry(host, { teardownQuietMs: 0 });
registry.install();
let release!: () => void;
const operation = registry.run(() => new Promise<void>((resolve) => { release = resolve; }));
await Promise.resolve();
const thread = new host.Worker("thread.js") as unknown as FakeWorker;

thread.terminate();
expect(thread.terminations).toBe(0);
expect(registry.size).toBe(1);

release();
await operation;
expect(thread.terminations).toBe(1);
expect(registry.size).toBe(0);
thread.terminate();
expect(thread.terminations).toBe(1);
});

it("terminates at once outside operations and waits for the teardown before the next one", async () => {
vi.useFakeTimers();
const host = hostWithOwnConstructor();
const registry = new OwnedWorkerRegistry(host, { teardownQuietMs: 250 });
registry.install();
const thread = new host.Worker("thread.js") as unknown as FakeWorker;
thread.terminate();
expect(thread.terminations).toBe(1);

let started = false;
const operation = registry.run(async () => { started = true; });
await vi.advanceTimersByTimeAsync(249);
expect(started).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await operation;
expect(started).toBe(true);
});

it("leaves Workers it does not own untouched", async () => {
const host = hostWithOwnConstructor();
const registry = new OwnedWorkerRegistry(host, { owns: (url) => url === "thread.js", teardownQuietMs: 0 });
registry.install();
let stage!: FakeWorker;
await registry.run(async () => {
stage = new host.Worker("stage.js") as unknown as FakeWorker;
stage.terminate();
expect(stage.terminations).toBe(1);
});
expect(registry.size).toBe(0);
expect(Object.hasOwn(stage, "terminate")).toBe(false);
});

it("terminates deferred Workers immediately when ownership ends", async () => {
const host = hostWithOwnConstructor();
const registry = new OwnedWorkerRegistry(host, { teardownQuietMs: 0 });
registry.install();
let thread!: FakeWorker;
await registry.run(async () => {
thread = new host.Worker("thread.js") as unknown as FakeWorker;
thread.terminate();
registry.terminateAll();
expect(thread.terminations).toBe(1);
});
expect(thread.terminations).toBe(1);
});
});
97 changes: 77 additions & 20 deletions src/runtime/owned-worker-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,53 @@ export interface WorkerConstructorHost {
Worker: typeof Worker;
}

export interface OwnedWorkerRegistryOptions {
/** Owns only Workers created with a script URL this accepts; others are left alone. */
owns?: (scriptUrl: string) => boolean;
/** How long `run` waits after the last owned Worker was terminated before it starts. */
teardownQuietMs?: number;
}

/**
* Time a terminated Worker's VM takes to be torn down. A shared memory that grows during that
* teardown can crash WebKit (see `run`), so new operations wait this long after the last one.
*/
export const OWNED_WORKER_TEARDOWN_QUIET_MS = 250;

/**
* Gives an owning Worker explicit control over nested Workers created by a
* dependency that does not expose its own shutdown contract.
*/
export class OwnedWorkerRegistry {
private readonly host: WorkerConstructorHost;
private readonly originalWorker: typeof Worker;
private readonly originalDescriptor: PropertyDescriptor | undefined;
private readonly trackedWorker: typeof Worker;
private readonly workers = new Set<Worker>();
private readonly owns: (scriptUrl: string) => boolean;
private originalWorker!: typeof Worker;
private originalDescriptor: PropertyDescriptor | undefined;
private trackedWorker!: typeof Worker;
private readonly workers = new Map<Worker, () => void>();
private readonly deferred = new Set<() => void>();
private readonly teardownQuietMs: number;
private operations = 0;
private lastTeardownAt = Number.NEGATIVE_INFINITY;
private installed = false;

constructor(host: WorkerConstructorHost) {
constructor(host: WorkerConstructorHost, options: OwnedWorkerRegistryOptions = {}) {
this.host = host;
this.originalWorker = host.Worker;
this.originalDescriptor = Object.getOwnPropertyDescriptor(host, "Worker");
const workers = this.workers;
this.teardownQuietMs = options.teardownQuietMs ?? OWNED_WORKER_TEARDOWN_QUIET_MS;
this.owns = options.owns ?? (() => true);
}

install(): void {
if (this.installed) throw new Error("Nested Worker ownership is already installed.");
this.originalWorker = this.host.Worker;
this.originalDescriptor = Object.getOwnPropertyDescriptor(this.host, "Worker");
this.trackedWorker = new Proxy(this.originalWorker, {
construct(target, argumentsList, newTarget) {
construct: (target, argumentsList, newTarget) => {
const worker = Reflect.construct(target, argumentsList, newTarget) as Worker;
workers.add(worker);
if (this.owns(String(argumentsList[0]))) this.own(worker);
return worker;
},
});
}

install(): void {
if (this.installed) throw new Error("Nested Worker ownership is already installed.");
if (this.host.Worker !== this.originalWorker) {
throw new Error("The Worker constructor changed before nested Worker ownership was installed.");
}
Object.defineProperty(this.host, "Worker", {
configurable: true,
enumerable: this.originalDescriptor?.enumerable ?? false,
Expand All @@ -45,6 +61,33 @@ export class OwnedWorkerRegistry {
this.installed = true;
}

/**
* Runs one operation of the dependency. Its own `terminate()` calls on owned Workers are held
* until the last running operation ends, and an operation starts only once the last teardown
* has had `teardownQuietMs` to finish.
*
* The Wasmer SDK terminates a thread Worker whenever a WASIX thread or process ends, and starts
* the next one in a new Worker whose initialization grows the shared `WebAssembly.Memory` that
* all its Workers import; a C compile does this six to nine times. JavaScriptCore crashes the
* page (SIGSEGV in `SharedArrayBufferContents::grow`) when a shared memory grows while a Worker
* whose instance imported it is being torn down, so teardowns are kept away from operations.
*/
async run<T>(operation: () => Promise<T>): Promise<T> {
const wait = this.lastTeardownAt + this.teardownQuietMs - performance.now();
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
this.operations += 1;
try {
return await operation();
} finally {
this.operations -= 1;
if (this.operations === 0) {
const deferred = [...this.deferred];
this.deferred.clear();
for (const terminate of deferred) terminate();
}
}
}

/** Restore the host constructor and synchronously terminate every child. */
terminateAll(): void {
if (!this.installed) {
Expand All @@ -67,12 +110,26 @@ export class OwnedWorkerRegistry {
}
this.installed = false;

const owned = [...this.workers];
this.workers.clear();
for (const worker of owned) worker.terminate();
const owned = [...this.workers.values()];
this.deferred.clear();
for (const terminate of owned) terminate();
}

get size(): number {
return this.workers.size;
}

private own(worker: Worker): void {
const terminate = worker.terminate.bind(worker);
const terminateNow = () => {
if (!this.workers.delete(worker)) return;
terminate();
this.lastTeardownAt = performance.now();
};
this.workers.set(worker, terminateNow);
worker.terminate = () => {
if (this.operations > 0) this.deferred.add(terminateNow);
else terminateNow();
};
}
}
Loading