pcbjam/tests/kicad/utils/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

365 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as fs from 'fs';
import * as path from 'path';
import type { Page } from '@playwright/test';
/**
* Install a REAL `globalThis.occService` provider into a harness page — the
* same worker-backed occ_service boot the standalone app does, minus the CDN
* manifest resolution: the harness serves occ_service.{js,wasm} same-origin
* next to the tool page (tests/scripts/setup-kicad-wasm.sh copies them from
* output/).
*
* The worker-side wrapper is the SHARED source of truth
* (web/standalone/src/wasm/occ-worker.js — the standalone imports it via vite
* `?raw`; the harness reads it off disk and injects it verbatim), so the
* trap-prone boot logic (blob worker + locateFile absolutization + pthread
* mainScriptUrlOrBlob) cannot drift between app and tests.
*
* Differences from the app provider, for assertability:
* - export results are captured into window.__occExports (name, size, magic
* prefix, the exporter's report text, and a PRODUCT-entity count for STEP
* bodies — the per-component geometry signal) instead of triggering a
* browser download;
* - the app's export model prefetch (occ-service.ts → models-bridge
* collectBoardModelFiles) is mirrored against the page's `kicadLibs`
* provider: lib refs scanned from the board text are ensured (kind
* "model3d"), read back from the editor MEMFS, and shipped as the
* request's `models` array. Specs without a kicadLibs stub ship none —
* the pre-delivery behavior.
* - installed as an init script (kicad fixtures do this for every page), so
* it exists from document start on every navigation — standalone parity,
* where boot.ts installs the provider whenever the editor bundle boots.
*
* The worker fetches occ_service.js lazily on the FIRST request — specs can
* assert the lazy-load boundary by watching network requests.
*/
const OCC_WORKER_SRC = fs.readFileSync(
path.resolve(__dirname, '..', '..', '..',
'web', 'standalone', 'src', 'wasm', 'occ-worker.js'),
'utf8');
export interface OccHarnessWatchdogs {
bootTimeoutMs?: number;
}
export async function installOccServiceStub(
page: Page,
watchdogs: OccHarnessWatchdogs = {},
): Promise<void> {
const bootTimeoutMs = watchdogs.bootTimeoutMs ?? 2 * 60_000;
if (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1)
throw new Error('bootTimeoutMs must be a positive safe integer');
await page.addInitScript((options: { workerSrc: string; bootTimeoutMs: number }) => {
if ((globalThis as any).occService) return;
const { workerSrc, bootTimeoutMs } = options;
(window as any).__occExports = [];
interface WorkerSlot {
generation: number;
worker?: Worker;
failed: boolean;
ready: Promise<WorkerSlot>;
bootTimer?: ReturnType<typeof setTimeout>;
rejectBoot?: (reason?: unknown) => void;
removeBootListener?: () => void;
/** The exact lifecycle transition used by Worker.onmessageerror. */
failDecode: () => void;
}
interface PendingRequest {
generation: number;
resolve: (res: any) => void;
}
let nextGeneration = 1;
let workerSlot: WorkerSlot | null = null;
const pending = new Map<number, PendingRequest>();
let nextId = 1;
let maxPending = 0;
let requestsStarted = 0;
let requestsPosted = 0;
const workerGenerationsStarted: number[] = [];
let armedFault: { count: number; report: string } | null = null;
/** One-shot: the next boot uses a worker that never answers. */
let wedgeNextBootArmed: { bootTimeoutMs?: number } | null = null;
const retiredGenerations: number[] = [];
const pendingInGeneration = (generation: number): number => {
let count = 0;
for (const request of pending.values()) {
if (request.generation === generation) count++;
}
return count;
};
const failPending = (generation: number, report: string): void => {
for (const [id, request] of pending) {
if (request.generation !== generation) continue;
pending.delete(id);
request.resolve({ ok: false, report });
}
};
const retireWorker = (slot: WorkerSlot, report: string): void => {
if (slot.failed) return;
slot.failed = true;
retiredGenerations.push(slot.generation);
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 */
}
const reject = slot.rejectBoot;
slot.rejectBoot = undefined;
reject?.(new Error(report));
};
const maybeTriggerArmedFault = (slot: WorkerSlot): void => {
if (!armedFault || slot.failed) return;
if (pendingInGeneration(slot.generation) < armedFault.count) return;
const { report } = armedFault;
armedFault = null;
console.log(`[TEST-OCC] faulting generation ${slot.generation} (messageerror): ${report}`);
if (slot.worker) {
// Synthetic dispatch on Worker is engine-dependent. Invoke
// the exact transition installed as the real event handler.
slot.failDecode();
} else {
retireWorker(slot, report);
}
};
const ensureWorker = (): Promise<WorkerSlot> => {
if (!workerSlot) {
const slot = {
generation: nextGeneration++,
failed: false,
} as WorkerSlot;
workerGenerationsStarted.push(slot.generation);
workerSlot = slot;
const wedge = wedgeNextBootArmed;
wedgeNextBootArmed = null;
const bootDeadlineMs = wedge?.bootTimeoutMs ?? bootTimeoutMs;
// Legible boot (E-22): a worker DEATH shape that never posts
// ready OR bootError (importScripts hang, pthread spawn
// wedge, OOM-kill) used to hang every request until the
// spec's timeout with zero evidence. Bound the boot — same
// shape as the ngspice stub and the production service.
const bootDeadline = new Promise<never>((_resolve, reject) => {
slot.rejectBoot = reject;
slot.bootTimer = setTimeout(() => {
if (slot.failed || workerSlot !== slot) return;
const why = `occ_service boot timed out after ${bootDeadlineMs} ms`;
console.log(`[TEST-OCC] ${why} — resetting service`);
retireWorker(slot, why);
}, bootDeadlineMs);
});
const boot = (async () => {
const glue = new URL('occ_service.js', window.location.href).href;
console.log(`[TEST-OCC] booting occ_service from ${glue}`);
// A wedged boot is a REAL silent Worker (an empty module:
// it boots, runs nothing, never posts ready/bootError) —
// the importScripts-hang / pthread-wedge shape, engine
// independent.
const worker = wedge
? new Worker('data:text/javascript,/* [TEST-OCC] wedged boot */')
: new Worker(URL.createObjectURL(new Blob(
[`self.OCC_GLUE_URL = ${JSON.stringify(glue)};\n`, workerSrc],
{ type: 'text/javascript' })));
slot.worker = worker;
// All fatal transitions settle the boot through the ONE
// retirement funnel (which clears the deadline, removes
// the boot listener, and rejects the raced promise).
worker.onerror = (e) => {
const report = `occ_service crashed: ${e.message || 'worker error'}`;
console.error(`[TEST-OCC] ${report}; resetting service`);
retireWorker(slot, report);
};
slot.failDecode = () => {
const report = 'occ_service transport failed: message decode failed';
console.error(`[TEST-OCC] ${report}; resetting service`);
retireWorker(slot, report);
};
worker.onmessageerror = slot.failDecode;
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);
request.resolve(res);
}
};
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) {
retireWorker(slot,
`occ_service boot failed: ${String(e.data.bootError)}`);
}
};
worker.addEventListener('message', onFirst);
slot.removeBootListener = () => worker.removeEventListener('message', onFirst);
});
if (slot.failed || workerSlot !== slot)
throw new Error('occ_service worker retired during boot');
console.log('[TEST-OCC] occ_service ready');
return slot;
})();
slot.ready = Promise.race([boot, bootDeadline]).catch((e) => {
// A late rejection from a retired generation cannot clear
// the replacement slot created by a new request.
retireWorker(slot, `occ_service unavailable: ${String(e)}`);
throw e;
});
}
return workerSlot.ready;
};
// Mirror of the app's collectBoardModelFiles, against the page's
// kicadLibs provider (the specs' model stub): scan lib refs, ensure
// each into the editor MEMFS, read the staged bytes back.
const collectModels = async (boardText: string) => {
const hook = (globalThis as any).kicadLibs;
const FS = (window as any).FS;
if (!hook?.request || !FS) return [];
const ROOT = '/pcbjam/3dmodels';
const refs = new Set<string>();
const re = /\(\s*model\s+"((?:[^"\\]|\\.)*)"/g;
for (let m = re.exec(boardText); m; m = re.exec(boardText)) {
const raw = m[1].replace(/\\(.)/g, '$1');
const lib = raw.match(/^\$[{(](?:[^})]*3DMODEL_DIR|KISYS3DMOD)[})][/\\]+(.+)$/);
if (lib) refs.add(lib[1]);
}
const models: Array<{ path: string; bytes: Uint8Array }> = [];
const seen = new Set<string>();
for (const ref of refs) {
const abs = await hook.request('ensure', '', ref, 'model3d');
if (typeof abs !== 'string' || !abs.startsWith(`${ROOT}/`) || seen.has(abs)) continue;
seen.add(abs);
models.push({ path: abs.slice(ROOT.length + 1), bytes: FS.readFile(abs) });
}
console.log(`[TEST-OCC] shipping ${models.length} board model(s) with the export`);
return models;
};
const request = async (req: any) => {
// Count provider entry before model collection or worker boot. This
// distinguishes "the wx button reached OCC" from a worker that was
// already active for some earlier request.
requestsStarted++;
if (req.kind === 'export')
req.models = await collectModels(new TextDecoder().decode(req.board));
let slot: WorkerSlot;
try {
slot = await ensureWorker();
} catch (e) {
return { ok: false, report: `occ_service unavailable: ${e}` };
}
const worker = slot.worker;
if (!worker || slot.failed || workerSlot !== slot)
return { ok: false, report: 'occ_service worker is unavailable' };
const id = nextId++;
const transfer = req.kind === 'export'
? [req.board.buffer, ...(req.models ?? []).map((m: any) => m.bytes.buffer)]
: [req.bytes.buffer];
const res: any = await new Promise((resolve) => {
pending.set(id, { generation: slot.generation, resolve });
maxPending = Math.max(maxPending, pendingInGeneration(slot.generation));
try {
worker.postMessage({ id, req }, transfer);
requestsPosted++;
maybeTriggerArmedFault(slot);
} catch (error) {
pending.delete(id);
resolve({ ok: false, report: `occ_service request failed: ${String(error)}` });
}
});
if (req.kind === 'export') {
if (res.ok && res.bytes?.length) {
const magic = new TextDecoder().decode(res.bytes.slice(0, 16));
// STEP is a text format: `#n=PRODUCT('name',…)` entities count the
// distinct model bodies in the assembly (a bare board exports 12;
// component models add one each). The anchored `=PRODUCT(` match
// excludes PRODUCT_DEFINITION/PRODUCT_CONTEXT relatives.
let productCount = -1;
if (magic.startsWith('ISO-10303-21')) {
const text = new TextDecoder().decode(res.bytes);
productCount = (text.match(/=\s*PRODUCT\s*\(/g) ?? []).length;
}
(window as any).__occExports.push({
name: req.fileName || res.fileName,
size: res.bytes.length,
magic,
report: String(res.report ?? ''),
productCount,
});
console.log(`[TEST-OCC] export captured: ${req.fileName} ${res.bytes.length}B "${magic}" products=${productCount}`);
}
return { ok: res.ok, report: res.report, fileName: res.fileName };
}
return res;
};
(globalThis as any).__occServiceTestHooks = {
/** One-shot: wedge the next boot (silent worker, no ready and no
* bootError), optionally shortening that boot's deadline. */
wedgeNextBoot(bootTimeoutMs?: number) {
if (bootTimeoutMs !== undefined
&& (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1))
throw new Error('bootTimeoutMs must be a positive safe integer');
wedgeNextBootArmed = { bootTimeoutMs };
},
/** Arm the real Worker's production-parity messageerror handler. */
messageErrorWhenPendingAtLeast(count: number) {
if (!Number.isSafeInteger(count) || count < 1)
throw new Error('pending threshold must be a positive safe integer');
armedFault = {
count,
report: 'occ_service transport failed: message decode failed',
};
if (workerSlot) maybeTriggerArmedFault(workerSlot);
},
snapshot() {
return {
activeGeneration: workerSlot?.generation ?? null,
pending: pending.size,
maxPending,
requestsStarted,
requestsPosted,
workerGenerationsStarted: [...workerGenerationsStarted],
retiredGenerations: [...retiredGenerations],
armed: armedFault !== null,
};
},
};
(globalThis as any).occService = { request };
}, { workerSrc: OCC_WORKER_SRC, bootTimeoutMs });
}