diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ddf711..6370270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/library-contract.md b/docs/library-contract.md index 214a5fa..be49d2a 100644 --- a/docs/library-contract.md +++ b/docs/library-contract.md @@ -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 diff --git a/scripts/verify-browser-csp.mjs b/scripts/verify-browser-csp.mjs index 5f9a545..ef16034 100644 --- a/scripts/verify-browser-csp.mjs +++ b/scripts/verify-browser-csp.mjs @@ -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`); @@ -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")}`); diff --git a/src/runtime/compiler.worker.ts b/src/runtime/compiler.worker.ts index 79616bf..805deb4 100644 --- a/src/runtime/compiler.worker.ts +++ b/src/runtime/compiler.worker.ts @@ -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, @@ -70,6 +71,10 @@ let toolchainSources: readonly BrowserToolchainSource[] | undefined; let runtime: Runtime | undefined; let runtimeInitialization: Promise | 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 | undefined; let goStage: PersistentIsolatedStage | undefined; let javaStage: PersistentIsolatedStage | undefined; @@ -237,6 +242,10 @@ async function ensureOuterRuntime(requestId: string): Promise { 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), @@ -316,7 +325,7 @@ scope.addEventListener("message", (event: MessageEvent) => { post({ type: "build-result", requestId: request.requestId, - result: await build(request), + result: await wasmerThreadWorkers.run(() => build(request)), }); break; case "quiesce": diff --git a/src/runtime/owned-worker-registry.test.ts b/src/runtime/owned-worker-registry.test.ts index e5bd166..e8dbc24 100644 --- a/src/runtime/owned-worker-registry.test.ts +++ b/src/runtime/owned-worker-registry.test.ts @@ -1,15 +1,19 @@ -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 { @@ -17,6 +21,10 @@ function hostWithOwnConstructor(): WorkerConstructorHost { } describe("OwnedWorkerRegistry", () => { + afterEach(() => { + vi.useRealTimers(); + }); + it("tracks dependency-created Workers and restores the exact constructor", () => { const host = hostWithOwnConstructor(); const original = host.Worker; @@ -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); }); @@ -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); @@ -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((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); + }); }); diff --git a/src/runtime/owned-worker-registry.ts b/src/runtime/owned-worker-registry.ts index ce8891d..ec9d45f 100644 --- a/src/runtime/owned-worker-registry.ts +++ b/src/runtime/owned-worker-registry.ts @@ -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(); + private readonly owns: (scriptUrl: string) => boolean; + private originalWorker!: typeof Worker; + private originalDescriptor: PropertyDescriptor | undefined; + private trackedWorker!: typeof Worker; + private readonly workers = new Map 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, @@ -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(operation: () => Promise): Promise { + 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) { @@ -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(); + }; + } } diff --git a/src/runtime/runner.worker.ts b/src/runtime/runner.worker.ts index 2c32fde..cbe9472 100644 --- a/src/runtime/runner.worker.ts +++ b/src/runtime/runner.worker.ts @@ -65,6 +65,7 @@ import { createInteractivePipe, interactivePipeCapacity } from "./interactive-pi import type { InteractiveSideMessage, InteractiveSideStart } from "./interactive-side.worker"; import interactiveSideWorkerUrl from "./interactive-side.worker?worker&url"; import wasmerThreadWorkerUrl from "./wasmer-thread.worker?worker&url"; +import { OwnedWorkerRegistry, type WorkerConstructorHost } from "./owned-worker-registry"; import { loadBrowserRuntimeDriverPlugins } from "./browser-runtime-plugin"; import type { BrowserRuntimeDriverPlugin } from "@/src/core/types"; @@ -76,6 +77,10 @@ const packageFileSystems = new Map>>( let sdkRuntime: Runtime | undefined; let sdkRuntimeInitialization: Promise | undefined; let wasmerThreadWorkerBootstrap: ModuleWorkerBootstrap | undefined; +const wasmerThreadWorkers = new OwnedWorkerRegistry(globalThis as unknown as WorkerConstructorHost, { + owns: (scriptUrl) => scriptUrl === wasmerThreadWorkerBootstrap?.url, +}); +let wasmerThreadWorkersInstalled = false; let runtimeDrivers: RuntimeDriverRegistry | undefined; let quickJsBytes: Promise | undefined; let toolchainSources: readonly BrowserToolchainSource[] | undefined; @@ -179,6 +184,10 @@ async function ensurePackageRuntime(): Promise { 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), @@ -597,17 +606,21 @@ scope.addEventListener("message", (event: MessageEvent) => { post({ type: "ready", requestId: request.requestId }); break; case "run": - post({ type: "run-result", requestId: request.requestId, result: await runArtifact(request) }); + post({ + type: "run-result", + requestId: request.requestId, + result: await wasmerThreadWorkers.run(() => runArtifact(request)), + }); break; case "interact": post({ type: "interactive-result", requestId: request.requestId, - result: await interactArtifacts(request), + result: await wasmerThreadWorkers.run(() => interactArtifacts(request)), }); break; case "clear-runtime-cache": - await clearRuntimeCaches(); + await wasmerThreadWorkers.run(() => clearRuntimeCaches()); post({ type: "runtime-cache-cleared", requestId: request.requestId }); break; } diff --git a/src/runtime/rustc-stage.worker.ts b/src/runtime/rustc-stage.worker.ts index 3fe2381..d2bcc25 100644 --- a/src/runtime/rustc-stage.worker.ts +++ b/src/runtime/rustc-stage.worker.ts @@ -34,6 +34,7 @@ const NESTED_WORKER_RELEASE_GRACE_MS = 1_000; let requestTail = Promise.resolve(); let toolchain: Promise | undefined; let toolchainBaseUrl: string | undefined; +const wasmerThreadWorkers = new OwnedWorkerRegistry(globalThis as unknown as WorkerConstructorHost); let ownedWasmerWorkers: OwnedWorkerRegistry | undefined; let wasmerThreadWorkerBootstrap: ModuleWorkerBootstrap | undefined; @@ -164,7 +165,7 @@ async function respond(message: RustcStageRequest): Promise { scope.close(); return; } - const result = await compile(message); + const result = await wasmerThreadWorkers.run(() => compile(message)); const response: RustcStageResponse = { type: "result", result }; const transfer = result.wasm ? [result.wasm.buffer] : []; scope.postMessage(response, transfer); @@ -225,9 +226,8 @@ function loadToolchain(baseUrl: URL): Promise { } async function initializeToolchain(baseUrl: URL): Promise { - const workerRegistry = new OwnedWorkerRegistry(globalThis as unknown as WorkerConstructorHost); - workerRegistry.install(); - ownedWasmerWorkers = workerRegistry; + wasmerThreadWorkers.install(); + ownedWasmerWorkers = wasmerThreadWorkers; const bootstrap = createModuleWorkerBootstrap(new URL(wasmerThreadWorkerUrl, workerBaseUrl)); wasmerThreadWorkerBootstrap = bootstrap; try {