From 5191a63fd8911094596188ddb29b8be70a8d1d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Tue, 18 Aug 2026 20:13:25 +0200 Subject: [PATCH] fix: spawn CDN pthread workers via a same-origin blob trampoline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the doc-23 §7 KNOWN GAP that killed the editor wherever the wasm is CDN-served (staging/prod platform): emscripten 6 spawns pthread workers from _scriptName — the glue's absolute CDN URL — and new Worker() is a SecurityError, observed on staging as 'Failed to construct Worker' right after instantiation and an editor that never renders. No runtime hook exists post-mainScriptUrlOrBlob, so wrap window.Worker and redirect EXACTLY the glue-URL construction to the (formerly dormant) pthreadWorkerScript blob that importScripts() the glue — blob workers inherit the page origin, and the CDN already sends the CORP/ACAO the page's COEP requires. ?trace= now reaches pthread realms through the same blob, same-origin included. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu --- web/standalone/src/wasm/boot.test.ts | 91 +++++++++++++++++++++++++++- web/standalone/src/wasm/boot.ts | 65 +++++++++++++++++--- 2 files changed, 147 insertions(+), 9 deletions(-) diff --git a/web/standalone/src/wasm/boot.test.ts b/web/standalone/src/wasm/boot.test.ts index 0e99348..8f8ae25 100644 --- a/web/standalone/src/wasm/boot.test.ts +++ b/web/standalone/src/wasm/boot.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, it } from "vitest"; -import { hasWritableLib } from "./boot"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + hasWritableLib, + installPthreadWorkerRedirect, + pthreadWorkerScript, + resetWorkerRedirectForTest, +} from "./boot"; import type { LibInfo } from "./libs/source"; const lib = (type?: string): LibInfo => ({ id: `id-${type}`, name: `n-${type}`, type }); @@ -22,3 +27,85 @@ describe("hasWritableLib", () => { expect(hasWritableLib([])).toBe(false); }); }); + +/** + * Cross-origin (CDN) pthread spawn fix — doc-23 §7 KNOWN GAP. Emscripten + * spawns pthread workers from the glue's absolute URL; when that URL is the + * CDN, `new Worker(...)` is a SecurityError and the editor dies right after + * instantiation (observed on staging). The redirect substitutes a same-origin + * blob that importScripts() the glue — for EXACTLY that URL, nothing else. + */ +describe("installPthreadWorkerRedirect", () => { + class FakeWorker { + constructor( + public scriptURL: string | URL, + public opts?: WorkerOptions, + ) {} + } + const PAGE = "https://editor.example.test/some/route"; + const CDN = "https://cdn.example.test/wasm/kicad_editor/rev"; + + function stubWindow(): { win: { location: { href: string; origin: string }; Worker: unknown } } { + const win = { + location: { href: PAGE, origin: "https://editor.example.test" }, + Worker: FakeWorker as unknown, + }; + vi.stubGlobal("window", win); + return { win }; + } + + afterEach(() => { + vi.unstubAllGlobals(); + resetWorkerRedirectForTest(); + }); + + it("same-origin base without trace installs nothing", () => { + const { win } = stubWindow(); + installPthreadWorkerRedirect("/wasm/kicad_editor/rev", "kicad_editor"); + expect(win.Worker).toBe(FakeWorker); + }); + + it("cross-origin base: the glue URL is redirected to a same-origin blob; everything else passes through", () => { + const { win } = stubWindow(); + installPthreadWorkerRedirect(CDN, "kicad_editor"); + expect(win.Worker).not.toBe(FakeWorker); + const W = win.Worker as new (u: string | URL, o?: WorkerOptions) => FakeWorker; + + // Exactly what emscripten does: new Worker(_scriptName) with the glue URL. + const pthread = new W(`${CDN}/kicad_editor.js`, { name: "em-pthread" }); + expect(String(pthread.scriptURL).startsWith("blob:")).toBe(true); + expect(pthread.opts).toEqual({ name: "em-pthread" }); + + // Unrelated workers (ngspice/occ services…) are untouched. + const other = new W("/assets/other-worker.js"); + expect(other.scriptURL).toBe("/assets/other-worker.js"); + }); + + it("?trace= rides the blob even same-origin (worker realms need the env)", () => { + const { win } = stubWindow(); + installPthreadWorkerRedirect( + "/wasm/kicad_editor/rev", + "kicad_editor", + "KI_TRACE_SYM_CHOOSER", + ); + const W = win.Worker as new (u: string | URL) => FakeWorker; + const pthread = new W( + "https://editor.example.test/wasm/kicad_editor/rev/kicad_editor.js", + ); + expect(String(pthread.scriptURL).startsWith("blob:")).toBe(true); + }); + + it("the blob trampoline importScripts the absolute glue URL", async () => { + stubWindow(); + const script = pthreadWorkerScript(CDN, "kicad_editor"); + expect(script).toBeInstanceOf(Blob); + const text = await (script as Blob).text(); + expect(text).toBe( + `importScripts(${JSON.stringify(`${CDN}/kicad_editor.js`)});`, + ); + // Same-origin without trace stays a plain relative URL (no blob detour). + expect(pthreadWorkerScript("/wasm/rev", "kicad_editor")).toBe( + "/wasm/rev/kicad_editor.js", + ); + }); +}); diff --git a/web/standalone/src/wasm/boot.ts b/web/standalone/src/wasm/boot.ts index 21a88f2..c6c3721 100644 --- a/web/standalone/src/wasm/boot.ts +++ b/web/standalone/src/wasm/boot.ts @@ -154,12 +154,11 @@ function loadScript(src: string): Promise { } /** - * DORMANT — nothing consumes this today. It built the value once passed as - * `Module.mainScriptUrlOrBlob`, which emscripten 6 ignores: pthread child - * workers spawn from `_scriptName` instead, which is same-origin-only. Kept - * because the cross-origin (CDN) pthread fix — a KNOWN GAP, see - * docs/features/async/23-jspi-runtime.md — will need exactly this plumbing: - * a SAME-ORIGIN `blob:` worker that `importScripts()` the cross-origin glue. + * The pthread worker-script substitute consumed by + * {@link installPthreadWorkerRedirect} (formerly the dormant + * `mainScriptUrlOrBlob` plumbing — emscripten 6 dropped that option, closing + * the doc-23 §7 KNOWN GAP required this instead): a SAME-ORIGIN `blob:` + * worker that `importScripts()` the cross-origin glue. * `new Worker()` is a SecurityError, but a `blob:` URL * inherits the page origin (legal), and a classic worker's `importScripts` MAY * load a cross-origin script when the CDN sends @@ -185,7 +184,7 @@ function isWasmDiagnostic(line: string): boolean { return line.startsWith("[wx-dispatch]") || line.startsWith("[wx-timer]"); } -function pthreadWorkerScript( +export function pthreadWorkerScript( base: string, bundle: Bundle, traceMask?: string | null, @@ -211,6 +210,55 @@ function pthreadWorkerScript( }); } +let workerRedirectInstalled = false; + +/** Test-only: clear the install latch (one boot per page in production). */ +export function resetWorkerRedirectForTest(): void { + workerRedirectInstalled = false; +} + +/** + * Cross-origin (CDN) pthread spawn fix — closes the doc-23 §7 KNOWN GAP that + * broke the editor wherever the wasm is CDN-served (staging/prod platform): + * emscripten 6 spawns every pthread worker from `_scriptName` (the glue's + * absolute URL, captured at script execution) and offers no override, and + * `new Worker()` is a SecurityError — observed as the tool + * dying right after instantiation with zero pthreads spawned. + * + * The runtime gives us no hook, so take the one seam that exists: wrap + * `window.Worker` and redirect EXACTLY the glue-URL construction to the + * same-origin substitute from {@link pthreadWorkerScript}. Everything else + * (ngspice/occ service workers, third-party code) passes through untouched. + * Also the only way `?trace=` reaches pthread realms (the blob seeds + * `__KICAD_TRACE__` before importScripts), so it installs for the trace case + * even same-origin. One boot per page (see `booted`), so never uninstalled. + */ +export function installPthreadWorkerRedirect( + base: string, + bundle: Bundle, + traceMask?: string | null, +): void { + const glueHref = new URL(`${base}/${bundle}.js`, window.location.href).href; + const crossOrigin = new URL(glueHref).origin !== window.location.origin; + if ((!crossOrigin && !traceMask) || workerRedirectInstalled) return; + workerRedirectInstalled = true; + const script = pthreadWorkerScript(base, bundle, traceMask); + const substitute = + typeof script === "string" ? script : URL.createObjectURL(script); + const NativeWorker = window.Worker; + window.Worker = new Proxy(NativeWorker, { + construct(target, args: [string | URL, WorkerOptions?]) { + let href: string; + try { + href = new URL(String(args[0]), window.location.href).href; + } catch { + href = String(args[0]); + } + return new target(href === glueHref ? substitute : args[0], args[1]); + }, + }); +} + /** * Fetch the tool's `.wasm` ourselves so we can report download progress (the big, * slow asset — 175–338 MB). Emscripten otherwise fetches it internally with no @@ -676,6 +724,9 @@ async function doBoot(opts: BootOptions): Promise { // tool aborts at startup with "wxDomCreateControl is not defined". // .js — the tool glue, whose execution captures currentScript.src as // Emscripten's _scriptName. + // BEFORE the glue executes: pthread workers must spawn from a same-origin + // script when `base` is the cross-origin CDN (and carry the trace env). + installPthreadWorkerRedirect(base, bundle, traceMask); await loadScript(`${base}/wx.js`); await loadScript(`${base}/wx-dom.js`); await loadScript(`${base}/${bundle}.js`);