pcbjam/web/standalone/src/wasm/occ-service.ts
Istvan Matejcsok c421d724b0 findings(E-10..E-22): fix the defects a code review found in the E-1..E-9 work
A review of the group-E fixes found 13 further defects; ten were introduced by
those fixes, two pre-existed and were merely relocated, one is deferred.

Services / transport
  E-10  retireWorker synthesized no bg/exit frame, so sharedspice's s_bgRunning
        mirror stayed latched true after a mid-run worker death: Run stayed
        disabled and the promised fresh-worker restart was unreachable for the
        whole session. Retirement now dispatches a synthetic controlled-exit
        straight to the installed handler (never through dispatchEvt — a
        fabricated frame must not touch the credit ledger). Driving the repro
        exposed two further defects, both fixed here: a replacement worker
        trapped on pre-init engine reads, and the rerun's cm_input_path/circ hit
        that uninitialized engine before KiCad's validate() re-init (the native
        flow assumes a crashed engine survives in-process — true for the dll,
        false for a dead worker). Reads now answer their empty shapes pre-init,
        writes lazy-init, and init is idempotent per worker engine.
  E-19  dispatchEvt acked only AFTER handler(evt) returned, and the sharedspice
        client deliberately rethrows non-trap errors — so each throw leaked one
        unit of the 64-frame credit window until the stream died with a
        misattributed "transport exceeded". The ack moves to a finally in both
        service copies; the throw still propagates (the trap machinery needs it).
  E-20  the oversize-line path promises to transfer the accepted prefix, but
        with the window full that flush only DEFERS, and stopEventStream wiped
        the deferred queue — losing the diagnostics that explain the failure.
        The terminal notice now carries them as pendingEvents; both hosts
        deliver them in order, unacked (the fatal frame is outside the credit
        protocol).
  E-21  the 30s prefetch deadline discarded every model already collected and
        reported nothing. A caller-owned progress sink ships the partials and
        the omission reaches the export report. (Awaiting the aborted collection
        was rejected: an in-flight source fetch is not abortable — E-4's
        original disease.) Plus a serving-candidate memo, so a .wrl ref served
        by its .step fallback stops re-probing the miss on every export.

Scheduler
  E-14  _terminalizeNativeTrap classified by message substring, so any plain JS
        error QUOTING 'Aborted(' or 'out of bounds' permanently bricked a
        healthy instance. Now structural only: instanceof RuntimeError plus a
        duck-typed name check (verified in this build's glue that abort() throws
        a genuine RuntimeError both pre- and post-runtime-init). Module.onAbort
        now latches the gate — the authoritative notification, previously
        ignored.
  E-15  the shim half: _pumpResume gates on terminal (catching wakes already
        queued at latch time) and resolveWait refuses on terminal WITHOUT
        consuming the entry, so a frame stays visibly parked rather than
        resuming inside a trapped module.
  E-16  the E-5 handler read the realm-global scheduler at dispatch instead of
        its installing module's; also frees the per-line buffer on the non-trap
        rethrow path.
  E-11  get_vec trusted the worker's res.length over the transferred arrays.
        Observed death shape: a 4 GiB std::vector threw an unhandled
        std::length_error that exited the editor's main loop. Now clamped, with
        the buffers freed on every failure path.

Guardrails (replacing two deferred refactors: e2e→production-code injection and
collapsing the four copies of the worker-lifecycle machinery)
  E-18  the source contract asserted comment-string counts — rewording failed
        CI while moving a guard outside its #ifdef passed. It now parses the
        #ifdef regions and asserts on code.
        service-stub-parity.ts pins what the four lifecycle copies must share:
        credit-window equality parsed from source, the finally-ack, boot
        deadlines, terminal-notice consumption. The transport numbers are now
        single-sourced from the worker.
        CI actually runs the gates: the web/standalone vitest suites (which had
        NEVER run in CI), the reducer, the source contract and the parity tool —
        with a NON_PLAYWRIGHT_GATES check so deleting a step re-fails the lint.
  E-22  the e2e occ stub's 60s boot watchdog, deleted in a66e109, is restored in
        the ngspice-stub shape with a wedgeNextBoot() repro hook.

Every behavioral fix has red-then-green evidence (the reds were captured first).
E-17 (a stale RUNNING cross-stamping the next run's generation under E-6's
transport deferral) is DEFERRED with its analysis recorded — a real fix needs
run identity on the bg frames.

Test hygiene: the dwell lint now requires the mandated ": <why>" and all 47 bare
markers carry their reason; three export-report dwells became modal-lease polls;
exact-ledger assertions became relative deltas; the dead data-wx-dom-id branch,
an unused fault hook and unused receipt plumbing are gone; abort scans, wx
dialog drivers, the sim harness and the vitest FakeWorker are each one copy now.

Bumps kicad and wxwidgets to their findings-group-e tips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 18:19:16 +02:00

415 lines
16 KiB
TypeScript

import { downloadBytes } from "@/lib/download";
import { collectBoardModelFiles, type BoardModelFile, type CollectProgress } from "./libs/models-bridge";
// The worker-side wrapper as text (vite ?raw): one shared source of truth,
// also injected by the e2e harness stub (tests/kicad/utils/occ-service.ts).
import occWorkerSource from "./occ-worker.js?raw";
import { resolveWasmBase } from "./wasm-assets";
/**
* `globalThis.occService` — the lazy OpenCASCADE 3D service provider.
*
* pcbnew.wasm carries no OCC (docs/features/occ-split/): its two OCC-backed
* paths suspend via EM_ASYNC_JS bridges (wasm/stubs/{exporter_step,oce_plugin}_stub.cpp)
* and land here:
* { kind: "export", board, jobJson, fileName } → STEP/GLB/… export; the
* resulting bytes are delivered straight to the browser download path and
* only { ok, report } goes back to the editor.
* { kind: "loadModel", bytes, ext } → STEP/IGES parse + tessellation; returns
* the SCENEGRAPH serialized in KiCad's binary cache format, which the
* C++ stub rebuilds with S3D::ReadCache.
*
* The occ_service module (own emscripten instance, no suspension backend) boots in a
* dedicated Worker on the FIRST request — a pcbnew session that never exports
* and never views STEP models never fetches it. Same cross-origin worker rules
* as the pthread workers (boot.ts): a same-origin blob wrapper importScripts
* the (possibly CDN) glue; the module's own pthread children reuse the trick
* via mainScriptUrlOrBlob.
*/
interface OccExportRequest {
kind: "export";
board: Uint8Array;
jobJson: string;
fileName: string;
/** Board lib model bodies, prefetched here (R2/IDB) and staged worker-side
* under its MEMFS model root — the export worker has no delivery of its own. */
models?: BoardModelFile[];
}
interface OccLoadModelRequest {
kind: "loadModel";
bytes: Uint8Array;
ext: string;
}
export type OccRequest = OccExportRequest | OccLoadModelRequest;
export interface OccResponse {
ok: boolean;
report?: string;
fileName?: string;
bytes?: Uint8Array;
}
declare global {
// eslint-disable-next-line no-var
var occService: { request(req: OccRequest): Promise<OccResponse> } | undefined;
}
/**
* Assemble the worker blob: a one-line prelude carrying the glue URL, then the
* shared wrapper source (occ-worker.js), which reads `self.OCC_GLUE_URL`.
*/
export function occWorkerBlobParts(glueHref: string): string[] {
return [
`self.OCC_GLUE_URL = ${JSON.stringify(glueHref)};\n`,
occWorkerSource,
];
}
export interface OccServiceWatchdogs {
/** Maximum time to wait for optional board-model prefetch before exporting without it. */
modelPrefetchTimeoutMs?: number;
/** Maximum time from the first request until a new generation announces `ready`. */
bootTimeoutMs?: number;
/** Maximum time for any one request in a ready generation to answer. */
responseTimeoutMs?: number;
}
// These are last-resort failure bounds, not normal scheduling deadlines.
// OCC startup, model parsing, and board export can all be expensive on slow
// devices, so production defaults deliberately leave a large margin.
export const OCC_BOOT_TIMEOUT_MS = 2 * 60_000;
export const OCC_RESPONSE_TIMEOUT_MS = 30 * 60_000;
export const OCC_MODEL_PREFETCH_TIMEOUT_MS = 30_000;
export function installOccService(
log: (msg: string) => void,
watchdogs: OccServiceWatchdogs = {},
): void {
if (globalThis.occService) return;
const modelPrefetchTimeoutMs =
watchdogs.modelPrefetchTimeoutMs ?? OCC_MODEL_PREFETCH_TIMEOUT_MS;
const bootTimeoutMs = watchdogs.bootTimeoutMs ?? OCC_BOOT_TIMEOUT_MS;
const responseTimeoutMs =
watchdogs.responseTimeoutMs ?? OCC_RESPONSE_TIMEOUT_MS;
interface WorkerSlot {
generation: number;
worker?: Worker;
workerUrl?: string;
failed: boolean;
ready: Promise<WorkerSlot>;
bootTimer?: ReturnType<typeof setTimeout>;
rejectBoot?: (reason?: unknown) => void;
removeBootListener?: () => void;
}
interface PendingRequest {
generation: number;
resolve: (res: OccResponse) => void;
timer: ReturnType<typeof setTimeout>;
}
let nextId = 1;
let nextGeneration = 1;
const pending = new Map<number, PendingRequest>();
let workerSlot: WorkerSlot | null = null;
const failPending = (generation: number, report: string): void => {
for (const [id, request] of pending) {
if (request.generation !== generation) continue;
pending.delete(id);
clearTimeout(request.timer);
request.resolve({ ok: false, report });
}
};
const retireWorker = (slot: WorkerSlot, report: string): void => {
if (slot.failed) return;
slot.failed = true;
if (slot.bootTimer !== undefined) {
clearTimeout(slot.bootTimer);
slot.bootTimer = undefined;
}
slot.removeBootListener?.();
slot.removeBootListener = undefined;
failPending(slot.generation, report);
if (workerSlot === slot) workerSlot = null;
try {
slot.worker?.terminate();
} catch {
/* already gone */
}
if (slot.workerUrl) {
try {
URL.revokeObjectURL(slot.workerUrl);
} catch {
/* URL cleanup must not prevent exact wait settlement */
}
slot.workerUrl = undefined;
}
const reject = slot.rejectBoot;
slot.rejectBoot = undefined;
reject?.(new Error(report));
};
const ensureWorker = (): Promise<WorkerSlot> => {
if (!workerSlot) {
const slot = {
generation: nextGeneration++,
failed: false,
} as WorkerSlot;
// Publish the generation before its async boot reaches the first await.
// This also lets every continuation test exact slot ownership directly.
workerSlot = slot;
// The editor is parked for this entire operation, including delivery
// discovery. Start the generation deadline before resolveWasmBase(): a
// hung manifest/CDN lookup must settle the exact wait just like a Worker
// which never announces ready.
const bootDeadline = new Promise<never>((_resolve, reject) => {
slot.rejectBoot = reject;
slot.bootTimer = setTimeout(() => {
if (slot.failed || workerSlot !== slot) return;
const report =
`occ_service boot timed out after ${bootTimeoutMs} ms`;
log(`[occ] ${report} — resetting service`);
retireWorker(slot, report);
}, bootTimeoutMs);
});
const boot = (async () => {
// occ_service is a Bundle (a published delivery artifact), not a Tool —
// resolveWasmBase accepts either and looks the bundle up directly.
const base = await resolveWasmBase("occ_service");
if (slot.failed || workerSlot !== slot) {
throw new Error("occ_service worker retired during delivery resolution");
}
const glue = new URL(`${base}/occ_service.js`, window.location.href).href;
log(`[occ] booting occ_service from ${base}`);
slot.workerUrl = URL.createObjectURL(
new Blob(occWorkerBlobParts(glue), { type: "text/javascript" }),
);
const worker = new Worker(slot.workerUrl);
slot.worker = worker;
// A hard OCC/Wasm fault must complete every exact editor wait which
// depends on this worker. The next request gets a fresh generation;
// callbacks from this retired worker cannot resolve its requests.
worker.onerror = (e) => {
const report = `occ_service crashed: ${e.message || "worker error"}`;
log(`[occ] worker error: ${e.message || "worker error"} — resetting service`);
retireWorker(slot, report);
};
worker.onmessageerror = () => {
const report = "occ_service transport failed: message decode failed";
log("[occ] worker message decode failed — resetting service");
retireWorker(slot, report);
};
worker.onmessage = (e) => {
if (slot.failed || workerSlot !== slot) return;
const { id, res } = e.data ?? {};
if (typeof id !== "number") return;
const request = pending.get(id);
if (request?.generation === slot.generation) {
pending.delete(id);
clearTimeout(request.timer);
request.resolve(res as OccResponse);
}
};
await new Promise<void>((resolve) => {
const onFirst = (e: MessageEvent) => {
if (e.data?.ready) {
if (slot.bootTimer !== undefined) {
clearTimeout(slot.bootTimer);
slot.bootTimer = undefined;
}
slot.removeBootListener?.();
slot.removeBootListener = undefined;
slot.rejectBoot = undefined;
resolve();
} else if (e.data?.bootError) {
const report = `occ_service boot failed: ${String(e.data.bootError)}`;
retireWorker(slot, report);
}
};
worker.addEventListener("message", onFirst);
slot.removeBootListener = () =>
worker.removeEventListener("message", onFirst);
});
if (slot.failed || workerSlot !== slot) {
throw new Error("occ_service worker retired during boot");
}
log("[occ] occ_service ready");
return slot;
})();
slot.ready = Promise.race([boot, bootDeadline]).catch((e) => {
// Do not let a late failure from an old generation clear a replacement
// which a re-entrant caller has already started.
retireWorker(slot, `occ_service unavailable: ${String(e)}`);
throw e;
});
}
return workerSlot.ready;
};
const post = (slot: WorkerSlot, req: OccRequest): Promise<OccResponse> => {
const worker = slot.worker;
if (!worker || slot.failed || workerSlot !== slot) {
return Promise.resolve({ ok: false, report: "occ_service worker is unavailable" });
}
const id = nextId++;
const transfer: Transferable[] =
req.kind === "export"
? [req.board.buffer, ...(req.models ?? []).map((m) => m.bytes.buffer)]
: [req.bytes.buffer];
return new Promise<OccResponse>((resolve) => {
const timer = setTimeout(() => {
if (pending.get(id)?.generation !== slot.generation) return;
const report =
`occ_service response timed out after ${responseTimeoutMs} ms`;
log(`[occ] ${report} — resetting service`);
retireWorker(slot, report);
}, responseTimeoutMs);
pending.set(id, { generation: slot.generation, resolve, timer });
try {
worker.postMessage({ id, req }, transfer);
} catch (error) {
pending.delete(id);
clearTimeout(timer);
resolve({ ok: false, report: `occ_service request failed: ${String(error)}` });
}
});
};
const prefetchBoardModels = async (
board: Uint8Array,
): Promise<{ models: BoardModelFile[]; note?: string }> => {
type Outcome =
| { kind: "ready"; models: BoardModelFile[] }
| { kind: "failed"; error: unknown }
| { kind: "timeout" };
// collectBoardModelFiles keeps its own bounded network parallelism and does
// no editor-native work. The controller owns this exact optional
// collection: a timeout stops it from selecting more models and makes its
// already-started source results inert. The progress sink receives every
// accepted model as it lands — on timeout the partial set still ships
// (an aborted collection can never be awaited: an in-flight source fetch
// is not abortable), and the omission is surfaced in the export report
// instead of silently exporting without models.
const controller = new AbortController();
const progress: CollectProgress = { totalRefs: 0, models: [] };
const collected: Promise<Outcome> = collectBoardModelFiles(
new TextDecoder().decode(board),
6,
controller.signal,
progress,
).then(
(models) => ({ kind: "ready", models }),
(error) => ({ kind: "failed", error }),
);
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<Outcome>((resolve) => {
timer = setTimeout(
() => {
// Settle the timeout outcome before abort rejection can enqueue its
// Promise reaction, so logs and public behavior stay deterministic.
resolve({ kind: "timeout" });
controller.abort(
new DOMException("OCC model prefetch timed out", "TimeoutError"),
);
},
modelPrefetchTimeoutMs,
);
});
const outcome = await Promise.race([collected, deadline]);
if (timer !== undefined) clearTimeout(timer);
if (outcome.kind === "ready") return { models: outcome.models };
if (!controller.signal.aborted) {
controller.abort(
new DOMException("OCC model prefetch retired", "AbortError"),
);
}
if (outcome.kind === "failed") {
const note =
`model prefetch failed — exported without models: ${String(outcome.error)}`;
log(`[occ] ${note}`);
return { models: [], note };
}
const models = [...progress.models];
const note =
`model prefetch timed out after ${modelPrefetchTimeoutMs} ms — ` +
`${progress.totalRefs - models.length} of ${progress.totalRefs} model(s) omitted`;
log(`[occ] ${note}`);
return { models, note };
};
const request = async (req: OccRequest): Promise<OccResponse> => {
let prepared: OccRequest;
let prefetchNote: string | undefined;
if (req.kind === "export") {
// Capture the caller-owned request fields before the first await and
// build a private dispatch object. A late optional prefetch can then
// neither mutate the caller's object nor change an already-sent payload.
const board = req.board;
const jobJson = req.jobJson;
const fileName = req.fileName;
// Ship the board's lib model bodies with the request: the worker's
// EXPORTER_STEP resolves them from its own MEMFS (delivery gap doc:
// docs/features/3d-models/0007). Best-effort — an export without
// models still succeeds, each miss reported by the exporter — but a
// curtailed prefetch is surfaced in the export report (E-21).
const { models, note } = await prefetchBoardModels(board);
prefetchNote = note;
if (models.length)
log(`[occ] shipping ${models.length} board model(s) with the export`);
prepared = { kind: "export", board, jobJson, fileName, models };
} else {
prepared = { kind: "loadModel", bytes: req.bytes, ext: req.ext };
}
let slot: WorkerSlot;
try {
slot = await ensureWorker();
} catch (e) {
return { ok: false, report: `occ_service unavailable: ${e}` };
}
const res = await post(slot, prepared);
if (prepared.kind === "export") {
// Deliver the export straight to the user; the editor gets status only
// (the bytes never enter pcbnew's heap).
if (res.ok && res.bytes?.length) {
// The dialog can hand over an extension-only name (".step" — its
// default filename field is empty in the browser); Chromium mangles a
// bare dotfile download to "step.txt", so give it a real stem while
// keeping the format extension the user picked.
const raw = prepared.fileName || res.fileName || "";
const name = !raw || raw.startsWith(".") ? `export${raw || ".step"}` : raw;
downloadBytes(name, res.bytes);
log(`[occ] export downloaded: ${name} (${res.bytes.length} bytes)`);
}
// A curtailed prefetch reaches the user through the export report
// dialog, not only the console.
const report = prefetchNote
? (res.report ? `${res.report}\n${prefetchNote}` : prefetchNote)
: res.report;
return { ok: res.ok, report, fileName: res.fileName };
}
return res;
};
globalThis.occService = { request };
log("[occ] occ_service provider installed (lazy)");
}