diff --git a/docs/features/async/17-mailbox-scheduler-plan.md b/docs/features/async/17-mailbox-scheduler-plan.md index ba48c2e..cee0cc0 100644 --- a/docs/features/async/17-mailbox-scheduler-plan.md +++ b/docs/features/async/17-mailbox-scheduler-plan.md @@ -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 (1–2 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: diff --git a/web/standalone/src/wasm/scheduler-shim.test.ts b/web/standalone/src/wasm/scheduler-shim.test.ts new file mode 100644 index 0000000..2ef7dcb --- /dev/null +++ b/web/standalone/src/wasm/scheduler-shim.test.ts @@ -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).__wxSchedulerInstalled; + delete (globalThis as Record).__wxScheduler; + const g = globalThis as Record; + 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).__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).Module as { + kicadCollabApplyItems: (x: number) => Promise | string; + }; + const N = 500; + const results: Promise[] = []; + 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).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(); + } + }); +}); diff --git a/wxwidgets b/wxwidgets index 4556343..6c33caf 160000 --- a/wxwidgets +++ b/wxwidgets @@ -1 +1 @@ -Subproject commit 45563434a2e23b513f0b71e2e9ad5ab6a8bcae00 +Subproject commit 6c33cafa9c31fba99d4dae4b0d300183751d05dd