mailbox S1 complete: embind lane, wheel lane, dual-contract specs
Shim embind lane wraps the doc-18 production mutators at the Module boundary (busy-window calls queue + deliver post-settle; time-boxed unkillable pump). N2 un-fixme'd and green; collab-load-fuzz carries the variant contract (drop on legacy, deliver-in-order on scheduler, capped hammer on the scheduler lane); timer-park's timerRetry silence tripwire arms on shim+export and is green on the C-lane kicad build. Bump wxwidgets for the wheel lane. CI both-EH matrix deliberately deferred. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TfxKn5utcntBSnxz4ZnYKs
This commit is contained in:
parent
61b5f266fb
commit
af1eb5fc58
6 changed files with 223 additions and 21 deletions
|
|
@ -160,6 +160,23 @@ rollback = the `WX_SCHEDULER=0` build + a per-step tag.
|
|||
> 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.
|
||||
>
|
||||
> **Work log 2026-08-05 (later) — S1 COMPLETE except the CI matrix (deferred to the end
|
||||
> by request).** Wheel lane: parked wheel ticks enqueue a replay instead of dropping
|
||||
> (`app.cpp`; keys/mouse were already queue-shaped via `wxPostEvent`). Embind lane: the
|
||||
> shim wraps the doc-18 production mutators at the Module boundary — busy-window calls
|
||||
> queue and deliver post-settle via a time-boxed (8 ms/16 ms), unkillable pump; not-busy
|
||||
> calls stay byte-compatible sync. Fresh warm-cache docker `kicad_editor` build carries
|
||||
> the C lanes; staged into the harness. Verified: wx dual battery 30+39+7 on BOTH glue
|
||||
> variants; kicad trio dual-contract — legacy (drop semantics + expected retry storms) and
|
||||
> scheduler (N2 delivery/order green, fuzz deliver-flip green, `timerRetry` silence
|
||||
> tripwire ARMED and green = zero retry storms on the C-lane build). Fuzz's deterministic
|
||||
> gate caps queued hammer calls on the scheduler lane (6 iters) — the unbounded replay is
|
||||
> a debug-build assert-flood benchmark (`PCB_VIA::GetWidth` × 800 vias × 170k console
|
||||
> lines), not an ordering test; volume stays in the stress test. Two more recorded traps:
|
||||
> the injector idempotence marker collided with evtloop's EM_JS probe text (sentinel now
|
||||
> `__WX_SCHEDULER_SHIM_SOURCE__`), and a pump whose setTimeout chain can die wedges the
|
||||
> queue forever — the pump body is now exception-proof with a `[wx-scheduler]` beacon.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -68,6 +68,96 @@ if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
|
|||
}, 0);
|
||||
},
|
||||
|
||||
// --- S1 embind lane ---------------------------------------------------
|
||||
// Wraps the audited production mutators (docs/features/async/18) at the
|
||||
// Module boundary: a call made while `kicadOpenFileBusy()` reads true is
|
||||
// QUEUED and DELIVERED after the open settles, in FIFO order, resolving a
|
||||
// returned promise — the doc-17 §3b drop→deliver flip, applied at the one
|
||||
// choke point every caller shares (standalone app, kicad e2e harness).
|
||||
// The not-busy path is byte-compatible: same synchronous call, same
|
||||
// return value. Busy-path callers historically got a gate no-op (empty
|
||||
// delta / dropped apply), so the promise is strictly more information.
|
||||
// Under PROXY_TO_PTHREAD this wraps in the window context where app code
|
||||
// calls; in worker contexts the names are absent and nothing wraps.
|
||||
MUTATOR_NAMES: [
|
||||
"kicadSetChrome", "kicadSetReadOnly",
|
||||
"kicadCollabApply", "kicadCollabApplyItems",
|
||||
"kicadCollabSnapshot", "kicadCollabSnapshotItems",
|
||||
"kicadCollabPresenceStart", "kicadCollabSetRemote",
|
||||
"kicadCollabSetPins", "kicadCollabSetStyle",
|
||||
"kicadCollabSetViewport", "kicadCollabFitViewport",
|
||||
"kicadCollabReleaseSelection", "kicadSetColorTheme",
|
||||
"kicadSaveBoard", "kicadSaveSchematic", "kicadSaveDrawingSheet",
|
||||
],
|
||||
mutatorQueue: [],
|
||||
mutatorsWrapped: 0,
|
||||
mutatorsDelivered: 0,
|
||||
_mutatorPumpArmed: false,
|
||||
_openBusy: function () {
|
||||
var probe = Module["kicadOpenFileBusy"];
|
||||
if (typeof probe !== "function") return false;
|
||||
try { return !!probe(); } catch (e) { return true; }
|
||||
},
|
||||
_wrapMutators: function () {
|
||||
var self = this;
|
||||
this.MUTATOR_NAMES.forEach(function (name) {
|
||||
var orig = Module[name];
|
||||
if (typeof orig !== "function") return;
|
||||
self.mutatorsWrapped++;
|
||||
Module[name] = function () {
|
||||
var args = arguments;
|
||||
var call = function () { return orig.apply(Module, args); };
|
||||
if (self.mutatorQueue.length === 0 && !self._openBusy()) {
|
||||
self.mutatorsDelivered++;
|
||||
return call();
|
||||
}
|
||||
return new Promise(function (resolve, reject) {
|
||||
self.mutatorQueue.push({ name: name, call: call, resolve: resolve, reject: reject });
|
||||
self._armMutatorPump();
|
||||
});
|
||||
};
|
||||
});
|
||||
if (this.mutatorsWrapped > 0)
|
||||
console.log("[wx-scheduler] embind lane: wrapped " + this.mutatorsWrapped + " mutator(s)");
|
||||
},
|
||||
_armMutatorPump: function () {
|
||||
if (this._mutatorPumpArmed) return;
|
||||
this._mutatorPumpArmed = true;
|
||||
var self = this;
|
||||
var now = (typeof performance !== "undefined" && performance.now)
|
||||
? function () { return performance.now(); }
|
||||
: function () { return Date.now(); };
|
||||
setTimeout(function pump() {
|
||||
// The pump must be unkillable: any exception escaping this body would
|
||||
// end the setTimeout chain and wedge the queue forever (observed: 559
|
||||
// messages frozen through a 240 s drain-wait). Per-delivery errors
|
||||
// reject that caller's promise; anything else is beaconed and the
|
||||
// chain re-arms regardless.
|
||||
try {
|
||||
if (!self._openBusy()) {
|
||||
// Time-boxed drain: a long queue (a hammer of snapshots against a
|
||||
// big board) must not monopolize the main thread in one burst —
|
||||
// paint, the title update, and input all starve. ~8 ms of work per
|
||||
// 16 ms tick keeps the page live while the backlog drains in order.
|
||||
var t0 = now();
|
||||
while (self.mutatorQueue.length > 0 && now() - t0 < 8) {
|
||||
if (self._openBusy()) break; // a delivered call re-opened the window
|
||||
var m = self.mutatorQueue.shift();
|
||||
self.mutatorsDelivered++;
|
||||
try { m.resolve(m.call()); } catch (e) { m.reject(e); }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
self._pumpErrors = (self._pumpErrors || 0) + 1;
|
||||
if (self._pumpErrors <= 5)
|
||||
console.warn("[wx-scheduler] mutator pump error (occurrence "
|
||||
+ self._pumpErrors + "): " + e);
|
||||
}
|
||||
if (self.mutatorQueue.length > 0) setTimeout(pump, 16);
|
||||
else self._mutatorPumpArmed = false;
|
||||
}, 16);
|
||||
},
|
||||
|
||||
// --- S2 scheduler core (not yet live) ---------------------------------
|
||||
// ctx = { id, kind: 'main'|'modal'|'nested'|'coroutine'|'sleep',
|
||||
// buffer, status: 'running'|'parked'|'ready', wakeReason, result }
|
||||
|
|
@ -88,6 +178,9 @@ if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
|
|||
+ " mailbox=" + this.mailbox.length
|
||||
+ " enqueued=" + this.enqueued
|
||||
+ " delivered=" + this.delivered
|
||||
+ " mutQ=" + this.mutatorQueue.length
|
||||
+ " mutWrapped=" + this.mutatorsWrapped
|
||||
+ " mutDelivered=" + this.mutatorsDelivered
|
||||
+ " contexts=" + this.contexts.size
|
||||
+ " ready=" + this.readyQueue.length
|
||||
+ " transition=" + this.transitionRunning;
|
||||
|
|
@ -95,6 +188,23 @@ if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
|
|||
};
|
||||
|
||||
globalThis.__wxScheduler = AsyncifyScheduler;
|
||||
|
||||
// Wrap the embind mutators once the runtime has registered them. The shim
|
||||
// executes at glue load (before instantiation), so chaining
|
||||
// onRuntimeInitialized is normally enough; the calledRun branch covers a
|
||||
// shim injected into an already-running Module (defensive).
|
||||
if (typeof Module !== "undefined") {
|
||||
if (Module["calledRun"]) {
|
||||
AsyncifyScheduler._wrapMutators();
|
||||
} else {
|
||||
var __wxSchedPrevInit = Module["onRuntimeInitialized"];
|
||||
Module["onRuntimeInitialized"] = function () {
|
||||
if (typeof __wxSchedPrevInit === "function") __wxSchedPrevInit();
|
||||
AsyncifyScheduler._wrapMutators();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[wx-scheduler] scaffolding installed (S1, mailbox live)");
|
||||
}
|
||||
// === End AsyncifyScheduler ===
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ import { test, expect } from "./fixtures";
|
|||
* - mid-load applies are DROPPED (the probe segment must not move);
|
||||
* - after settle the entries work normally (guard released).
|
||||
*
|
||||
* VARIANT CONTRACT (docs/features/async/17 §3b): on WX_SCHEDULER=1 glue the
|
||||
* shim's embind lane queues busy-window mutators and delivers them after
|
||||
* settle, so the "applies are DROPPED" assertions flip to "applies are
|
||||
* DELIVERED in order" — assertSettledContract branches on the lane's
|
||||
* presence. The busy-window and release assertions hold for both variants.
|
||||
*
|
||||
* The second test is the scheduler-dependent stress fuzz (spinning-worker CPU
|
||||
* starvation to force real futex-wait parks, hammering throughout the load).
|
||||
* It is skipped unless PCBJAM_FUZZ_STRESS=1: engagement of the window is not
|
||||
|
|
@ -229,12 +235,28 @@ async function openAndHammer(
|
|||
let iterations = 0;
|
||||
let maxBusySnapshotItems = 0;
|
||||
const errors: string[] = [];
|
||||
// Scheduler glue QUEUES busy-window entries for post-settle delivery
|
||||
// (doc 17 §3b) — an unbounded hammer would replay hundreds of heavy
|
||||
// applies/snapshots afterwards (each Push walks connectivity across the
|
||||
// fixture's 800 vias; on a debug build every via prints an assert — a
|
||||
// 170k-line console flood that drowns the drain). The deterministic
|
||||
// contract needs delivery + order, not volume: cap the queued calls and
|
||||
// keep observing the busy window. Legacy glue keeps the full hammer
|
||||
// (drop semantics make it free). Volume lives in the STRESS test.
|
||||
const lane =
|
||||
((globalThis as unknown as { __wxScheduler?: { mutatorsWrapped: number } }).__wxScheduler
|
||||
?.mutatorsWrapped ?? 0) > 0;
|
||||
const maxEntryIters = lane ? 6 : Infinity;
|
||||
// Every Asyncify park of the open chain hands the event loop to this
|
||||
// timer — exactly how the prod shell's collab attach interleaved.
|
||||
while (performance.now() - t0 < 120000) {
|
||||
if (!w.Module.kicadOpenFileBusy()) break;
|
||||
busySamples++;
|
||||
iterations++;
|
||||
if (iterations > maxEntryIters) {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
continue;
|
||||
}
|
||||
for (const [name, fn] of [
|
||||
["snapshotItems", () => w.Module.kicadCollabSnapshotItems()],
|
||||
["snapshot", () => w.Module.kicadCollabSnapshot()],
|
||||
|
|
@ -271,18 +293,46 @@ async function openAndHammer(
|
|||
async function assertSettledContract(page: Page, stats: FuzzStats): Promise<void> {
|
||||
expect(stats.settled, "kicadOpenFileBusy cleared after the load").toBe(true);
|
||||
expect(stats.errors, "no traps while hammering entries mid-load").toEqual([]);
|
||||
// Guard held: no mid-load snapshot ever saw the model.
|
||||
// Guard held: no mid-load snapshot ever saw the model. On scheduler glue a
|
||||
// busy-window snapshot returns a Promise (typeof !== "string" — the hammer
|
||||
// skips it), so this assertion holds for both variants.
|
||||
expect(stats.maxBusySnapshotItems, "mid-load snapshots returned the empty delta").toBe(0);
|
||||
await expect.poll(() => page.title(), { timeout: 30000 }).toMatch(/fuzz/i);
|
||||
|
||||
// Guard dropped the mid-load applies: the probe segment never moved.
|
||||
// Variant contract (docs/features/async/17 §3b). Legacy glue: the gate
|
||||
// DROPPED the mid-load applies — the probe never moved. Scheduler glue
|
||||
// (WX_SCHEDULER=1 shim embind lane, doc 18): the same applies were QUEUED
|
||||
// and DELIVERED after settle, in order — the probe sits where the hammer's
|
||||
// deltas moved it. Same stimulus, the drop→deliver flip is the assertion.
|
||||
const schedulerLane = await page.evaluate(
|
||||
() =>
|
||||
((globalThis as unknown as { __wxScheduler?: { mutatorsWrapped: number } }).__wxScheduler
|
||||
?.mutatorsWrapped ?? 0) > 0,
|
||||
);
|
||||
if (schedulerLane) {
|
||||
// The hammer queued hundreds of calls (each mid-load snapshot delivers as
|
||||
// a FULL board walk now, not the gate's empty delta) — wait for the
|
||||
// time-boxed pump to drain the backlog before asserting final state.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(globalThis as unknown as { __wxScheduler: { mutatorQueue: unknown[] } })
|
||||
.__wxScheduler.mutatorQueue.length,
|
||||
),
|
||||
{ timeout: 240000, intervals: [1000] },
|
||||
)
|
||||
.toBe(0);
|
||||
}
|
||||
const HAMMER_TARGET = "55000000,55000000"; // both hammer deltas move the probe here
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate((id) => (window.Module as unknown as Mod).kicadCollabGetPos(id), SEG_TARGET),
|
||||
{ timeout: 10000, intervals: [200] },
|
||||
)
|
||||
.toBe(PROBE_HOME);
|
||||
.toBe(schedulerLane ? HAMMER_TARGET : PROBE_HOME);
|
||||
|
||||
// Guard released: the snapshot now walks the real, fully-loaded board…
|
||||
const itemCount = await page.evaluate(
|
||||
|
|
@ -326,7 +376,9 @@ test.describe("collab entries during a parked board load (open_gate)", () => {
|
|||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
test.setTimeout(180000);
|
||||
// Scheduler-glue runs replay the whole hammer backlog after settle (the
|
||||
// drain-wait in assertSettledContract) — budget for it on top of the load.
|
||||
test.setTimeout(420000);
|
||||
await bootHarness(page);
|
||||
|
||||
const hooks = await page.evaluate(() => {
|
||||
|
|
|
|||
|
|
@ -2,21 +2,19 @@ import type { Page } from "@playwright/test";
|
|||
import { test, expect } from "./fixtures";
|
||||
|
||||
/**
|
||||
* N2 — message ordering under a parked open (RED, target semantics).
|
||||
* N2 — message ordering under a parked open (scheduler-build target semantics).
|
||||
* docs/features/async/17-mailbox-scheduler-plan.md §3d N2, §3b.
|
||||
*
|
||||
* Today (open_gate, doc 14): collab entries issued while `kicadOpenFile` is
|
||||
* asyncify-parked are DROPPED — collab-load-fuzz.spec.ts asserts exactly that
|
||||
* contract, and it is correct for the guard architecture.
|
||||
* Legacy glue (open_gate, doc 14): collab entries issued while `kicadOpenFile`
|
||||
* is asyncify-parked are DROPPED — collab-load-fuzz.spec.ts asserts that drop
|
||||
* contract on legacy builds, and it is correct for the guard architecture.
|
||||
*
|
||||
* The mailbox flips drop→deliver: a mutating entry issued during the open
|
||||
* becomes a queued message, applied IN ORDER after the open completes. This
|
||||
* spec asserts those target semantics, so it is RED by design until:
|
||||
* - S1 (JS wrapper enqueues mutating embind entries) makes basic delivery
|
||||
* work, and
|
||||
* - S4 (open runs on a scheduler fiber) removes the gate entirely.
|
||||
* Un-fixme at S1; collab-load-fuzz's "entries no-op" assertions retire at S4
|
||||
* (they flip per doc 17 §3b).
|
||||
* becomes a queued message, applied IN ORDER after the open completes. GREEN
|
||||
* since S1's embind lane — the WX_SCHEDULER=1 shim wraps the audited mutators
|
||||
* (doc 18) at the Module boundary, queueing busy-window calls and delivering
|
||||
* after settle with promise-returned results. S4 moves queueing worker-side.
|
||||
* Self-skips on legacy glue (the lane is a build variant until S5).
|
||||
*
|
||||
* Ordering probe: apply A ADDS a segment, apply B MOVES that same segment.
|
||||
* B can only land if A landed first — the single final-position check proves
|
||||
|
|
@ -81,8 +79,7 @@ async function bootHarness(page: Page): Promise<void> {
|
|||
}
|
||||
|
||||
test.describe("mailbox N2: entries during a parked open are delivered in order", () => {
|
||||
// RED until doc 17 S1 — the open gate currently DROPS both applies.
|
||||
test.fixme("apply A (add) then apply B (move) mid-park land in order after settle", async ({
|
||||
test("apply A (add) then apply B (move) mid-park land in order after settle", async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
|
|
@ -90,6 +87,15 @@ test.describe("mailbox N2: entries during a parked open are delivered in order",
|
|||
void testLogger;
|
||||
await bootHarness(page);
|
||||
|
||||
// The delivery contract under test is the scheduler build's embind lane;
|
||||
// on legacy glue the open gate drops both applies by design.
|
||||
const lane = await page.evaluate(() => {
|
||||
const s = (globalThis as unknown as { __wxScheduler?: { mutatorsWrapped: number } })
|
||||
.__wxScheduler;
|
||||
return s ? s.mutatorsWrapped : 0;
|
||||
});
|
||||
test.skip(lane === 0, "legacy glue — embind lane absent (drop contract in collab-load-fuzz)");
|
||||
|
||||
const issued = await page.evaluate(async ({ newSeg, board }) => {
|
||||
const w = window as unknown as { FS: FS; Module: Mod };
|
||||
const dir = "/home/kicad/documents";
|
||||
|
|
@ -146,9 +152,9 @@ test.describe("mailbox N2: entries during a parked open are delivered in order",
|
|||
expect(issued.inWindow, "the busy window was observed").toBe(true);
|
||||
expect(issued.settled, "the open settled").toBe(true);
|
||||
|
||||
// TARGET SEMANTICS (mailbox): both queued applies were delivered, in order —
|
||||
// A's segment exists and sits where B moved it. Under the open gate both
|
||||
// are dropped and GetPos finds nothing: deterministic red.
|
||||
// Both queued applies were delivered, in order — A's segment exists and
|
||||
// sits where B moved it. (Drop-A-deliver-B leaves B targetless; drop-both
|
||||
// leaves GetPos empty — either failure mode misses B_TARGET.)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { expectGuardsSilent } from "./utils/guard-beacons";
|
||||
|
||||
/**
|
||||
* Timer-park concurrent-Asyncify repro (gal-refresh-timer investigation).
|
||||
|
|
@ -311,6 +312,22 @@ test.describe("timer Notify() Asyncify-park during main-loop yield (concurrent c
|
|||
);
|
||||
expect(trapLines, "no wasm trap signature anywhere in the run").toEqual([]);
|
||||
|
||||
// Doc 17 S1 tripwire (arms itself when the wasm ships the C mailbox lane,
|
||||
// detected via the wxWasmMailboxTick export): timers are then delivered
|
||||
// from the mailbox only when the interlock is free, so the legacy 17 ms
|
||||
// parked-retry path must be SILENT. On legacy wasm this is skipped — the
|
||||
// retry storm there is expected and covered by the assertions above.
|
||||
// Both halves must be present: the wasm export (C lane compiled in) AND
|
||||
// the shim (variant injected) — a C-lane wasm on legacy glue keeps legacy
|
||||
// timer semantics, and its retry storms are expected.
|
||||
const cLane = await page.evaluate(
|
||||
() =>
|
||||
typeof (window.Module as unknown as { _wxWasmMailboxTick?: unknown })
|
||||
._wxWasmMailboxTick === "function" &&
|
||||
!!(globalThis as unknown as { __wxScheduler?: unknown }).__wxScheduler,
|
||||
);
|
||||
if (cLane) expectGuardsSilent(testLogger.consoleLogs, ["timerRetry"]);
|
||||
|
||||
// Window-engagement proof, independent of survival: the handlesleep shim
|
||||
// must have SEEN the concurrent parks (its reporting is new — silence here
|
||||
// means the lever never created the overlap and the repro is vacuous).
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 4124a5b817671dc7b654597612fd91b5751b1985
|
||||
Subproject commit 45563434a2e23b513f0b71e2e9ad5ab6a8bcae00
|
||||
Loading…
Reference in a new issue