From 61b5f266fb1b57b1606623808b5a397fd5ee14d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Wed, 5 Aug 2026 14:16:33 +0200 Subject: [PATCH] mailbox S1: shim delivery tick, embind audit, app-side WasmMailbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TfxKn5utcntBSnxz4ZnYKs --- .../async/17-mailbox-scheduler-plan.md | 25 ++++ docs/features/async/18-embind-audit.md | 81 ++++++++++++ scripts/common/inject-dyncall-shims.sh | 4 +- scripts/common/shims/asyncify-scheduler.js | 84 ++++++++++-- web/standalone/src/wasm/mailbox.test.ts | 122 ++++++++++++++++++ web/standalone/src/wasm/mailbox.ts | 116 +++++++++++++++++ wxwidgets | 2 +- 7 files changed, 419 insertions(+), 15 deletions(-) create mode 100644 docs/features/async/18-embind-audit.md create mode 100644 web/standalone/src/wasm/mailbox.test.ts create mode 100644 web/standalone/src/wasm/mailbox.ts diff --git a/docs/features/async/17-mailbox-scheduler-plan.md b/docs/features/async/17-mailbox-scheduler-plan.md index 40f89d6..26a352d 100644 --- a/docs/features/async/17-mailbox-scheduler-plan.md +++ b/docs/features/async/17-mailbox-scheduler-plan.md @@ -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. diff --git a/docs/features/async/18-embind-audit.md b/docs/features/async/18-embind-audit.md new file mode 100644 index 0000000..638a979 --- /dev/null +++ b/docs/features/async/18-embind-audit.md @@ -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. diff --git a/scripts/common/inject-dyncall-shims.sh b/scripts/common/inject-dyncall-shims.sh index 2f5e0f2..4377692 100755 --- a/scripts/common/inject-dyncall-shims.sh +++ b/scripts/common/inject-dyncall-shims.sh @@ -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" diff --git a/scripts/common/shims/asyncify-scheduler.js b/scripts/common/shims/asyncify-scheduler.js index 70a995b..6bcf6fa 100644 --- a/scripts/common/shims/asyncify-scheduler.js +++ b/scripts/common/shims/asyncify-scheduler.js @@ -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 === diff --git a/web/standalone/src/wasm/mailbox.test.ts b/web/standalone/src/wasm/mailbox.test.ts new file mode 100644 index 0000000..5d44c89 --- /dev/null +++ b/web/standalone/src/wasm/mailbox.test.ts @@ -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(); + } + }); +}); diff --git a/web/standalone/src/wasm/mailbox.ts b/web/standalone/src/wasm/mailbox.ts new file mode 100644 index 0000000..3d196ae --- /dev/null +++ b/web/standalone/src/wasm/mailbox.ts @@ -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 { + 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 | 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(label: string, fn: () => T): Promise { + if (this.queue.length === 0 && !this.busy()) { + this.delivered++; + try { + return Promise.resolve(fn()); + } catch (e) { + return Promise.reject(e); + } + } + return new Promise((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); + } +} diff --git a/wxwidgets b/wxwidgets index bca69b9..4124a5b 160000 --- a/wxwidgets +++ b/wxwidgets @@ -1 +1 @@ -Subproject commit bca69b9fddc88adec57b05e6809467ef9f5158c8 +Subproject commit 4124a5b817671dc7b654597612fd91b5751b1985