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

@ -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);
}
}