mailbox S3: N5 flood spec + work log; bump wxwidgets (plain-call pumps)

N5 unit gates (scheduler-shim.test.ts): 500-call mutator flood strict
FIFO, time-boxed chunking proven under load, wake-drain FIFO. Gates for
S3: asyncify 9/9, coroutine 39/39, wx modal-heavy 45/45, kicad 6/6
(incl. modal-stack + contextmenu-scrollbar) on a fresh C-lane build.

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 16:29:14 +02:00
commit 5f29cd7be9
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
3 changed files with 174 additions and 1 deletions

View file

@ -211,6 +211,22 @@ rollback = the `WX_SCHEDULER=0` build + a per-step tag.
(#13302) is removed. The root context never awaits JS (13 §6b). **Gate:** net green with
emphasis on `contextmenu*`, `menu`, `popup`, coroutine apps (the June regression pair,
13 §6d, must both stay green simultaneously).
> **Work log 2026-08-05 — S3 LANDED and gated.** All three remaining `await ccall(
> 'ProcessEvents',{async:true})` pumps — modal (`dialog.cpp` startModal), nested loop
> (`evtloop.cpp` wxWasmRunNestedLoop), popup (`wx-dom.js`) — now drive ProcessEvents as a
> PLAIN export call on scheduler builds (runtime-gated on the shim marker; legacy paths
> byte-identical). With the v0.1.28 top-level tick, NO pump awaits a suspending export
> anymore: the #13302 boundary is gone from the scheduler variant entirely. Overlapping
> pump ticks are parked-safe by ProcessEvents' existing gate; a chain that dies mid-park
> surfaces via the window error taps instead of the pump's await-catch (containment
> unchanged: fatal-screen + `wx_dispatch_abandon`). **Gates:** asyncify 9/9, coroutine
> 39/39 (the June pair green simultaneously), wx-chromium modal-heavy 45/45 (+menu,
> wizard, filedialog), kicad 6/6 incl. import-settings-modal-stack + contextmenu-scrollbar
> on a fresh warm-cache C-lane build. **N5 landed** alongside (scheduler-shim.test.ts:
> 500-call flood strict-FIFO, time-box chunking proven, wake-drain FIFO — 3/3).
> **Left open:** the doc-12 "root fiber" formalization (main loop as ctx object,
> `set_main_loop`-style top-off-asyncify) — not needed for the #13302 goal; revisit at S4
> if the waits migration wants real park/resume methods.
- **S4 · Waits migration, one wait at a time (12 wk).** Implement
`wasm_begin_async_wait`/`wasm_yield_until`/`wasm_resolve_wait` (13 §2). Order, lowest-risk
first, each sub-step flipping its own specs (§3b/3c) and deleting its own pump:

View file

@ -0,0 +1,157 @@
/**
* N5 flood/fairness unit gates for the scheduler shim
* (docs/features/async/17 §3d N5; the shim source is scripts/common/shims/
* asyncify-scheduler.js, loaded here against a fake runtime surface).
*
* Doc 06 §starvation: FIFO by default; a stimulus flood must neither reorder
* deliveries nor starve them, and the time-boxed pump must not monopolize the
* thread in one burst. These are unit gates the e2e batteries cover the
* same machinery under the real runtime.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const SHIM_PATH = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../scripts/common/shims/asyncify-scheduler.js",
);
type SchedulerShape = {
mutatorQueue: unknown[];
mutatorsDelivered: number;
readyWakes: { deliver: (r: unknown) => void; result: unknown }[];
deferredWakes: number;
drainedWakes: number;
strayWrites: number;
_openBusy(): boolean;
_armMutatorPump(): void;
_scheduleWakeDrain(): void;
state(): string;
};
declare global {
// eslint-disable-next-line no-var
var __wxScheduler: SchedulerShape | undefined;
// eslint-disable-next-line no-var
var __wxSchedulerInstalled: boolean | undefined;
}
function loadShim(opts: { busy: () => boolean }) {
// Fresh globals per load — the shim's install guard is per-context.
delete (globalThis as Record<string, unknown>).__wxSchedulerInstalled;
delete (globalThis as Record<string, unknown>).__wxScheduler;
const g = globalThis as Record<string, unknown>;
g.Asyncify = {
state: 0,
exportCallStack: [],
currData: null,
handleSleep: function (startAsync: (wake: (r: unknown) => void) => void) {
startAsync(() => undefined);
},
allocateData: () => 0,
maybeStopUnwind: () => undefined,
};
g.Module = {
kicadOpenFileBusy: opts.busy,
kicadCollabApplyItems: (x: unknown) => `applied:${String(x)}`,
};
// eslint-disable-next-line no-eval
(0, eval)(readFileSync(SHIM_PATH, "utf8"));
const S = (globalThis as Record<string, unknown>).__wxScheduler as SchedulerShape;
// Run the Module init hook (fake runtime: pretend init fired).
const M = g.Module as { onRuntimeInitialized?: () => void };
M.onRuntimeInitialized?.();
return S;
}
describe("N5: scheduler shim under flood", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("500-call mutator flood delivers strictly FIFO with zero drops", async () => {
vi.useFakeTimers();
try {
let busy = true;
const S = loadShim({ busy: () => busy });
const M = (globalThis as Record<string, unknown>).Module as {
kicadCollabApplyItems: (x: number) => Promise<string> | string;
};
const N = 500;
const results: Promise<string>[] = [];
for (let i = 0; i < N; i++) {
results.push(Promise.resolve(M.kicadCollabApplyItems(i)));
}
expect(S.mutatorQueue.length).toBe(N);
busy = false;
// Drive fake time until the queue drains (the pump self-rearms at 16ms).
for (let guard = 0; guard < 5000 && S.mutatorQueue.length > 0; guard++) {
await vi.advanceTimersByTimeAsync(16);
}
expect(S.mutatorQueue.length, "flood fully drained (no starvation)").toBe(0);
const values = await Promise.all(results);
// Strict FIFO: promise i resolved with its own payload, in order.
for (let i = 0; i < N; i++) expect(values[i]).toBe(`applied:${i}`);
expect(S.mutatorsDelivered).toBe(N);
} finally {
vi.useRealTimers();
}
});
it("time-boxed pump yields between chunks — a flood cannot monopolize one task", async () => {
vi.useFakeTimers();
try {
let busy = true;
const S = loadShim({ busy: () => busy });
const M = (globalThis as Record<string, unknown>).Module as {
kicadCollabApplyItems: (x: number) => unknown;
};
// Each delivery burns ~1ms of fake "work" — with an 8ms box, one tick
// must deliver only a handful, not all 100.
const realNow = performance.now.bind(performance);
let clock = 0;
const nowSpy = vi.spyOn(performance, "now").mockImplementation(() => {
clock += 1; // every now() call advances 1ms: ~8 deliveries per box
return clock;
});
for (let i = 0; i < 100; i++) void M.kicadCollabApplyItems(i);
busy = false;
await vi.advanceTimersByTimeAsync(16); // exactly one pump tick
const afterOneTick = 100 - S.mutatorQueue.length;
expect(afterOneTick, "one tick delivered something").toBeGreaterThan(0);
expect(afterOneTick, "one tick did NOT deliver the whole flood").toBeLessThan(100);
nowSpy.mockRestore();
void realNow;
// And the remainder still drains (no starvation after the box closes).
for (let guard = 0; guard < 200 && S.mutatorQueue.length > 0; guard++) {
await vi.advanceTimersByTimeAsync(16);
}
expect(S.mutatorQueue.length).toBe(0);
} finally {
vi.useRealTimers();
}
});
it("deferred wakes drain strictly FIFO", async () => {
vi.useFakeTimers();
try {
const S = loadShim({ busy: () => false });
const order: number[] = [];
// Queue 50 deferred wakes directly (the runtime path queues these when
// a wake arrives mid-transition); the drain must preserve order.
for (let i = 0; i < 50; i++) {
S.readyWakes.push({ deliver: (r) => order.push(r as number), result: i });
}
S._scheduleWakeDrain();
await vi.advanceTimersByTimeAsync(50);
expect(order).toEqual(Array.from({ length: 50 }, (_, i) => i));
expect(S.readyWakes.length).toBe(0);
} finally {
vi.useRealTimers();
}
});
});

@ -1 +1 @@
Subproject commit 45563434a2e23b513f0b71e2e9ad5ab6a8bcae00
Subproject commit 6c33cafa9c31fba99d4dae4b0d300183751d05dd