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:
parent
94ae4a8a41
commit
61b5f266fb
7 changed files with 419 additions and 15 deletions
|
|
@ -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.
|
||||
|
|
|
|||
81
docs/features/async/18-embind-audit.md
Normal file
81
docs/features/async/18-embind-audit.md
Normal 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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ===
|
||||
|
|
|
|||
122
web/standalone/src/wasm/mailbox.test.ts
Normal file
122
web/standalone/src/wasm/mailbox.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
116
web/standalone/src/wasm/mailbox.ts
Normal file
116
web/standalone/src/wasm/mailbox.ts
Normal 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
|
||||
Loading…
Reference in a new issue