Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/wasm-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,14 @@ jobs:
corepack enable
pnpm install --frozen-lockfile

# The web/standalone vitest suites carry the red/green evidence for the
# findings-E service-layer fixes (worker lifecycle, transport credits,
# admission gate, models prefetch). Node-env, no wasm build needed.
- name: web standalone unit tests (vitest)
if: inputs.run_tests
working-directory: web
run: pnpm --filter @pcbjam/standalone test

# kicad_tools gates (tasks-runner 0001 R2): the corpus lint (fixtures +
# shared-codec round-trips — the wrapInBoardEnvelope-class E3 gate,
# kicad-validity 0001 §5) and the CLI contract the backend job runner
Expand All @@ -401,6 +409,18 @@ jobs:
npm run corpus:lint
npm run tools:contract

# Findings-E gates: the ngspice transport reducer (production worker
# source in a node:vm), the C++/shim source contract, and the
# stub/production parity tripwire. lint-ci-coverage asserts these exact
# invocations stay wired (NON_PLAYWRIGHT_GATES).
- name: findings-E transport reducer + source/parity contracts
if: inputs.run_tests
working-directory: tests
run: |
npm run ngspice:worker-batch
npm run findings-e:contract
npm run findings-e:parity

# Browser binaries keyed on the lockfile (which pins the playwright version).
# On a hit `playwright install` skips the downloads; --with-deps still
# apt-installs its small OS dep set either way.
Expand Down
102 changes: 101 additions & 1 deletion scripts/common/shims/jspi-scheduler.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,13 @@
earlyWaitResolves: 0,

beginWait: function (kind) {
if (this.dead || this.terminal) {
// Refuse to mint a wait an unhealthy instance can never satisfy.
// Callers treat token 0 as "not started" (the C++ bridges bail);
// a stray waitPromise(0) settles immediately and warns.
this._note("beginWaitRefused", kind, 0);
return 0;
}
var token = ++this.waitSeq;
var entry = { kind: kind, resolved: false, resolve: null, promise: null };
entry.promise = new Promise(function (resolve) { entry.resolve = resolve; });
Expand Down Expand Up @@ -241,6 +248,16 @@
resolveWait: function (token, result) {
var entry = this.waits.get(token);
if (!entry || entry.resolved) return false;
if (this.terminal) {
// Resolving would resume the parked frame INSIDE the trapped module
// (the runWaitCompletion invariant, which the bare finishers used to
// bypass). Refuse WITHOUT consuming the entry — the frame stays
// visibly parked in dump() and the ring says why.
this._note("resolveRefused", entry.kind, token);
console.warn("[wx-scheduler] resolveWait(" + token + ", " + entry.kind
+ ") refused: instance is terminal");
return false;
}
entry.resolved = true;
this.waitsResolved++;
var stack = this.waitStacks[entry.kind];
Expand Down Expand Up @@ -272,6 +289,85 @@
},

dead: false,
// --- E-8: admission gate for delayed worker/MEMFS completions -----------
// `terminal` means the wasm instance TRAPPED (WebAssembly.RuntimeError,
// or emscripten's abort — which throws a RuntimeError itself and is also
// latched authoritatively via Module.onAbort → terminalize): the heap may
// be mid-mutation, so no further native work (malloc / heap stores / FS
// writes) may run and no parked frame may be resumed into it. Distinct
// from `dead` (orderly shutdown). One-way.
terminal: false,
canTouchNative: function () { return !this.dead && !this.terminal; },
// Public one-way latch (also wired from boot's Module.onAbort — the
// authoritative abort notification).
terminalize: function (site, e) {
if (this.terminal) return;
this.terminal = true;
this._note("terminal", site, 0);
console.error("[wx-scheduler] instance is terminal (" + site
+ ") — all further native completions are inert: " + (e || ""));
},
_terminalizeNativeTrap: function (site, e) {
// Structural signals only: a genuine engine trap in this same-realm
// prepare/entry IS a WebAssembly.RuntimeError instance; the duck-typed
// name fallback survives realm loss on a relayed error object. The old
// message-substring sniff ('Aborted(', 'index out of bounds', …) only
// added false positives — any plain JS error QUOTING such text bricked
// a healthy instance permanently.
var isTrap = (typeof WebAssembly !== "undefined"
&& WebAssembly.RuntimeError
&& e instanceof WebAssembly.RuntimeError)
|| !!(e && e.name === "RuntimeError");
if (!isTrap) return false;
this.terminalize(site, e);
return true;
},
// The one admission boundary for delayed completions that both touch
// native state and wake a parked waiter (the four worker/MEMFS completion
// sites: OCC export, OCC model, ngspice request, ngspice vector).
// `prepare` runs IMMEDIATELY, never queued — it owns the parked waiter's
// output pointers, and queuing it behind anything can deadlock the very
// frame this completion wakes. Disposition (every drop is loud, never
// silent):
// stale/unknown token -> drop + warn (late frame from a retired
// worker generation)
// dead or terminal instance -> drop + warn, DO NOT resolve — resolving
// resumes the suspended frame INSIDE the
// damaged module
// prepare() traps -> latch terminal, DO NOT resolve
// prepare() throws plain JS -> resolve inertResult (fail the wait
// rather than strand its parked frame in
// a healthy instance)
runWaitCompletion: function (site, token, prepare, inertResult) {
var entry = this.waits.get(token);
if (!entry || entry.resolved) {
console.warn("[wx-scheduler] " + site + ": completion for stale wait "
+ token + " dropped");
this._note("staleCompletion", site, token);
return false;
}
if (!this.canTouchNative()) {
console.warn("[wx-scheduler] " + site + ": completion dropped ("
+ (this.terminal ? "terminal" : "dead") + " instance)");
this._note("inertCompletion", site, token);
return false;
}
var result;
try {
result = prepare();
} catch (e) {
if (this._terminalizeNativeTrap(site, e)) {
this._note("completionTrap", site, token);
return false;
}
console.error("[wx-scheduler] " + site + ": completion failed: " + e);
this._note("completionError", site, token);
this.resolveWait(token, inertResult == null ? 0 : inertResult | 0);
return false;
}
this.resolveWait(token, result | 0);
return true;
},
shutdown: function (why) {
this.dead = true;
// S6 teardown contract: queued-but-
Expand Down Expand Up @@ -471,7 +567,11 @@
},

_pumpResume: function () {
if (this.dead) return;
// `terminal` too: a queued wake must never re-enter a trapped module —
// resuming swaps SP into (and runs wasm on) a heap that may be
// mid-mutation. Freezing the pump on a terminal instance is by design:
// the fatal overlay owns the page from here.
if (this.dead || this.terminal) return;
if (this._windowLive) {
// Self-heal: an activation that suspended RAW (bypassing the shim)
// or completed untracked never ends its window here; without this
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/filedialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,9 @@ test.describe('wxFileDialog Tests', () => {

// Try all three buttons
await clickByLabel(page, 'Open File...');
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: click commit before the next dialog button click
await clickByLabel(page, 'Save File...');
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: click commit before the next dialog button click
await clickByLabel(page, 'Open Multiple...');

await stableShot(page, 'filedialog-05-all-buttons.png', { fullPage: true });
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ test.describe('wxSplitterWindow & wxScrolledWindow Tests', () => {
await page.mouse.down();
await page.mouse.move(sash!.centerX + 100, sash!.centerY, { steps: 5 });
await page.mouse.up();
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell (splitter drag commit before re-reading sash from registry)
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: splitter drag commit before re-reading sash from registry

// Get updated sash position after drag
const sashAfter = await getSplitterSash(page);
Expand All @@ -91,7 +91,7 @@ test.describe('wxSplitterWindow & wxScrolledWindow Tests', () => {
// Scroll left pane (use position left of sash)
await page.mouse.move(sashAfter!.centerX - 100, sashAfter!.centerY);
await page.mouse.wheel(0, 50);
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell (scroll commit between the two pane scrolls)
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: scroll commit between the two pane scrolls

// Scroll right pane (use position right of sash)
await page.mouse.move(sashAfter!.centerX + 100, sashAfter!.centerY);
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ test.describe('wxMenuBar Tests', () => {
for (const label of menuLabels) {
const clicked = await clickMenuBarItem(page, label);
expect(clicked, `Menu "${label}" should be found and clicked`).toBe(true);
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: menu open commit between menu-bar clicks
}

await stableShot(page, 'menu-05-all-menus.png', { fullPage: true });
Expand Down
6 changes: 3 additions & 3 deletions tests/e2e/modal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,9 @@ test.describe('Modal dialog border + drag (pcbjam #22)', () => {
const startX = tbox!.x + tbox!.width / 2;
const startY = tbox!.y + tbox!.height / 2;
await page.mouse.move(startX, startY);
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell (pointer settle before grabbing the title bar)
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell: pointer settle before grabbing the title bar
await page.mouse.down();
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell (press commit before the drag begins)
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: press commit before the drag begins

// Drag in many small steps, sampling the modal canvas immediately after each
// move. Each move calls setWindowRect, which clears the canvas; the dialog's
Expand Down Expand Up @@ -253,7 +253,7 @@ test.describe('Modal dialog border + drag (pcbjam #22)', () => {
const startY = hbox!.y + hbox!.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.waitForTimeout(120); // eslint-disable-line -- documented interaction dwell (press commit before the resize drag begins)
await page.waitForTimeout(120); // eslint-disable-line -- documented interaction dwell: press commit before the resize drag begins

let minOpaque = 1;
let lowFrames = 0;
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/scrollbar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ test.describe('DOM-port scrollbars', () => {
await page.mouse.down();
await page.mouse.move(tx, ty, { steps: 6 });
await page.mouse.up();
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: slider drag commit before the next drag
}

// At least one standalone scrollbar must have reported a non-zero position.
Expand Down
8 changes: 4 additions & 4 deletions tests/e2e/secondary-frame-chrome.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ test.describe('secondary-frame DOM title bar (drag / close)', () => {
const after = await listWindows();
const id = after.find((w) => !before.includes(w));
expect(id, `${buttonLabel} should open a new window`).toBeTruthy();
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell (new-window DOM population settle; no event/registry observable)
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: new-window DOM population settle; no event/registry observable
return id as string;
}

Expand All @@ -61,14 +61,14 @@ test.describe('secondary-frame DOM title bar (drag / close)', () => {
await page.mouse.down();
await page.mouse.move(sx, sy + 90, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(250); // eslint-disable-line -- documented interaction dwell (title-bar drag commit; no event/registry observable)
await page.waitForTimeout(250); // eslint-disable-line -- documented interaction dwell: title-bar drag commit; no event/registry observable
const after = await styleRect(winId);
return !!before && !!after && (Math.abs(after.top - before.top) > 5 || Math.abs(after.left - before.left) > 5);
}

async function closeViaTitlebar(winId: string): Promise<boolean> {
await page.locator(`#${winId} .window-titlebar-close`).click();
await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell (× close / modal EndModal commit; no event/registry observable)
await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell: × close / modal EndModal commit; no event/registry observable
return page.evaluate((wid) => {
const el = document.getElementById(wid);
return !el || getComputedStyle(el).display === 'none';
Expand All @@ -87,7 +87,7 @@ test.describe('secondary-frame DOM title bar (drag / close)', () => {
await page.mouse.down();
await page.mouse.move(sx + 60, sy + 60, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(250); // eslint-disable-line -- documented interaction dwell (se-corner resize drag commit; no event/registry observable)
await page.waitForTimeout(250); // eslint-disable-line -- documented interaction dwell: se-corner resize drag commit; no event/registry observable
const after = await styleRect(winId);
return !!before && !!after
&& (after.width - before.width > 20) && (after.height - before.height > 20);
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/wizard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ test.describe('wxWizard Tests', () => {

// Let the Next page-transition commit before clicking Back (the Back/Next
// buttons persist across pages, so there is no registry delta to poll on).
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: Next page-transition commit; no registry delta to poll

// Click Back using element registry
const backClicked = await clickByLabel(page, 'Back');
Expand Down
6 changes: 3 additions & 3 deletions tests/kicad/3d-viewer-models.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,9 @@ async function loadBoard(page: Page, testLogger: { consoleLogs: string[]; errors

await page.mouse.click(filenameInput.x, filenameInput.y);
// Documented interaction dwells: focus + typed-text registration have no observable signal.
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: focus registration has no observable signal
await page.keyboard.type(pcbFilename);
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: typed-text registration has no observable signal
await page.keyboard.press('Enter');

const result = await waitForBoardLoaded(page, testLogger, 60000);
Expand Down Expand Up @@ -215,7 +215,7 @@ test.describe('3D viewer component models', () => {
SERVED_REF, { timeout: 120000 });
// Let the rest of the model-enumeration ensures flush after the served ref lands —
// the total count isn't known up front, so this is a documented settle interval.
await page.waitForTimeout(3000); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(3000); // eslint-disable-line -- documented interaction dwell: model-enumeration ensures flush; total count unknown up front

// --- bridge assertions (run on CI too) ---------------------------------
const ensures = await page.evaluate(() => window.__modelEnsures ?? []);
Expand Down
6 changes: 3 additions & 3 deletions tests/kicad/3d-viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,13 +269,13 @@ test.describe('3D viewer from pcbnew', () => {
// Let the frame-move op (wx_window_move → wxWindow::Move) fully settle before the
// next interaction: the DOM style.top updates before the wx-side op completes, so
// polling the outcome races the following close click (documented interaction dwell).
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: frame-move op settle; polling races the close click
const afterTop = await styleTop(winId as string);
expect(afterTop, 'dragging the title bar should move the 3D viewer frame').not.toBe(beforeTop);

// Close via the × (wx_window_close → wx Close() → OnCloseWindow).
await page.locator(`#${winId} .window-titlebar-close`).click();
await page.waitForTimeout(600); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(600); // eslint-disable-line -- documented interaction dwell: wx Close() commit before checking the frame is gone
const gone = await page.evaluate((wid) => {
const el = document.getElementById(wid);
return !el || getComputedStyle(el).display === 'none';
Expand Down Expand Up @@ -352,7 +352,7 @@ test.describe('3D viewer from pcbnew', () => {
await page.mouse.up();
// Let the resize op (wx_window_resize → SetSize → relayout + GL canvas resize)
// settle before reading widths (documented interaction dwell).
await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell: resize + GL canvas relayout settle before reading widths

const afterFrame = await frameWidth(winId as string);
const afterGl = await glWidth();
Expand Down
Loading
Loading