mailbox S1: shim delivery tick, embind audit, app-side WasmMailbox

Shim: mailbox FIFO + self-armed delivery tick calling wxWasmMailboxTick
(plain export — never inside a pump's awaited ccall); injector sentinel
fixed (the old marker also matched evtloop's EM_JS probe text). Doc 18:
79-export embind audit (14+3 production mutators to wrap, 20 pure-read
allowlist, asymmetries). web/standalone WasmMailbox: FIFO defer-until-
settled keyed on the proxy-safe kicadOpenFileBusy probe, 7 vitest green.
Dual-variant wx battery green (28+39+7 both variants). Bump wxwidgets.

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 14:16:33 +02:00
commit 61b5f266fb
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
7 changed files with 419 additions and 15 deletions

View file

@ -39,6 +39,15 @@ Also carried forward: **the root/scheduler context must never itself await JS**
**`ProcessEvents` must be driven wasm-side**, not via `await ccall(...,{async:true})`
(Emscripten #13302; 12 §prior-art). v0.1.28's fresh-task tick is the first brick of exactly that.
> **S1 lesson (2026-08-05, learned red-first):** the #13302 boundary constrains the MAILBOX
> too — delivering messages from inside pump-driven `ProcessEvents` puts a fiber-swapping
> handler inside the modal pump's awaited ccall and traps `unreachable`
> (`coroutine-nested` / `fiber_create_run_destroy_inside_modal`). Deliveries must enter via
> a dedicated plain export (`wxWasmMailboxTick`, self-armed from the shim, 17 ms retry while
> the interlock is held) — the mailbox changes *when* a message runs, never the kind of
> stack it runs on. This constraint binds every future lane (DOM input, embind) until S3
> removes the awaited-ccall pumps entirely.
## 2. Cutover mechanics
- **Build flag `WX_SCHEDULER=1`** producing parallel glue variants, same pattern as
@ -139,6 +148,18 @@ rollback = the `WX_SCHEDULER=0` build + a per-step tag.
> `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:
> **Work log 2026-08-05 — timers routed, dual battery green.** JS FIFO in the shim;
> `wx/wasm/private/mailbox.h` + drain in `evtloop.cpp`; `timer.cpp` enqueues on scheduler
> builds (runtime-gated on the shim marker; legacy path untouched; 17 ms retry kept as
> tripwire). Delivery enters via the dedicated plain export `wxWasmMailboxTick` (see the
> S1 lesson above — the first pump-embedded delivery design trapped in `coroutine-nested`
> and was fixed red→green). Battery on BOTH variants: wx-chromium 28/28 (timer, dialog,
> dialogs, contextmenu, popup) + coroutine-firefox 39/39 (incl. raytrace multicore) +
> asyncify-firefox 7/7. App-side `WasmMailbox` wrapper (web/standalone/src/wasm/mailbox.ts,
> 7 vitest green) keys on the proxy-safe `kicadOpenFileBusy` probe — PROXY_TO_PTHREAD makes
> the window blind to the worker-side interlock; precision moves worker-side at S4.
> **Still open in S1:** DOM-input lane, wiring the wrapper into the 14 production mutators
> (needs the kicad wasm build + e2e), N2 un-fixme, beacon-silence assertions in fuzz runs.
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
**mutating** embind entries (enqueue + returned promise). Deliverable alongside: the **sync
@ -193,6 +214,10 @@ first step with user-visible semantic changes.
- **Sync embind policy** (S1 audit): pure reads that never dispatch/park stay synchronous;
everything else becomes a promise-returning message. The audit's deliverable is the exact
allowlist, checked by a lint or a dev-build assert.
> **DONE 2026-08-05 → [`18-embind-audit.md`](18-embind-audit.md):** 79 exports — 47 mutators
> (14 production + 3 saves to wrap; 33 test-only stay direct), 20 pure-reads (the allowlist,
> with the model-walk caveat), 9 park-capable. Asymmetries to fix listed there (ungated
> pure-reads, pl_editor bare-stack applies, ungated `kicadLibsReload`).
- **`kicadOpenFileBusy` compatibility**: `open-flow.ts` feature-detects it and legacy wasm
builds rely on the fallback — keep the export, backed by scheduler state, until the web app
drops support for pre-scheduler wasm.

View file

@ -0,0 +1,81 @@
# 18 — Embind export audit (doc 17 S1 deliverable)
> **Status: AUDIT (2026-08-05).** The sync-embind classification doc 17 §4 S1 calls for:
> every JS-callable export in `wasm/bindings/`, classified for the mailbox migration.
> Method: all `EMSCRIPTEN_BINDINGS` blocks enumerated; each body followed one level deep.
> Classes: **PURE-READ** (stays synchronous), **MUTATOR** (becomes a queued message),
> **PARKER** (can asyncify-park itself), **TEST-LEVER** (`kicadTest*`, e2e only).
## Headline numbers
**79 distinct JS names** (171 `function()` registrations across 6 bindings blocks —
names duplicate per-bundle behind `#ifndef KICAD_MERGED_EMBIND`; pcb_calculator's block
is empty). No `EMSCRIPTEN_KEEPALIVE` JS entries exist in the tree.
| class | count | migration consequence |
|---|---|---|
| MUTATOR | 47 (33 test-only, 14 production) | queued mailbox message (S1 wrapper / S4) |
| PURE-READ | 20 | stays sync — the allowlist |
| TEST-LEVER | 9 (6 of them park-capable by design) | keep direct — they *stage* collisions |
| PARKER | 3 production (`kicadOpenFile`, `kicadOpenFiles`, `kicadLibsReload`) | fiber contexts at S4 |
**The 14+3 production mutators/IO:** `kicadSetChrome`, `kicadSetReadOnly`,
`kicadCollabApply`, `kicadCollabApplyItems`, `kicadCollabSnapshot`,
`kicadCollabSnapshotItems`, `kicadCollabPresenceStart`, `kicadCollabSetRemote`,
`kicadCollabSetPins`, `kicadCollabSetStyle`, `kicadCollabSetViewport`,
`kicadCollabFitViewport`, `kicadCollabReleaseSelection`, `kicadSetColorTheme`
(park-suspect: settings Save + full chrome rebuild), plus light `kicadSetDarkChrome`
and the three bare-stack I/O entries `kicadSaveBoard` / `kicadSaveSchematic` /
`kicadSaveDrawingSheet`.
**The PURE-READ allowlist (stays sync):** `kicadOpenFileBusy`, `kicadCollabFiberBusy`,
`kicadCollabGetPos`, `kicadCollabGetViewport`, `kicadCollabGetSelection`,
`kicadCollabGetSelectionFull`, `kicadCollabTestGetCrossMapped`, `kicadCollabTestGetLocked`,
`kicadCollabTestListItems`, `kicadCollabTestDemoSet`, `kicadCollabTestItemBlob`,
`kicadCollabTestUndoDepth`, `kicadTestTimerParkState`, `kicadTestFiberParkState`,
`kicadLibsSymbolUsage`, `Board_GetFootprints`, `Board_GetFileName`, `Footprint_GetPads`,
`Footprint_GetReference`, `Footprint_GetValue`, `Pad_GetNumber`, `Pad_GetPinFunction`.
Caveat below: "sync" is safe for the asyncify machinery, but reads that walk the model
still need the open to have settled (N6 territory).
## Guard coverage today
| guard | coverage |
|---|---|
| `pcbjam_open::BusyGuard` (sets the gate) | the 6 open entries |
| `pcbjam_open::busy()` early-return | ONLY 8 names: Collab{Apply,ApplyItems,Snapshot,SnapshotItems} × pcbnew/eeschema/pl_editor |
| `runOnFiber` FIFO (collab_common.h) | ~35 mutators |
| bare `CallAfter`, no fiber | selection/presence sub-paths, eeschema's 2 move test hooks |
| entirely unguarded, bare embind stack | `kicadSetChrome`, `kicadSetReadOnly`, the 3 `kicadSave*`, pl_editor applies (busy-gated but not fibered, inline `HardRedraw`), `kicadCollabTestAddText`, `kicadLibsReload` (fibered, no busy check), and every model-walking PURE-READ |
## Asymmetries the migration must fix (or knowingly accept)
1. **Model-walking pure-reads have no `busy()` gate** (`kicadCollabGetPos`,
`kicadCollabGetSelection*`, `kicadCollabTestListItems`, `Board_*`/`Footprint_*`/`Pad_*`,
`kicadLibsSymbolUsage`, `kicadCollabTestItemBlob`) — they can walk a half-built model
during a parked `OpenProjectFiles`. Same exposure class the gate was built for; the
mailbox does NOT fix reads (they stay sync) — needs either a busy() early-return with a
benign empty result, or documented "caller must await settle" (open-flow already does).
2. **pl_editor's `kicadCollabApply`/`ApplyItems` are busy-gated but NOT fibered** — they
mutate `DS_DATA_MODEL` + `HardRedraw()` on the bare embind stack (pl_editor_embind.cpp:313,
:515), unlike the pcbnew/eeschema twins. `kicadCollabTestAddText` (PL:597) likewise.
3. **`kicadLibsReload` is the only production PARKER with no `busy()` gate**
(pcbjam_libs_reload.h — `LoadLibraryEntry` asyncify-suspends per its own header).
4. **eeschema's `schCollabTestMoveFirst`/`MoveSchItem` use bare `CallAfter`** where the
pcbnew twins use `runOnFiber`.
5. **Split-context construction**: `kicadCollabTestDuplicate*`/`AddSymbol` clone/construct
items off-fiber then commit on-fiber.
6. `kicadSave*` park-safety rests on MEMFS writes staying synchronous — UNCERTAIN, verify
before S4 puts saves on fibers.
7. Registration gaps: `kicadSetChrome` merged-image only; the timer/fiber park test levers
exist in kicad_editor + pcbnew only.
## Consequences for the S1 wrapper (doc 17 S1.5)
Wrap-and-enqueue applies to the **14 production mutators + 3 saves**; test-only mutators
keep direct entry (the harness *wants* to stage collisions). The busy()-gated four
(apply/snapshot pairs) are the first wrap targets — their drop→deliver flip is N2's
subject. `kicadSetDarkChrome` is documented main-thread-safe and can stay direct.
Full per-export table (file:line, evidence, guard) lives in the audit transcript; the
classifications above are the binding contract for S1/S4 work.

View file

@ -109,7 +109,9 @@ fi
# 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
# NOTE: must be the shim-source sentinel, not __wxSchedulerInstalled — that
# string also appears in evtloop.cpp's EM_JS probe inside every glue.
if grep -q '__WX_SCHEDULER_SHIM_SOURCE__' "$JS_FILE"; then
echo "asyncify-scheduler already present - skipping"
else
echo "" >> "$JS_FILE"

View file

@ -1,18 +1,74 @@
// === AsyncifyScheduler (S0 scaffolding — observation-only) ===
// === AsyncifyScheduler (S1 — mailbox front-end live; scheduler core lands in S2) ===
// __WX_SCHEDULER_SHIM_SOURCE__ — injector idempotence sentinel. Must appear ONLY in
// this file: the obvious marker (__wxSchedulerInstalled) also occurs in evtloop.cpp's
// EM_JS probe text inside every glue, which made the injector skip real injections.
// 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.
// S1 state: the MAILBOX is live — deferred browser callbacks (wx timers, via
// wx/wasm/private/mailbox.h) are enqueued here on expiry and delivered by the wx
// event pump (evtloop.cpp wxWasmMailboxDeliver) from a clean dispatch context,
// only when the dispatch interlock is free. The legacy handlesleep.js shim
// (injected just above) remains authoritative for currData until S2, when the
// scheduler core (registry, deferred drain, fiber tracking) lands here gated on
// the N1 single-writer tripwire.
if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
globalThis.__wxSchedulerInstalled = true;
Asyncify.__schedulerBuild = 1;
var AsyncifyScheduler = {
// --- S1 mailbox -------------------------------------------------------
// Due messages, FIFO. {fn, arg} are wasm function-pointer / pointer ints;
// the C side (wxWasmMailboxDeliver) pops and calls them. Exactly-once:
// a message stays queued until the pump delivers it — there is no drop
// path, matching the emscripten_async_call contract it replaces.
mailbox: [],
enqueued: 0,
delivered: 0,
_tickArmed: false,
enqueueAfter: function (fn, arg, ms) {
var self = this;
setTimeout(function () {
self.mailbox.push({ fn: fn, arg: arg });
self.enqueued++;
self._armDeliveryTick();
}, ms);
},
pop: function () {
var m = this.mailbox.shift();
if (m) this.delivered++;
return m || null;
},
// Deliver via a dedicated PLAIN export call (wxWasmMailboxTick), never
// from inside a pump's awaited ProcessEvents ccall — a fiber swap there
// sits on the JS-awaits-a-suspending-export boundary (#13302) and traps.
// Re-arms at 17ms while messages remain (the interlock may be held; the
// C side skips delivery then). One retry loop for the WHOLE queue — this
// replaces the per-timer retry storms of the legacy path.
_armDeliveryTick: function () {
if (this._tickArmed) return;
this._tickArmed = true;
var self = this;
setTimeout(function tick() {
try {
if (Module["_wxWasmMailboxTick"]) Module["_wxWasmMailboxTick"]();
} catch (e) {
self._tickArmed = false;
// Mirror wxWasmScheduleProcessEvents' guard: a trapped delivery
// must not leave the interlock held or a parked nested DoRun stuck.
if (Module["_wx_dispatch_abandon"]) Module["_wx_dispatch_abandon"]();
var exits = Module["_wxNestedLoopExit"];
if (exits && exits.length) (exits.pop())();
throw e;
}
if (self.mailbox.length > 0) {
setTimeout(tick, 17);
} else {
self._tickArmed = false;
}
}, 0);
},
// --- S2 scheduler core (not yet live) ---------------------------------
// ctx = { id, kind: 'main'|'modal'|'nested'|'coroutine'|'sleep',
// buffer, status: 'running'|'parked'|'ready', wakeReason, result }
contexts: new Map(),
@ -21,22 +77,24 @@ if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
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.
// S2 fills these in. They throw today so a premature caller is loud, not
// silent — nothing in an S1 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"
return "[wx-scheduler] build=1 impl=S1-mailbox"
+ " mailbox=" + this.mailbox.length
+ " enqueued=" + this.enqueued
+ " delivered=" + this.delivered
+ " 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)");
console.log("[wx-scheduler] scaffolding installed (S1, mailbox live)");
}
// === End AsyncifyScheduler ===

View file

@ -0,0 +1,122 @@
import { describe, expect, it, vi } from "vitest";
import { WasmMailbox } from "./mailbox";
describe("WasmMailbox", () => {
it("delivers immediately when not busy and queue empty", async () => {
const mb = new WasmMailbox({ kicadOpenFileBusy: () => false });
await expect(mb.enqueue("a", () => 41 + 1)).resolves.toBe(42);
expect(mb.pending()).toBe(0);
expect(mb.delivered).toBe(1);
});
it("legacy wasm without the probe never defers", async () => {
const mb = new WasmMailbox({});
await expect(mb.enqueue("a", () => "ok")).resolves.toBe("ok");
});
it("defers while busy, delivers in FIFO order after settle", async () => {
vi.useFakeTimers();
try {
let busy = true;
const mb = new WasmMailbox({ kicadOpenFileBusy: () => busy });
const order: string[] = [];
const pa = mb.enqueue("a", () => (order.push("a"), "ra"));
const pb = mb.enqueue("b", () => (order.push("b"), "rb"));
expect(mb.pending()).toBe(2);
expect(order).toEqual([]);
await vi.advanceTimersByTimeAsync(100);
expect(order).toEqual([]); // still busy — nothing delivered
busy = false;
await vi.advanceTimersByTimeAsync(50);
expect(order).toEqual(["a", "b"]);
await expect(pa).resolves.toBe("ra");
await expect(pb).resolves.toBe("rb");
expect(mb.pending()).toBe(0);
} finally {
vi.useRealTimers();
}
});
it("a throwing call rejects its promise without breaking the queue", async () => {
vi.useFakeTimers();
try {
let busy = true;
const mb = new WasmMailbox({ kicadOpenFileBusy: () => busy });
const pa = mb.enqueue("boom", () => {
throw new Error("boom");
});
const pb = mb.enqueue("b", () => "rb");
// Attach the expectations BEFORE the drain fires, or the rejection is
// briefly unhandled and vitest reports it as an error.
const exA = expect(pa).rejects.toThrow("boom");
const exB = expect(pb).resolves.toBe("rb");
busy = false;
await vi.advanceTimersByTimeAsync(50);
await exA;
await exB;
} finally {
vi.useRealTimers();
}
});
it("re-opened busy window mid-drain pauses delivery", async () => {
vi.useFakeTimers();
try {
let busy = true;
const mb = new WasmMailbox({ kicadOpenFileBusy: () => busy });
const order: string[] = [];
// Call "a" re-opens the busy window (an apply that triggers a reload).
void mb.enqueue("a", () => {
order.push("a");
busy = true;
});
void mb.enqueue("b", () => order.push("b"));
busy = false;
await vi.advanceTimersByTimeAsync(30);
expect(order).toEqual(["a"]); // b held back by the re-opened window
busy = false;
await vi.advanceTimersByTimeAsync(30);
expect(order).toEqual(["a", "b"]);
} finally {
vi.useRealTimers();
}
});
it("a throwing probe counts as busy (wedged runtime defers, no delivery)", async () => {
vi.useFakeTimers();
try {
let wedged = true;
const mb = new WasmMailbox({
kicadOpenFileBusy: () => {
if (wedged) throw new Error("dead runtime");
return false;
},
});
const order: string[] = [];
void mb.enqueue("a", () => order.push("a"));
await vi.advanceTimersByTimeAsync(100);
expect(order).toEqual([]);
wedged = false;
await vi.advanceTimersByTimeAsync(50);
expect(order).toEqual(["a"]);
} finally {
vi.useRealTimers();
}
});
it("dispose rejects all queued calls", async () => {
vi.useFakeTimers();
try {
const mb = new WasmMailbox({ kicadOpenFileBusy: () => true });
const pa = mb.enqueue("a", () => "never");
const exA = expect(pa).rejects.toThrow("wasm died");
mb.dispose(new Error("wasm died"));
await exA;
expect(mb.pending()).toBe(0);
} finally {
vi.useRealTimers();
}
});
});

View file

@ -0,0 +1,116 @@
// App-side mailbox for mutating embind calls (docs/features/async/17 S1;
// classification: docs/features/async/18-embind-audit.md).
//
// Under PROXY_TO_PTHREAD the app's JS runs on the window while the wx dispatch
// interlock lives in the pthread worker, so this queue cannot key on the C-side
// interlock — it keys on the proxy-safe `kicadOpenFileBusy()` probe. Semantics:
// a mutator enqueued while an open is in flight is DEFERRED and DELIVERED after
// settle, in FIFO order, with its return value resolving the caller's promise —
// the doc-17 §3b drop→deliver flip, replacing the "entry silently no-ops
// against the gate" behavior at the call sites that adopt this wrapper.
//
// Deliberately NOT here (S4 territory): worker-side interlock precision (a
// mutator can still land while a NON-open chain is parked — those entries keep
// their own C-side guards until S4 moves queuing into the bindings), and any
// change to drift-detect's skip-if-fiber-busy logic (#10b semantics, its own
// deliberate contract).
interface BusyProbe {
kicadOpenFileBusy?: () => boolean;
}
interface QueuedCall<T = unknown> {
label: string;
fn: () => T;
resolve: (v: T) => void;
reject: (e: unknown) => void;
}
const POLL_MS = 25;
export class WasmMailbox {
private queue: QueuedCall[] = [];
private timer: ReturnType<typeof setInterval> | null = null;
private mod: BusyProbe;
/** Delivered-call count, for tests and state dumps. */
delivered = 0;
constructor(mod: BusyProbe) {
this.mod = mod;
}
private busy(): boolean {
const probe = this.mod.kicadOpenFileBusy;
// Legacy wasm without the probe: never defer (matches pre-mailbox behavior).
if (typeof probe !== "function") return false;
try {
return probe.call(this.mod);
} catch {
// A probe that throws means the runtime is wedged; delivering would make
// it worse. Treat as busy — the queue drains if it recovers.
return true;
}
}
/**
* Enqueue a mutating embind call. Runs immediately (synchronously, before
* this function returns the promise's settled state is still async) when
* nothing is queued and the runtime is not busy the common path costs one
* probe read. Otherwise FIFO-defers until the open settles.
*/
enqueue<T>(label: string, fn: () => T): Promise<T> {
if (this.queue.length === 0 && !this.busy()) {
this.delivered++;
try {
return Promise.resolve(fn());
} catch (e) {
return Promise.reject(e);
}
}
return new Promise<T>((resolve, reject) => {
this.queue.push({ label, fn, resolve, reject } as QueuedCall);
this.armPump();
});
}
/** Pending (not yet delivered) calls. */
pending(): number {
return this.queue.length;
}
private armPump(): void {
if (this.timer !== null) return;
this.timer = setInterval(() => this.drain(), POLL_MS);
}
private drain(): void {
if (this.busy()) return;
// Snapshot: a delivered call may enqueue more; those wait for the next
// tick rather than extending this drain unboundedly.
let budget = this.queue.length;
while (budget-- > 0) {
if (this.busy()) return; // a delivered call re-opened the busy window
const call = this.queue.shift();
if (!call) break;
this.delivered++;
try {
call.resolve(call.fn());
} catch (e) {
call.reject(e);
}
}
if (this.queue.length === 0 && this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
}
/** Reject everything queued (teardown — e.g. the wasm died). */
dispose(reason: unknown): void {
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
for (const call of this.queue.splice(0)) call.reject(reason);
}
}

@ -1 +1 @@
Subproject commit bca69b9fddc88adec57b05e6809467ef9f5158c8
Subproject commit 4124a5b817671dc7b654597612fd91b5751b1985