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
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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue