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>
415 lines
16 KiB
TypeScript
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)");
|
|
}
|