From 94ae4a8a4119d3bc818f29ab1c721fe2db48942f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Wed, 5 Aug 2026 13:43:23 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TfxKn5utcntBSnxz4ZnYKs --- .../async/17-mailbox-scheduler-plan.md | 11 ++ scripts/common/inject-dyncall-shims.sh | 23 ++- scripts/common/shims/asyncify-scheduler.js | 42 +++++ tests/kicad/mailbox-ordering.spec.ts | 160 ++++++++++++++++++ tests/kicad/utils/guard-beacons.ts | 119 +++++++++++++ 5 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 scripts/common/shims/asyncify-scheduler.js create mode 100644 tests/kicad/mailbox-ordering.spec.ts create mode 100644 tests/kicad/utils/guard-beacons.ts diff --git a/docs/features/async/17-mailbox-scheduler-plan.md b/docs/features/async/17-mailbox-scheduler-plan.md index 6f9d1fd..40f89d6 100644 --- a/docs/features/async/17-mailbox-scheduler-plan.md +++ b/docs/features/async/17-mailbox-scheduler-plan.md @@ -127,6 +127,17 @@ rollback = the `WX_SCHEDULER=0` build + a per-step tag. - **S0 · Baseline + scaffolding (2–3 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 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: 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 diff --git a/scripts/common/inject-dyncall-shims.sh b/scripts/common/inject-dyncall-shims.sh index e062444..2f5e0f2 100755 --- a/scripts/common/inject-dyncall-shims.sh +++ b/scripts/common/inject-dyncall-shims.sh @@ -5,6 +5,8 @@ # The actual JavaScript that gets injected lives in readable, standalone files in # scripts/common/shims/ (not inline heredocs): # - 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) # # Native wasm-EH is the only build mode, so the .js has no invoke_* wrappers / dynCall_ call @@ -17,6 +19,7 @@ # Usage: # inject-dyncall-shims.sh # SHIM_DIAGNOSTICS=1 inject-dyncall-shims.sh # also inject diagnostics.js +# WX_SCHEDULER=1 inject-dyncall-shims.sh # also inject the scheduler (dual-glue variant) set -e @@ -32,7 +35,7 @@ if [ -z "$JS_FILE" ] || [ ! -f "$JS_FILE" ]; then echo "Usage: $0 " exit 1 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 echo "Error: missing shim source $SHIM_DIR/$f" exit 1 @@ -99,6 +102,24 @@ else 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) ---------------- # embind's generic caller (getDynCaller) routes through dynCallLegacy, which only # reads Module["dynCall_"]. But the DYNCALLS=1 trampolines are wasm EXPORTS, diff --git a/scripts/common/shims/asyncify-scheduler.js b/scripts/common/shims/asyncify-scheduler.js new file mode 100644 index 0000000..70a995b --- /dev/null +++ b/scripts/common/shims/asyncify-scheduler.js @@ -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 === diff --git a/tests/kicad/mailbox-ordering.spec.ts b/tests/kicad/mailbox-ordering.spec.ts new file mode 100644 index 0000000..7339260 --- /dev/null +++ b/tests/kicad/mailbox-ordering.spec.ts @@ -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 drop→deliver: 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 { + 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 }).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); + }); +}); diff --git a/tests/kicad/utils/guard-beacons.ts b/tests/kicad/utils/guard-beacons.ts new file mode 100644 index 0000000..5f9cf13 --- /dev/null +++ b/tests/kicad/utils/guard-beacons.ts @@ -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, + 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> +): 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}`); + } +}