mailbox S0: dual-glue flag, beacon counters, N2 red spec

Doc 17 step S0 scaffolding: WX_SCHEDULER=1 injector path with an
observation-only asyncify-scheduler.js skeleton (legacy shim stays
authoritative until S2), guard-beacon extraction with occurrence
recovery for rate-limited beacons, and the fixme'd N2 ordering spec
(add-then-move probe; un-fixme at S1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TfxKn5utcntBSnxz4ZnYKs
This commit is contained in:
Gergő Törcsvári 2026-08-05 13:43:23 +02:00
commit 94ae4a8a41
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
5 changed files with 354 additions and 1 deletions

View file

@ -127,6 +127,17 @@ rollback = the `WX_SCHEDULER=0` build + a per-step tag.
- **S0 · Baseline + scaffolding (23 d).** Tag current mains. CI matrix runs both EH models. - **S0 · Baseline + scaffolding (23 d).** Tag current mains. CI matrix runs both EH models.
Add beacon-count extraction to the fuzz/e2e harness (guard-firing counters per run). Stand up Add beacon-count extraction to the fuzz/e2e harness (guard-firing counters per run). Stand up
the `WX_SCHEDULER` dual-glue build. Write N2 (red), N8. the `WX_SCHEDULER` dual-glue build. Write N2 (red), N8.
> **Work log 2026-08-05 — S0 mostly landed** on `feature/async-mailbox`:
> local `mailbox-s0-baseline` tags in all 6 repos; `WX_SCHEDULER=1` injector path +
> `scripts/common/shims/asyncify-scheduler.js` (observation-only skeleton, build marker
> `[wx-scheduler] scaffolding installed`; legacy shim stays authoritative until S2;
> remember the `.ci-cache-epoch` bump when shim behavior changes);
> `tests/kicad/utils/guard-beacons.ts` (per-family counts, occurrence-recovery for
> rate-limited beacons, `expectGuardsSilent`, `parseAsyncifyCounters`);
> `REPRO_ASSERT=1` in `apps/tests/tools/repro-board-load.ts` (N8: fails on unsettled load,
> missing recorder, `rootHotTotal>0`, trap signatures);
> `tests/kicad/mailbox-ordering.spec.ts` (N2, `test.fixme` red — add-then-move ordering
> probe; un-fixme at S1). **Still open in S0:** the CI workflow matrix for both EH models.
- **S1 · Mailbox front-end (≈1 wk).** One queue, drained by `wxWasmTopLevelTick`. Route into it: - **S1 · Mailbox front-end (≈1 wk).** One queue, drained by `wxWasmTopLevelTick`. Route into it:
timer `Notify` (replacing the direct callback body; the 17 ms retry stays as tripwire), DOM timer `Notify` (replacing the direct callback body; the 17 ms retry stays as tripwire), DOM
input (formalizing today's `wxPostEvent`/`CallAfter` deferrals), and a JS-side wrapper for input (formalizing today's `wxPostEvent`/`CallAfter` deferrals), and a JS-side wrapper for

View file

@ -5,6 +5,8 @@
# The actual JavaScript that gets injected lives in readable, standalone files in # The actual JavaScript that gets injected lives in readable, standalone files in
# scripts/common/shims/ (not inline heredocs): # scripts/common/shims/ (not inline heredocs):
# - handlesleep.js nested-Asyncify handleSleep currData save/restore (#9153) # - handlesleep.js nested-Asyncify handleSleep currData save/restore (#9153)
# - asyncify-scheduler.js WX_SCHEDULER=1 builds only: the mailbox/scheduler
# (docs/features/async/17; S0 = observation-only skeleton)
# - diagnostics.js optional logging-only instrumentation (see SHIM_DIAGNOSTICS) # - diagnostics.js optional logging-only instrumentation (see SHIM_DIAGNOSTICS)
# #
# Native wasm-EH is the only build mode, so the .js has no invoke_* wrappers / dynCall_<sig> call # Native wasm-EH is the only build mode, so the .js has no invoke_* wrappers / dynCall_<sig> call
@ -17,6 +19,7 @@
# Usage: # Usage:
# inject-dyncall-shims.sh <pcbnew.js> # inject-dyncall-shims.sh <pcbnew.js>
# SHIM_DIAGNOSTICS=1 inject-dyncall-shims.sh <pcbnew.js> # also inject diagnostics.js # SHIM_DIAGNOSTICS=1 inject-dyncall-shims.sh <pcbnew.js> # also inject diagnostics.js
# WX_SCHEDULER=1 inject-dyncall-shims.sh <pcbnew.js> # also inject the scheduler (dual-glue variant)
set -e set -e
@ -32,7 +35,7 @@ if [ -z "$JS_FILE" ] || [ ! -f "$JS_FILE" ]; then
echo "Usage: $0 <path/to/pcbnew.js>" echo "Usage: $0 <path/to/pcbnew.js>"
exit 1 exit 1
fi fi
for f in handlesleep.js diagnostics.js; do for f in handlesleep.js asyncify-scheduler.js diagnostics.js; do
if [ ! -f "$SHIM_DIR/$f" ]; then if [ ! -f "$SHIM_DIR/$f" ]; then
echo "Error: missing shim source $SHIM_DIR/$f" echo "Error: missing shim source $SHIM_DIR/$f"
exit 1 exit 1
@ -99,6 +102,24 @@ else
fi fi
fi fi
# --- 3e. Mailbox/scheduler (WX_SCHEDULER=1 dual-glue variant) ------------------
# docs/features/async/17-mailbox-scheduler-plan.md, step S0. Appended AFTER the
# handleSleep shim: the legacy shim stays authoritative until S2, when the
# scheduler takes ownership of currData and this ordering flips. Idempotent via
# the __wxSchedulerInstalled marker. Default OFF — the legacy build is the
# shippable fallback until S5.
if [ "${WX_SCHEDULER:-0}" = "1" ]; then
if grep -q '__wxSchedulerInstalled' "$JS_FILE"; then
echo "asyncify-scheduler already present - skipping"
else
echo "" >> "$JS_FILE"
cat "$SHIM_DIR/asyncify-scheduler.js" >> "$JS_FILE"
echo "Injected asyncify-scheduler (WX_SCHEDULER=1 dual-glue variant)"
fi
else
echo "scheduler disabled (set WX_SCHEDULER=1 for the dual-glue variant)"
fi
# --- 3b. embind dynCall fallback (dynCallLegacy -> wasmExports) ---------------- # --- 3b. embind dynCall fallback (dynCallLegacy -> wasmExports) ----------------
# embind's generic caller (getDynCaller) routes through dynCallLegacy, which only # embind's generic caller (getDynCaller) routes through dynCallLegacy, which only
# reads Module["dynCall_<sig>"]. But the DYNCALLS=1 trampolines are wasm EXPORTS, # reads Module["dynCall_<sig>"]. But the DYNCALLS=1 trampolines are wasm EXPORTS,

View file

@ -0,0 +1,42 @@
// === AsyncifyScheduler (S0 scaffolding — observation-only) ===
// docs/features/async/17-mailbox-scheduler-plan.md · injected only on WX_SCHEDULER=1 builds.
//
// S0 contract: this file changes NO runtime behavior. It claims the namespace, the
// registry data structures, and the build marker so (a) the dual-glue build variant
// exists and can run the full suite, and (b) tests can detect which runtime they're on.
// S2 turns this into the sole owner of Asyncify.currData/state (doc 13 §1): the four
// hooks (handleSleep wrap, fiber-swap tracking, trampoline ownership, deferred drain)
// land there, gated on the N1 single-writer tripwire. Until then the legacy
// handlesleep.js shim (injected just above) stays authoritative.
if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
globalThis.__wxSchedulerInstalled = true;
Asyncify.__schedulerBuild = 1;
var AsyncifyScheduler = {
// ctx = { id, kind: 'main'|'modal'|'nested'|'coroutine'|'sleep',
// buffer, status: 'running'|'parked'|'ready', wakeReason, result }
contexts: new Map(),
readyQueue: [],
running: null,
transitionRunning: false,
trampolineRunning: false,
// S2 fills these in. They throw today so a premature caller is loud, not silent —
// nothing in an S0 build calls them.
park: function () { throw new Error("[wx-scheduler] park(): not implemented until S2"); },
resume: function () { throw new Error("[wx-scheduler] resume(): not implemented until S2"); },
drain: function () { throw new Error("[wx-scheduler] drain(): not implemented until S2"); },
state: function () {
return "[wx-scheduler] build=1 impl=S0-observation-only"
+ " contexts=" + this.contexts.size
+ " ready=" + this.readyQueue.length
+ " running=" + this.running
+ " transition=" + this.transitionRunning;
},
};
globalThis.__wxScheduler = AsyncifyScheduler;
console.log("[wx-scheduler] scaffolding installed (S0, observation-only)");
}
// === End AsyncifyScheduler ===

View file

@ -0,0 +1,160 @@
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
/**
* N2 message ordering under a parked open (RED, target semantics).
* docs/features/async/17-mailbox-scheduler-plan.md §3d N2, §3b.
*
* Today (open_gate, doc 14): collab entries issued while `kicadOpenFile` is
* asyncify-parked are DROPPED collab-load-fuzz.spec.ts asserts exactly that
* contract, and it is correct for the guard architecture.
*
* The mailbox flips dropdeliver: a mutating entry issued during the open
* becomes a queued message, applied IN ORDER after the open completes. This
* spec asserts those target semantics, so it is RED by design until:
* - S1 (JS wrapper enqueues mutating embind entries) makes basic delivery
* work, and
* - S4 (open runs on a scheduler fiber) removes the gate entirely.
* Un-fixme at S1; collab-load-fuzz's "entries no-op" assertions retire at S4
* (they flip per doc 17 §3b).
*
* Ordering probe: apply A ADDS a segment, apply B MOVES that same segment.
* B can only land if A landed first the single final-position check proves
* both delivery and order (drop-A-deliver-B leaves B targetless).
*/
const NEW_SEG = "fa2c0000-0000-0000-0000-00000000beef";
const B_TARGET = "60000000,60000000"; // where apply B moves A's segment (IU)
function smallBoard(): string {
return `(kicad_pcb
\t(version 20241229)
\t(generator "pcbnew")
\t(generator_version "9.0")
\t(general (thickness 1.6))
\t(paper "A4")
\t(layers
\t\t(0 "F.Cu" signal)
\t\t(2 "B.Cu" signal)
\t\t(25 "Edge.Cuts" user)
\t)
\t(setup)
\t(net 0 "")
\t(segment (start 10 10) (end 15 10) (width 0.2) (layer "F.Cu") (net 0) (uuid "fa2b0000-0000-0000-0000-000000000001"))
)`;
}
type FS = { mkdirTree(p: string): void; writeFile(p: string, d: string): void };
type Mod = {
kicadOpenFile(p: string): unknown;
kicadOpenFileBusy(): boolean;
kicadTestSetOpenPark(ms: number): void;
kicadCollabApplyItems(j: string): unknown;
kicadCollabGetPos(id: string): string;
};
async function bootHarness(page: Page): Promise<void> {
await page.goto("/kicad/pcbnew-collab.html");
await expect(page.locator("#canvas")).toBeVisible({ timeout: 90000 });
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
await page.waitForFunction(
() => {
const m = (window as unknown as { Module?: Partial<Mod> }).Module;
return (
typeof m?.kicadOpenFile === "function" &&
typeof m?.kicadCollabApplyItems === "function" &&
typeof m?.kicadTestSetOpenPark === "function"
);
},
null,
{ timeout: 90000 },
);
await page.waitForFunction(
() =>
!!window.wxElementRegistry &&
window.wxElementRegistry
.findAll({ visible: true })
.some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")),
null,
{ timeout: 90000 },
);
}
test.describe("mailbox N2: entries during a parked open are delivered in order", () => {
// RED until doc 17 S1 — the open gate currently DROPS both applies.
test.fixme("apply A (add) then apply B (move) mid-park land in order after settle", async ({
page,
testLogger,
}) => {
test.setTimeout(180000);
void testLogger;
await bootHarness(page);
const issued = await page.evaluate(async ({ newSeg, board }) => {
const w = window as unknown as { FS: FS; Module: Mod };
const dir = "/home/kicad/documents";
try {
w.FS.mkdirTree(dir);
} catch {
/* exists */
}
const path = `${dir}/n2.kicad_pcb`;
w.FS.writeFile(path, board);
// Deterministic park window on entry AND post-load (open_gate.h test lever)
w.Module.kicadTestSetOpenPark(1500);
w.Module.kicadOpenFile(path);
// Wait until the busy window is observably open, then issue A and B once.
const t0 = performance.now();
while (!w.Module.kicadOpenFileBusy() && performance.now() - t0 < 30000) {
await new Promise((r) => setTimeout(r, 5));
}
if (!w.Module.kicadOpenFileBusy()) return { inWindow: false };
const applyA = JSON.stringify({
added: [
{
sexpr: `(segment (start 50 50) (end 55 50) (width 0.2) (layer "F.Cu") (net 0) (uuid "${newSeg}"))`,
parent: null,
},
],
changed: [],
removed: [],
});
const applyB = JSON.stringify({
added: [],
changed: [
{
sexpr: `(segment (start 60 60) (end 65 60) (width 0.2) (layer "F.Cu") (net 0) (uuid "${newSeg}"))`,
parent: null,
},
],
removed: [],
});
w.Module.kicadCollabApplyItems(applyA);
w.Module.kicadCollabApplyItems(applyB);
// Wait for the open chain to settle.
const t1 = performance.now();
while (w.Module.kicadOpenFileBusy() && performance.now() - t1 < 120000) {
await new Promise((r) => setTimeout(r, 50));
}
w.Module.kicadTestSetOpenPark(0);
return { inWindow: true, settled: !w.Module.kicadOpenFileBusy() };
}, { newSeg: NEW_SEG, board: smallBoard() });
expect(issued.inWindow, "the busy window was observed").toBe(true);
expect(issued.settled, "the open settled").toBe(true);
// TARGET SEMANTICS (mailbox): both queued applies were delivered, in order —
// A's segment exists and sits where B moved it. Under the open gate both
// are dropped and GetPos finds nothing: deterministic red.
await expect
.poll(
() =>
page.evaluate((id) => (window.Module as unknown as Mod).kicadCollabGetPos(id), NEW_SEG),
{ timeout: 10000, intervals: [200] },
)
.toBe(B_TARGET);
});
});

View file

@ -0,0 +1,119 @@
// Guard-beacon extraction for the mailbox/scheduler migration
// (docs/features/async/17-mailbox-scheduler-plan.md, step S0.3).
//
// Every legacy anti-collision guard announces itself on the console when it fires.
// During the migration each superseded guard is kept as a TRIPWIRE: the mailbox is
// only trusted once the guard it replaces is provably silent across the suite.
// This module turns a TestLogger's consoleLogs into per-family counts so specs can
// assert `expectGuardsSilent(...)` at the step that claims a family.
//
// Rate-limiting caveat: [wx-asyncify] and [collab-fcontext] beacons print the first
// 10 occurrences, then every 100th, embedding "(occurrence N)". `linesSeen` is what
// reached the console; `estimatedTotal` recovers the true count from the highest
// occurrence number when present (else it equals linesSeen). Assertions on SILENCE
// are exact either way: zero fires = zero lines.
export interface BeaconFamilyCount {
linesSeen: number;
estimatedTotal: number;
samples: string[]; // first few matching lines, for the failure message
}
export interface GuardBeaconCounts {
// wx timer interlock (timer.cpp): parked-dispatch retries
timerRetry: BeaconFamilyCount;
// wx dispatch interlock bookkeeping anomalies (evtloop.cpp)
dispatchAnomaly: BeaconFamilyCount;
// handlesleep.js shim: nested-park / wake-aliasing / stale-fiber refusals
wxAsyncify: BeaconFamilyCount;
// libcontext swap-layer refusals + hot-main beacons ([collab-fcontext])
libcontext: BeaconFamilyCount;
// open-settle gate giving up (open-flow.ts)
openSettleFailed: BeaconFamilyCount;
// scheduler build marker — identifies the dual-glue variant, not a guard
schedulerBuild: boolean;
}
const FAMILY_PATTERNS: Record<
Exclude<keyof GuardBeaconCounts, 'schedulerBuild'>,
RegExp
> = {
timerRetry: /\[wx-timer\] retry storm/,
dispatchAnomaly: /\[wx-dispatch\] (ERASED|NEGATIVE)/,
wxAsyncify:
/\[wx-asyncify\] (concurrent-park|reentrant-state|aliased-wake-live|overlapped-wake|fiber-resume-refused)/,
libcontext:
/\[collab-fcontext\] (jump-refused|jump-refused-hot-main|hot-main-swap-out|jump-hot-into-main|jump-ghost|entry-orphaned)/,
openSettleFailed: /\[open\] load chain never settled/,
};
const OCCURRENCE_RE = /\(occurrence (\d+)\)/;
const SAMPLE_LIMIT = 3;
function emptyFamily(): BeaconFamilyCount {
return { linesSeen: 0, estimatedTotal: 0, samples: [] };
}
export function countGuardBeacons(consoleLines: string[]): GuardBeaconCounts {
const counts: GuardBeaconCounts = {
timerRetry: emptyFamily(),
dispatchAnomaly: emptyFamily(),
wxAsyncify: emptyFamily(),
libcontext: emptyFamily(),
openSettleFailed: emptyFamily(),
schedulerBuild: false,
};
for (const line of consoleLines) {
if (line.includes('[wx-scheduler] scaffolding installed')) {
counts.schedulerBuild = true;
continue;
}
for (const family of Object.keys(FAMILY_PATTERNS) as Array<
keyof typeof FAMILY_PATTERNS
>) {
if (!FAMILY_PATTERNS[family].test(line)) continue;
const fam = counts[family];
fam.linesSeen += 1;
const occ = OCCURRENCE_RE.exec(line);
const occurrenceTotal = occ ? parseInt(occ[1], 10) : fam.linesSeen;
fam.estimatedTotal = Math.max(fam.estimatedTotal, occurrenceTotal, fam.linesSeen);
if (fam.samples.length < SAMPLE_LIMIT) fam.samples.push(line);
}
}
return counts;
}
// Last-seen fcsTotal/rootHotTotal from a __wxAsyncifyDump()/STATE line, if any.
// rootHotTotal must stay 0 post-v0.1.28 — the standing N8 assertion.
export function parseAsyncifyCounters(
consoleLines: string[]
): { fcsTotal: number; rootHotTotal: number } | null {
let result: { fcsTotal: number; rootHotTotal: number } | null = null;
for (const line of consoleLines) {
const m = /fcsTotal=(\d+) rootHotTotal=(\d+)/.exec(line);
if (m) result = { fcsTotal: parseInt(m[1], 10), rootHotTotal: parseInt(m[2], 10) };
}
return result;
}
// Assert the named guard families never fired. Throws with the offending sample
// lines so the log points straight at the collision the mailbox failed to absorb.
export function expectGuardsSilent(
consoleLines: string[],
families: Array<Exclude<keyof GuardBeaconCounts, 'schedulerBuild'>>
): void {
const counts = countGuardBeacons(consoleLines);
const noisy = families
.map((f) => ({ family: f, count: counts[f] }))
.filter(({ count }) => count.linesSeen > 0);
if (noisy.length > 0) {
const detail = noisy
.map(
({ family, count }) =>
`${family}: ${count.estimatedTotal} fire(s)\n ${count.samples.join('\n ')}`
)
.join('\n ');
throw new Error(`guard beacons fired (expected silent):\n ${detail}`);
}
}