mailbox S2: scheduler core — deferred wakes + N1 single-writer tripwire
asyncify-scheduler.js REPLACES handlesleep.js on WX_SCHEDULER=1 builds (injector either-or): ports capture/restore, fiber consume-once/ quarantine guard, wake-window flags, recorder, trampoline heal — and adds deferred wakes (a wake mid-transition queues and drains from a clean macrotask) plus the N1 currData accessor (pure-JS writes need scheduler authorization; strict mode throws; meta-tested). Gates: races 9/9 with NO legacy shim (subsumption), coroutine 39/39, wx-chromium 30/30, kicad trio 3/3 on the 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:
parent
af1eb5fc58
commit
603e260885
4 changed files with 584 additions and 97 deletions
|
|
@ -188,6 +188,24 @@ rollback = the `WX_SCHEDULER=0` build + a per-step tag.
|
||||||
`_asyncify_stop_rewind`/`maybeStopUnwind`, never JS `finally`), fiber tracking at
|
`_asyncify_stop_rewind`/`maybeStopUnwind`, never JS `finally`), fiber tracking at
|
||||||
`emscripten_fiber_swap` (`fiber+20` buffers), trampoline ownership. **Gate:** races battery
|
`emscripten_fiber_swap` (`fiber+20` buffers), trampoline ownership. **Gate:** races battery
|
||||||
green **with the legacy shim ablated**; N1/N4/N5 green; libcontext refusal beacons ≈ 0.
|
green **with the legacy shim ablated**; N1/N4/N5 green; libcontext refusal beacons ≈ 0.
|
||||||
|
> **Work log 2026-08-05 — S2 core LANDED and gated.** The scheduler shim now REPLACES
|
||||||
|
> handlesleep.js on WX_SCHEDULER=1 builds (injector either-or; the S1 "append-after"
|
||||||
|
> ordering is gone). Ported name-identical: capture/restore, wake-window flags
|
||||||
|
> (`__wakingRoot` is read by libcontext EM_JS!), consume-once/quarantine fiber guard,
|
||||||
|
> counters, flight recorder, trampoline heal (external catch-reset wrap). NEW:
|
||||||
|
> **deferred wakes** — a sleep wake arriving mid-transition (state≠Normal or trampoline
|
||||||
|
> live) queues and drains from a clean macrotask (the aliased-wake class is now
|
||||||
|
> structural, not detective); **N1 accessor** — `Asyncify.currData` is a property with a
|
||||||
|
> single-writer tripwire (pure-JS writes need scheduler authorization; wasm-frame writes
|
||||||
|
> pass; strict mode throws), meta-tested by introducing a stray.
|
||||||
|
> **Gates all green:** asyncify-firefox 9/9 (7 races + new N1 meta + N4 books — on glue
|
||||||
|
> with NO legacy shim, so the redundancy pins are now subsumption pins), coroutine 39/39,
|
||||||
|
> wx-chromium 30/30, kicad trio 3/3 on the C-lane build. Injector legacy path verified
|
||||||
|
> unchanged. **Deliberately left for S3:** the formal ctx-Map registry + park/resume
|
||||||
|
> methods (stubs that throw), wasm-side ProcessEvents; **open:** N5 flood spec, both-EH
|
||||||
|
> matrix (CI, deferred). Build-system note: `build-wasm-test.sh` only re-injects freshly
|
||||||
|
> relinked apps — a variant flip without C changes needs the strip+reinject converter
|
||||||
|
> (one-shot python in the work log commit) or a clean build.
|
||||||
- **S3 · Root context + wasm-side ProcessEvents (≈1 wk).** The tick resumes the root context
|
- **S3 · Root context + wasm-side ProcessEvents (≈1 wk).** The tick resumes the root context
|
||||||
which calls `ProcessEvents` on the wasm side — the `await ccall(...,{async:true})` boundary
|
which calls `ProcessEvents` on the wasm side — the `await ccall(...,{async:true})` boundary
|
||||||
(#13302) is removed. The root context never awaits JS (13 §6b). **Gate:** net green with
|
(#13302) is removed. The root context never awaits JS (13 §6b). **Gate:** net green with
|
||||||
|
|
|
||||||
|
|
@ -72,54 +72,53 @@ apply_fix 'var iterFunc = (() => {});' 'var iterFunc = () => wasmExports["dynCal
|
||||||
apply_fix '(a1 => {})(userData);' 'wasmExports["dynCall_vi"](entryPoint, userData);' "fiber entry callback(s) (wasmExports.dynCall_vi)"
|
apply_fix '(a1 => {})(userData);' 'wasmExports["dynCall_vi"](entryPoint, userData);' "fiber entry callback(s) (wasmExports.dynCall_vi)"
|
||||||
echo "Total: Fixed $TOTAL_FIXED empty callback(s)"
|
echo "Total: Fixed $TOTAL_FIXED empty callback(s)"
|
||||||
|
|
||||||
# --- 3. Nested-Asyncify handleSleep fix ---------------------------------------
|
# --- 3. Asyncify shim: legacy handleSleep fix OR the S2 scheduler --------------
|
||||||
# Injected after Emscripten's fiber glue (the _emscripten_fiber_swap.isAsync marker).
|
# Injected after Emscripten's fiber glue (the _emscripten_fiber_swap.isAsync marker),
|
||||||
# SHIM_DISABLE_HANDLESLEEP=1 skips it: used by the asyncify-races red-green harness
|
# or at EOF for non-fiber apps (a plain wx app still needs the currData machinery:
|
||||||
# to keep the historical "sleep buffer clobbered by fiber swap" crash reproducible.
|
# without it a rewind resuming through a fresh wasm re-entry hits
|
||||||
if [ "${SHIM_DISABLE_HANDLESLEEP:-0}" = "1" ]; then
|
# _asyncify_start_rewind(null) -> "memory access out of bounds").
|
||||||
|
#
|
||||||
|
# WX_SCHEDULER=1 (doc 17 S2): asyncify-scheduler.js REPLACES handlesleep.js — it
|
||||||
|
# subsumes the capture/restore, fiber guard, and trampoline heal, and adds the
|
||||||
|
# deferred-wake drain + N1 single-writer tripwire. Injecting BOTH would
|
||||||
|
# double-manage the wake path (the scheduler refuses to install its core then).
|
||||||
|
#
|
||||||
|
# SHIM_DISABLE_HANDLESLEEP=1 skips the legacy shim: the asyncify-races red-green
|
||||||
|
# harness uses it to keep the historical "sleep buffer clobbered by fiber swap"
|
||||||
|
# crash reproducible. On WX_SCHEDULER=1 builds the variant still gets the
|
||||||
|
# scheduler — the ablation pins become scheduler-subsumption pins (doc 17 §3c).
|
||||||
|
inject_shim_at_marker() { # <shim file> <label>
|
||||||
|
local shim_file="$1" label="$2"
|
||||||
|
local marker
|
||||||
|
marker=$(grep -n '^_emscripten_fiber_swap\.isAsync = true;$' "$JS_FILE" | head -1 | cut -d: -f1)
|
||||||
|
if [ -z "$marker" ]; then
|
||||||
|
echo "" >> "$JS_FILE"
|
||||||
|
cat "$SHIM_DIR/$shim_file" >> "$JS_FILE"
|
||||||
|
echo "Injected $label at EOF (no fiber glue)"
|
||||||
|
else
|
||||||
|
head -n "$marker" "$JS_FILE" > "${JS_FILE}.tmp"
|
||||||
|
echo "" >> "${JS_FILE}.tmp"
|
||||||
|
cat "$SHIM_DIR/$shim_file" >> "${JS_FILE}.tmp"
|
||||||
|
tail -n +$((marker + 1)) "$JS_FILE" >> "${JS_FILE}.tmp"
|
||||||
|
mv "${JS_FILE}.tmp" "$JS_FILE"
|
||||||
|
echo "Injected $label after line $marker"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "${WX_SCHEDULER:-0}" = "1" ]; then
|
||||||
|
# NOTE: idempotence via the shim-source sentinel, not __wxSchedulerInstalled —
|
||||||
|
# that string also appears in evtloop.cpp's EM_JS probe inside every glue.
|
||||||
|
if grep -q '__WX_SCHEDULER_SHIM_SOURCE__' "$JS_FILE"; then
|
||||||
|
echo "asyncify-scheduler already present - skipping"
|
||||||
|
else
|
||||||
|
inject_shim_at_marker asyncify-scheduler.js "asyncify-scheduler (replaces handlesleep; WX_SCHEDULER=1)"
|
||||||
|
fi
|
||||||
|
elif [ "${SHIM_DISABLE_HANDLESLEEP:-0}" = "1" ]; then
|
||||||
echo "handleSleep fix DISABLED (SHIM_DISABLE_HANDLESLEEP=1) - ablation build"
|
echo "handleSleep fix DISABLED (SHIM_DISABLE_HANDLESLEEP=1) - ablation build"
|
||||||
elif grep -q '__nestedHandleSleepInstalled' "$JS_FILE"; then
|
elif grep -q '__nestedHandleSleepInstalled' "$JS_FILE"; then
|
||||||
echo "handleSleep fix already present - skipping"
|
echo "handleSleep fix already present - skipping"
|
||||||
else
|
else
|
||||||
HS_MARKER=$(grep -n '^_emscripten_fiber_swap\.isAsync = true;$' "$JS_FILE" | head -1 | cut -d: -f1)
|
inject_shim_at_marker handlesleep.js "handleSleep fix"
|
||||||
if [ -z "$HS_MARKER" ]; then
|
|
||||||
# No libcontext fiber glue (a non-fiber Asyncify app — e.g. a plain wx app with
|
|
||||||
# modals/menus, no tool coroutines). The currData save/restore is still needed:
|
|
||||||
# without it a rewind resuming through a fresh wasm re-entry hits
|
|
||||||
# _asyncify_start_rewind(null) -> "memory access out of bounds" (the context-menu
|
|
||||||
# pick while the main loop is Asyncify-parked). Append at EOF — Asyncify is defined
|
|
||||||
# by then and the shim wraps handleSleep at load, before any runtime sleep.
|
|
||||||
echo "" >> "$JS_FILE"
|
|
||||||
cat "$SHIM_DIR/handlesleep.js" >> "$JS_FILE"
|
|
||||||
echo "Injected handleSleep fix at EOF (no fiber glue)"
|
|
||||||
else
|
|
||||||
head -n "$HS_MARKER" "$JS_FILE" > "${JS_FILE}.tmp"
|
|
||||||
echo "" >> "${JS_FILE}.tmp"
|
|
||||||
cat "$SHIM_DIR/handlesleep.js" >> "${JS_FILE}.tmp"
|
|
||||||
tail -n +$((HS_MARKER + 1)) "$JS_FILE" >> "${JS_FILE}.tmp"
|
|
||||||
mv "${JS_FILE}.tmp" "$JS_FILE"
|
|
||||||
echo "Injected handleSleep fix after line $HS_MARKER"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- 3e. Mailbox/scheduler (WX_SCHEDULER=1 dual-glue variant) ------------------
|
|
||||||
# docs/features/async/17-mailbox-scheduler-plan.md, step S0. Appended AFTER the
|
|
||||||
# handleSleep shim: the legacy shim stays authoritative until S2, when the
|
|
||||||
# scheduler takes ownership of currData and this ordering flips. Idempotent via
|
|
||||||
# the __wxSchedulerInstalled marker. Default OFF — the legacy build is the
|
|
||||||
# shippable fallback until S5.
|
|
||||||
if [ "${WX_SCHEDULER:-0}" = "1" ]; then
|
|
||||||
# NOTE: must be the shim-source sentinel, not __wxSchedulerInstalled — that
|
|
||||||
# string also appears in evtloop.cpp's EM_JS probe inside every glue.
|
|
||||||
if grep -q '__WX_SCHEDULER_SHIM_SOURCE__' "$JS_FILE"; then
|
|
||||||
echo "asyncify-scheduler already present - skipping"
|
|
||||||
else
|
|
||||||
echo "" >> "$JS_FILE"
|
|
||||||
cat "$SHIM_DIR/asyncify-scheduler.js" >> "$JS_FILE"
|
|
||||||
echo "Injected asyncify-scheduler (WX_SCHEDULER=1 dual-glue variant)"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "scheduler disabled (set WX_SCHEDULER=1 for the dual-glue variant)"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 3b. embind dynCall fallback (dynCallLegacy -> wasmExports) ----------------
|
# --- 3b. embind dynCall fallback (dynCallLegacy -> wasmExports) ----------------
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,36 @@
|
||||||
// === AsyncifyScheduler (S1 — mailbox front-end live; scheduler core lands in S2) ===
|
// === AsyncifyScheduler (S2 — scheduler core: registry, deferred wakes, single writer) ===
|
||||||
// __WX_SCHEDULER_SHIM_SOURCE__ — injector idempotence sentinel. Must appear ONLY in
|
// __WX_SCHEDULER_SHIM_SOURCE__ — injector idempotence sentinel. Must appear ONLY in
|
||||||
// this file: the obvious marker (__wxSchedulerInstalled) also occurs in evtloop.cpp's
|
// this file: the obvious marker (__wxSchedulerInstalled) also occurs in evtloop.cpp's
|
||||||
// EM_JS probe text inside every glue, which made the injector skip real injections.
|
// EM_JS probe text inside every glue, which made the injector skip real injections.
|
||||||
// docs/features/async/17-mailbox-scheduler-plan.md · injected only on WX_SCHEDULER=1 builds.
|
// docs/features/async/17-mailbox-scheduler-plan.md · injected only on WX_SCHEDULER=1 builds.
|
||||||
//
|
//
|
||||||
// S1 state: the MAILBOX is live — deferred browser callbacks (wx timers, via
|
// S2 state: this file REPLACES the legacy handlesleep.js on scheduler builds (the
|
||||||
// wx/wasm/private/mailbox.h) are enqueued here on expiry and delivered by the wx
|
// injector's either-or flip). It carries:
|
||||||
// event pump (evtloop.cpp wxWasmMailboxDeliver) from a clean dispatch context,
|
// S1 · the wx mailbox (timer/wheel messages, wx/wasm/private/mailbox.h) and the
|
||||||
// only when the dispatch interlock is free. The legacy handlesleep.js shim
|
// embind mutator lane (doc 18 classification).
|
||||||
// (injected just above) remains authoritative for currData until S2, when the
|
// S2 · the scheduler core — every behavior the legacy shim provided (per-sleep
|
||||||
// scheduler core (registry, deferred drain, fiber tracking) lands here gated on
|
// currData capture/restore, the stale-fiber consume-once/quarantine guard,
|
||||||
// the N1 single-writer tripwire.
|
// wake-window flags, flight recorder, trampoline heal) PLUS:
|
||||||
|
// - DEFERRED WAKES: a sleep wake arriving while a transition is in flight
|
||||||
|
// (state != Normal, or the fiber trampoline mid-loop) is queued and
|
||||||
|
// delivered from a clean macrotask when the slot frees — the aliased-wake
|
||||||
|
// class becomes unrepresentable instead of merely detected (doc 12 §law).
|
||||||
|
// - N1 SINGLE-WRITER TRIPWIRE: Asyncify.currData is an accessor; a write
|
||||||
|
// from pure JS (no wasm frames on the export stack) without scheduler
|
||||||
|
// authorization is a STRAY — beaconed, counted, and (opt-in strict mode)
|
||||||
|
// thrown. Wasm-driven writes (fiber_swap, handleSleep internals) are
|
||||||
|
// runtime-legitimate and pass through.
|
||||||
|
// Contract surfaces kept name-identical (external readers!): Asyncify.__wakingRoot
|
||||||
|
// (libcontext EM_JS wasm_root_wake_in_flight), __inSleepWake, __wakingOwnerFiber,
|
||||||
|
// __pendingSleepContexts, Fibers.__fcsTotal/__rootHotTotal/__rootFiber/
|
||||||
|
// __validSuspensions/__internallyParked/__parkSleepBuf/__inFiberEntry,
|
||||||
|
// the "[wx-asyncify] STATE"/"RECORDER" dump formats, window.__wxAsyncifyDump.
|
||||||
if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
|
if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
|
||||||
globalThis.__wxSchedulerInstalled = true;
|
globalThis.__wxSchedulerInstalled = true;
|
||||||
Asyncify.__schedulerBuild = 1;
|
Asyncify.__schedulerBuild = 1;
|
||||||
|
|
||||||
var AsyncifyScheduler = {
|
var AsyncifyScheduler = {
|
||||||
// --- S1 mailbox -------------------------------------------------------
|
// --- S1 wx mailbox ----------------------------------------------------
|
||||||
// Due messages, FIFO. {fn, arg} are wasm function-pointer / pointer ints;
|
|
||||||
// the C side (wxWasmMailboxDeliver) pops and calls them. Exactly-once:
|
|
||||||
// a message stays queued until the pump delivers it — there is no drop
|
|
||||||
// path, matching the emscripten_async_call contract it replaces.
|
|
||||||
mailbox: [],
|
mailbox: [],
|
||||||
enqueued: 0,
|
enqueued: 0,
|
||||||
delivered: 0,
|
delivered: 0,
|
||||||
|
|
@ -41,9 +51,6 @@ if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
|
||||||
// Deliver via a dedicated PLAIN export call (wxWasmMailboxTick), never
|
// Deliver via a dedicated PLAIN export call (wxWasmMailboxTick), never
|
||||||
// from inside a pump's awaited ProcessEvents ccall — a fiber swap there
|
// from inside a pump's awaited ProcessEvents ccall — a fiber swap there
|
||||||
// sits on the JS-awaits-a-suspending-export boundary (#13302) and traps.
|
// sits on the JS-awaits-a-suspending-export boundary (#13302) and traps.
|
||||||
// Re-arms at 17ms while messages remain (the interlock may be held; the
|
|
||||||
// C side skips delivery then). One retry loop for the WHOLE queue — this
|
|
||||||
// replaces the per-timer retry storms of the legacy path.
|
|
||||||
_armDeliveryTick: function () {
|
_armDeliveryTick: function () {
|
||||||
if (this._tickArmed) return;
|
if (this._tickArmed) return;
|
||||||
this._tickArmed = true;
|
this._tickArmed = true;
|
||||||
|
|
@ -53,8 +60,6 @@ if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
|
||||||
if (Module["_wxWasmMailboxTick"]) Module["_wxWasmMailboxTick"]();
|
if (Module["_wxWasmMailboxTick"]) Module["_wxWasmMailboxTick"]();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
self._tickArmed = false;
|
self._tickArmed = false;
|
||||||
// Mirror wxWasmScheduleProcessEvents' guard: a trapped delivery
|
|
||||||
// must not leave the interlock held or a parked nested DoRun stuck.
|
|
||||||
if (Module["_wx_dispatch_abandon"]) Module["_wx_dispatch_abandon"]();
|
if (Module["_wx_dispatch_abandon"]) Module["_wx_dispatch_abandon"]();
|
||||||
var exits = Module["_wxNestedLoopExit"];
|
var exits = Module["_wxNestedLoopExit"];
|
||||||
if (exits && exits.length) (exits.pop())();
|
if (exits && exits.length) (exits.pop())();
|
||||||
|
|
@ -69,16 +74,6 @@ if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- S1 embind lane ---------------------------------------------------
|
// --- 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: [
|
MUTATOR_NAMES: [
|
||||||
"kicadSetChrome", "kicadSetReadOnly",
|
"kicadSetChrome", "kicadSetReadOnly",
|
||||||
"kicadCollabApply", "kicadCollabApplyItems",
|
"kicadCollabApply", "kicadCollabApplyItems",
|
||||||
|
|
@ -128,20 +123,15 @@ if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
|
||||||
? function () { return performance.now(); }
|
? function () { return performance.now(); }
|
||||||
: function () { return Date.now(); };
|
: function () { return Date.now(); };
|
||||||
setTimeout(function pump() {
|
setTimeout(function pump() {
|
||||||
// The pump must be unkillable: any exception escaping this body would
|
// Unkillable: an exception escaping this body would end the setTimeout
|
||||||
// end the setTimeout chain and wedge the queue forever (observed: 559
|
// chain and wedge the queue forever (observed: 559 frozen messages).
|
||||||
// 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 {
|
try {
|
||||||
if (!self._openBusy()) {
|
if (!self._openBusy()) {
|
||||||
// Time-boxed drain: a long queue (a hammer of snapshots against a
|
// Time-boxed drain: ~8 ms of work per 16 ms tick keeps the page
|
||||||
// big board) must not monopolize the main thread in one burst —
|
// live while a long backlog drains in order.
|
||||||
// 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();
|
var t0 = now();
|
||||||
while (self.mutatorQueue.length > 0 && now() - t0 < 8) {
|
while (self.mutatorQueue.length > 0 && now() - t0 < 8) {
|
||||||
if (self._openBusy()) break; // a delivered call re-opened the window
|
if (self._openBusy()) break;
|
||||||
var m = self.mutatorQueue.shift();
|
var m = self.mutatorQueue.shift();
|
||||||
self.mutatorsDelivered++;
|
self.mutatorsDelivered++;
|
||||||
try { m.resolve(m.call()); } catch (e) { m.reject(e); }
|
try { m.resolve(m.call()); } catch (e) { m.reject(e); }
|
||||||
|
|
@ -158,41 +148,399 @@ if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
|
||||||
}, 16);
|
}, 16);
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- S2 scheduler core (not yet live) ---------------------------------
|
// --- S2 scheduler core state -------------------------------------------
|
||||||
// ctx = { id, kind: 'main'|'modal'|'nested'|'coroutine'|'sleep',
|
// Deferred sleep wakes: {deliver, result} queued because a transition was
|
||||||
// buffer, status: 'running'|'parked'|'ready', wakeReason, result }
|
// in flight when the wake arrived. Delivered FIFO from a clean macrotask.
|
||||||
contexts: new Map(),
|
readyWakes: [],
|
||||||
readyQueue: [],
|
deferredWakes: 0,
|
||||||
running: null,
|
drainedWakes: 0,
|
||||||
transitionRunning: false,
|
_wakeDrainArmed: false,
|
||||||
trampolineRunning: false,
|
// N1: pure-JS currData writes seen without scheduler authorization.
|
||||||
|
strayWrites: 0,
|
||||||
// S2 fills these in. They throw today so a premature caller is loud, not
|
strictStrays: false, // tests set true → stray throws instead of beaconing
|
||||||
// silent — nothing in an S1 build calls them.
|
_authorizedWrite: 0,
|
||||||
park: function () { throw new Error("[wx-scheduler] park(): not implemented until S2"); },
|
authorize: function (fn) {
|
||||||
resume: function () { throw new Error("[wx-scheduler] resume(): not implemented until S2"); },
|
this._authorizedWrite++;
|
||||||
drain: function () { throw new Error("[wx-scheduler] drain(): not implemented until S2"); },
|
try { return fn(); } finally { this._authorizedWrite--; }
|
||||||
|
},
|
||||||
|
|
||||||
state: function () {
|
state: function () {
|
||||||
return "[wx-scheduler] build=1 impl=S1-mailbox"
|
return "[wx-scheduler] build=1 impl=S2-core"
|
||||||
+ " mailbox=" + this.mailbox.length
|
+ " mailbox=" + this.mailbox.length
|
||||||
+ " enqueued=" + this.enqueued
|
+ " enqueued=" + this.enqueued
|
||||||
+ " delivered=" + this.delivered
|
+ " delivered=" + this.delivered
|
||||||
+ " mutQ=" + this.mutatorQueue.length
|
+ " mutQ=" + this.mutatorQueue.length
|
||||||
+ " mutWrapped=" + this.mutatorsWrapped
|
+ " mutWrapped=" + this.mutatorsWrapped
|
||||||
+ " mutDelivered=" + this.mutatorsDelivered
|
+ " mutDelivered=" + this.mutatorsDelivered
|
||||||
+ " contexts=" + this.contexts.size
|
+ " readyWakes=" + this.readyWakes.length
|
||||||
+ " ready=" + this.readyQueue.length
|
+ " deferredWakes=" + this.deferredWakes
|
||||||
+ " transition=" + this.transitionRunning;
|
+ " drainedWakes=" + this.drainedWakes
|
||||||
|
+ " strayWrites=" + this.strayWrites;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
globalThis.__wxScheduler = AsyncifyScheduler;
|
globalThis.__wxScheduler = AsyncifyScheduler;
|
||||||
|
|
||||||
// Wrap the embind mutators once the runtime has registered them. The shim
|
// ======================================================================
|
||||||
// executes at glue load (before instantiation), so chaining
|
// S2 core install. Skipped defensively if the legacy shim somehow got in
|
||||||
// onRuntimeInitialized is normally enough; the calledRun branch covers a
|
// first — double-managing the wake path corrupts (the injector's either-or
|
||||||
// shim injected into an already-running Module (defensive).
|
// flip should make this unreachable).
|
||||||
|
// ======================================================================
|
||||||
|
if (Asyncify.__nestedHandleSleepInstalled) {
|
||||||
|
console.warn("[wx-scheduler] legacy handlesleep present - S2 core NOT installed (dual-management guard)");
|
||||||
|
} else if (typeof Asyncify.handleSleep === "function"
|
||||||
|
&& typeof Asyncify.allocateData === "function") {
|
||||||
|
|
||||||
|
// --- flight recorder + state dump (ported verbatim-in-spirit from
|
||||||
|
// handlesleep.js; formats are parsed by guard-beacons.ts and
|
||||||
|
// apps/tests/tools/repro-board-load.ts — do not change shapes) ---------
|
||||||
|
Asyncify.__pendingSleepContexts = [];
|
||||||
|
var __recMax = 96;
|
||||||
|
Asyncify.__rec = [];
|
||||||
|
var __rec = function (ev) {
|
||||||
|
var r = Asyncify.__rec;
|
||||||
|
r.push(((typeof performance !== "undefined" ? performance.now() : 0) | 0) + " " + ev);
|
||||||
|
if (r.length > __recMax) r.shift();
|
||||||
|
};
|
||||||
|
Asyncify.__recPush = __rec;
|
||||||
|
|
||||||
|
var __dumpState = function () {
|
||||||
|
var F = (typeof Fibers !== "undefined") ? Fibers : null;
|
||||||
|
var pend = Array.isArray(Asyncify.__pendingSleepContexts)
|
||||||
|
? Asyncify.__pendingSleepContexts.map(function (c) { return c.capturedData || 0; }).join(",")
|
||||||
|
: "n/a";
|
||||||
|
var head = "[wx-asyncify] STATE"
|
||||||
|
+ " state=" + Asyncify.state
|
||||||
|
+ " currData=" + (Asyncify.currData || 0)
|
||||||
|
+ " inSleepWake=" + (Asyncify.__inSleepWake || 0)
|
||||||
|
+ " exportStack=" + (Asyncify.exportCallStack ? Asyncify.exportCallStack.length : -1)
|
||||||
|
+ " pendingSleeps=[" + pend + "]"
|
||||||
|
+ (F ? (" nextFiber=" + F.nextFiber
|
||||||
|
+ " trampolining=" + F.trampolineRunning
|
||||||
|
+ " root=" + F.__rootFiber
|
||||||
|
+ " fcsTotal=" + (F.__fcsTotal || 0)
|
||||||
|
+ " rootHotTotal=" + (F.__rootHotTotal || 0)
|
||||||
|
+ " valid=[" + (F.__validSuspensions ? Array.from(F.__validSuspensions).join(",") : "") + "]"
|
||||||
|
+ " parked=[" + (F.__internallyParked ? Array.from(F.__internallyParked).join(",") : "") + "]"
|
||||||
|
+ " deferrals=" + (F.__rootDeferrals || 0))
|
||||||
|
: " (no Fibers)")
|
||||||
|
+ " | " + AsyncifyScheduler.state();
|
||||||
|
return head + "\n[wx-asyncify] RECORDER (oldest first):\n " + Asyncify.__rec.join("\n ");
|
||||||
|
};
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.__wxAsyncifyDump = __dumpState;
|
||||||
|
var __dumps = 0;
|
||||||
|
var __onTrap = function (msg) {
|
||||||
|
if (__dumps >= 2) return;
|
||||||
|
if (!/index out of bounds|unreachable executed|table index|indirect call signature|null function or function signature|memory access out of bounds/i.test(msg)) return;
|
||||||
|
++__dumps;
|
||||||
|
try { console.error(__dumpState()); } catch (e) {}
|
||||||
|
};
|
||||||
|
window.addEventListener("error", function (e) {
|
||||||
|
__onTrap(e && e.error instanceof Error ? e.error.message : String((e && e.message) || ""));
|
||||||
|
});
|
||||||
|
window.addEventListener("unhandledrejection", function (e) {
|
||||||
|
__onTrap(e && e.reason instanceof Error ? e.reason.message : String((e && e.reason) || ""));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var __wxAsyncifyReport = (function () {
|
||||||
|
var counts = {};
|
||||||
|
return function (kind, msg, withStack) {
|
||||||
|
var n = (counts[kind] = (counts[kind] || 0) + 1);
|
||||||
|
if (n > 10 && n % 100 !== 0) return;
|
||||||
|
var line = "[wx-asyncify] " + kind + ": " + msg + " (occurrence " + n + ")";
|
||||||
|
if (withStack) {
|
||||||
|
try { line += "\n" + String(new Error().stack).split("\n").slice(1, 8).join("\n"); } catch (e) {}
|
||||||
|
}
|
||||||
|
console.warn(line);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// --- N1: single-writer accessor on Asyncify.currData ------------------
|
||||||
|
// Writes made while compiled code is on the export stack are the wasm
|
||||||
|
// runtime's own (fiber_swap, handleSleep's park/stop paths) — legitimate.
|
||||||
|
// A pure-JS write (empty export stack) must come from a scheduler-
|
||||||
|
// authorized span; anything else is a STRAY: the exact shape of every
|
||||||
|
// historical corruption's bad write. Beacon + count; strict mode throws.
|
||||||
|
(function () {
|
||||||
|
var realCurrData = Asyncify.currData; // null at install time
|
||||||
|
Object.defineProperty(Asyncify, "currData", {
|
||||||
|
configurable: true,
|
||||||
|
get: function () { return realCurrData; },
|
||||||
|
set: function (v) {
|
||||||
|
if ((!Asyncify.exportCallStack || Asyncify.exportCallStack.length === 0)
|
||||||
|
&& AsyncifyScheduler._authorizedWrite === 0
|
||||||
|
&& !(typeof Fibers !== "undefined" && Fibers.trampolineRunning)) {
|
||||||
|
AsyncifyScheduler.strayWrites++;
|
||||||
|
__wxAsyncifyReport("stray-currdata-write",
|
||||||
|
"currData=" + (v || 0) + " written from pure JS without scheduler authorization", true);
|
||||||
|
if (AsyncifyScheduler.strictStrays)
|
||||||
|
throw new Error("[wx-scheduler] stray currData write (strict mode)");
|
||||||
|
}
|
||||||
|
realCurrData = v;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// --- deferred-wake drain ----------------------------------------------
|
||||||
|
var __transitionFree = function () {
|
||||||
|
return Asyncify.state === 0
|
||||||
|
&& !(typeof Fibers !== "undefined" && Fibers.trampolineRunning);
|
||||||
|
};
|
||||||
|
AsyncifyScheduler._scheduleWakeDrain = function () {
|
||||||
|
if (this._wakeDrainArmed) return;
|
||||||
|
this._wakeDrainArmed = true;
|
||||||
|
var self = this;
|
||||||
|
setTimeout(function () {
|
||||||
|
self._wakeDrainArmed = false;
|
||||||
|
// Deliver from a CLEAN macrotask (export stack empty by construction).
|
||||||
|
while (self.readyWakes.length > 0 && __transitionFree()) {
|
||||||
|
var w = self.readyWakes.shift();
|
||||||
|
self.drainedWakes++;
|
||||||
|
w.deliver(w.result);
|
||||||
|
}
|
||||||
|
if (self.readyWakes.length > 0) self._scheduleWakeDrain();
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- handleSleep wrap: registry + capture/restore + deferral ----------
|
||||||
|
var __originalAllocateData = Asyncify.allocateData.bind(Asyncify);
|
||||||
|
Asyncify.allocateData = function () {
|
||||||
|
var ptr = __originalAllocateData();
|
||||||
|
for (var i = Asyncify.__pendingSleepContexts.length - 1; i >= 0; --i) {
|
||||||
|
var ctx = Asyncify.__pendingSleepContexts[i];
|
||||||
|
if (!ctx.capturedData) {
|
||||||
|
ctx.capturedData = ptr;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
var __originalHandleSleep = Asyncify.handleSleep.bind(Asyncify);
|
||||||
|
Asyncify.handleSleep = function (startAsync) {
|
||||||
|
__rec("sleep s=" + Asyncify.state + " cd=" + (Asyncify.currData || 0)
|
||||||
|
+ " w=" + (Asyncify.__inSleepWake || 0));
|
||||||
|
if (Asyncify.state === 0 && Asyncify.currData) {
|
||||||
|
__wxAsyncifyReport("concurrent-park",
|
||||||
|
"handleSleep entered while currData=" + Asyncify.currData, true);
|
||||||
|
}
|
||||||
|
if (Asyncify.state === 1) {
|
||||||
|
__wxAsyncifyReport("reentrant-state",
|
||||||
|
"handleSleep entered mid-unwind (state=1) currData=" + Asyncify.currData, true);
|
||||||
|
}
|
||||||
|
// Only a FRESH park (state 0) allocates data and needs tracking; the
|
||||||
|
// state-2 resume re-entry returns synchronously through the rewind
|
||||||
|
// branch (a context pushed for it leaks one per resume).
|
||||||
|
if (Asyncify.state !== 0) {
|
||||||
|
return __originalHandleSleep(startAsync);
|
||||||
|
}
|
||||||
|
var sleepCtx = {
|
||||||
|
capturedData: null,
|
||||||
|
cleanedUp: false,
|
||||||
|
rootOwned: (typeof Fibers === "undefined")
|
||||||
|
|| (!Fibers.__inFiberEntry
|
||||||
|
&& !(Asyncify.__wakingOwnerFiber || false)),
|
||||||
|
};
|
||||||
|
Asyncify.__pendingSleepContexts.push(sleepCtx);
|
||||||
|
|
||||||
|
var cleanup = function () {
|
||||||
|
if (sleepCtx.cleanedUp) return;
|
||||||
|
sleepCtx.cleanedUp = true;
|
||||||
|
var idx = Asyncify.__pendingSleepContexts.indexOf(sleepCtx);
|
||||||
|
if (idx !== -1) Asyncify.__pendingSleepContexts.splice(idx, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return __originalHandleSleep(function (wakeUp) {
|
||||||
|
// deliver(): the legacy shim's whole wake path — restore OUR buffer,
|
||||||
|
// mark the wake window, swallow the "unwind" sentinel.
|
||||||
|
var deliver = function (result) {
|
||||||
|
__rec("wake buf=" + (sleepCtx.capturedData || 0) + " cdWas=" + (Asyncify.currData || 0)
|
||||||
|
+ (sleepCtx.rootOwned ? " R" : " f"));
|
||||||
|
if (sleepCtx.capturedData) {
|
||||||
|
if (Asyncify.currData !== sleepCtx.capturedData) {
|
||||||
|
__wxAsyncifyReport(
|
||||||
|
Asyncify.currData ? "aliased-wake-live" : "overlapped-wake",
|
||||||
|
"restoring currData=" + sleepCtx.capturedData +
|
||||||
|
" over " + (Asyncify.currData || "null") +
|
||||||
|
" state=" + Asyncify.state, !!Asyncify.currData);
|
||||||
|
}
|
||||||
|
AsyncifyScheduler.authorize(function () {
|
||||||
|
Asyncify.currData = sleepCtx.capturedData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
Asyncify.__inSleepWake = (Asyncify.__inSleepWake || 0) + 1;
|
||||||
|
var prevWakingOwnerFiber = Asyncify.__wakingOwnerFiber || false;
|
||||||
|
Asyncify.__wakingOwnerFiber = !sleepCtx.rootOwned;
|
||||||
|
var prevWakingRoot = Asyncify.__wakingRoot || 0;
|
||||||
|
if (sleepCtx.rootOwned) Asyncify.__wakingRoot = (Asyncify.__wakingRoot || 0) + 1;
|
||||||
|
try {
|
||||||
|
return wakeUp(result);
|
||||||
|
} catch (e) {
|
||||||
|
if (e === "unwind") return;
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
Asyncify.__inSleepWake -= 1;
|
||||||
|
Asyncify.__wakingOwnerFiber = prevWakingOwnerFiber;
|
||||||
|
if (sleepCtx.rootOwned) Asyncify.__wakingRoot = prevWakingRoot;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return startAsync(function (result) {
|
||||||
|
// THE S2 LAW (doc 12): a wake never starts a rewind while another
|
||||||
|
// transition is in flight — it enqueues and the drain delivers
|
||||||
|
// from a clean macrotask when the slot frees. The legacy shim
|
||||||
|
// could only beacon this window (aliased-wake-live); the
|
||||||
|
// scheduler removes it.
|
||||||
|
if (!__transitionFree()) {
|
||||||
|
AsyncifyScheduler.deferredWakes++;
|
||||||
|
__rec("defer-wake buf=" + (sleepCtx.capturedData || 0)
|
||||||
|
+ " s=" + Asyncify.state);
|
||||||
|
AsyncifyScheduler.readyWakes.push({ deliver: deliver, result: result });
|
||||||
|
AsyncifyScheduler._scheduleWakeDrain();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return deliver(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
cleanup();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transition-completion signal: maybeStopUnwind is where an unwind
|
||||||
|
// finishes (state → Normal) and the trampoline runs queued fiber
|
||||||
|
// switches. After it settles, deferred wakes may proceed.
|
||||||
|
var __originalMaybeStopUnwind = Asyncify.maybeStopUnwind.bind(Asyncify);
|
||||||
|
Asyncify.maybeStopUnwind = function () {
|
||||||
|
var ret = __originalMaybeStopUnwind();
|
||||||
|
if (AsyncifyScheduler.readyWakes.length > 0 && __transitionFree())
|
||||||
|
AsyncifyScheduler._scheduleWakeDrain();
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
|
||||||
|
Asyncify.__nestedHandleSleepInstalled = true; // compat: tools probe this
|
||||||
|
console.log("[wx-scheduler] S2 core installed (deferred wakes + N1 accessor)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- stale-fiber-rewind guard (ported from handlesleep.js; semantics
|
||||||
|
// unchanged — these encode the consume-once/quarantine contracts of
|
||||||
|
// docs/features/async/16) + trampoline heal ownership -------------------
|
||||||
|
if (typeof Fibers !== "undefined"
|
||||||
|
&& typeof Fibers.finishContextSwitch === "function"
|
||||||
|
&& !Fibers.__staleRewindGuardInstalled) {
|
||||||
|
Fibers.__validSuspensions = new Set();
|
||||||
|
Fibers.__internallyParked = new Set();
|
||||||
|
Fibers.__parkSleepBuf = new Map();
|
||||||
|
|
||||||
|
var __origFinishContextSwitch = Fibers.finishContextSwitch.bind(Fibers);
|
||||||
|
var __fiberRefusals = 0;
|
||||||
|
|
||||||
|
var __fcsRec = (typeof Asyncify !== "undefined" && Asyncify.__recPush)
|
||||||
|
? Asyncify.__recPush
|
||||||
|
: function () {};
|
||||||
|
|
||||||
|
var __refuseFiber = function (newFiber, why) {
|
||||||
|
__fcsRec("refuse new=" + newFiber);
|
||||||
|
++__fiberRefusals;
|
||||||
|
if (__fiberRefusals <= 10 || __fiberRefusals % 100 === 0) {
|
||||||
|
console.warn("[wx-asyncify] fiber-resume-refused: fiber=" + newFiber + " " + why
|
||||||
|
+ " (occurrence " + __fiberRefusals + ")");
|
||||||
|
}
|
||||||
|
AsyncifyScheduler.authorize(function () {
|
||||||
|
Asyncify.currData = null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Fibers.finishContextSwitch = function (newFiber) {
|
||||||
|
Fibers.__fcsTotal = (Fibers.__fcsTotal || 0) + 1;
|
||||||
|
if (newFiber === Fibers.__rootFiber && (Asyncify.__inSleepWake || 0) > 0) {
|
||||||
|
Fibers.__rootHotTotal = (Fibers.__rootHotTotal || 0) + 1;
|
||||||
|
}
|
||||||
|
var __remStr = "";
|
||||||
|
if (Asyncify.currData) {
|
||||||
|
var __H = (typeof GROWABLE_HEAP_U32 === "function") ? GROWABLE_HEAP_U32() : HEAPU32;
|
||||||
|
__remStr = " rem=" + (__H[((Asyncify.currData + 4) >>> 2) >>> 0] - __H[(Asyncify.currData >>> 2) >>> 0])
|
||||||
|
+ " rf=" + (Asyncify.getDataRewindFuncName ? Asyncify.getDataRewindFuncName(Asyncify.currData) : "?")
|
||||||
|
+ " es=[" + (Asyncify.exportCallStack || []).join("|") + "]";
|
||||||
|
}
|
||||||
|
__fcsRec("fcs old=" + (Asyncify.currData ? Asyncify.currData - 20 : 0)
|
||||||
|
+ " new=" + newFiber
|
||||||
|
+ (newFiber === Fibers.__rootFiber ? " ROOT" : "")
|
||||||
|
+ " w=" + (Asyncify.__inSleepWake || 0) + __remStr);
|
||||||
|
if (Asyncify.currData) {
|
||||||
|
var oldFiber = Asyncify.currData - 20;
|
||||||
|
if (Fibers.__rootFiber === undefined) {
|
||||||
|
Fibers.__rootFiber = oldFiber;
|
||||||
|
}
|
||||||
|
var parkBuf = Fibers.__parkSleepBuf.get(oldFiber);
|
||||||
|
var stillParked = parkBuf !== undefined
|
||||||
|
&& Array.isArray(Asyncify.__pendingSleepContexts)
|
||||||
|
&& Asyncify.__pendingSleepContexts.some(function (c) { return c.capturedData === parkBuf; });
|
||||||
|
if (!stillParked) {
|
||||||
|
Fibers.__validSuspensions.add(oldFiber);
|
||||||
|
Fibers.__internallyParked.delete(oldFiber);
|
||||||
|
Fibers.__parkSleepBuf.delete(oldFiber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isRoot = newFiber === Fibers.__rootFiber;
|
||||||
|
var HEAPU32v = (typeof GROWABLE_HEAP_U32 === "function") ? GROWABLE_HEAP_U32() : HEAPU32;
|
||||||
|
var entryPoint = HEAPU32v[((newFiber + 12) >>> 2) >>> 0];
|
||||||
|
if (!isRoot && Fibers.__internallyParked.has(newFiber)) {
|
||||||
|
__refuseFiber(newFiber, "is asyncify-parked mid-body (sleep in flight)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entryPoint === 0) {
|
||||||
|
if (!Fibers.__validSuspensions.has(newFiber)) {
|
||||||
|
__refuseFiber(newFiber, isRoot
|
||||||
|
? "root suspension already consumed - a second rewind would replay stale frames"
|
||||||
|
: "has no live suspension - rewinding would replay stale data");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Fibers.__validSuspensions.delete(newFiber);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRoot) Fibers.__inFiberEntry = (Fibers.__inFiberEntry || 0) + 1;
|
||||||
|
var ret;
|
||||||
|
try {
|
||||||
|
// The original writes currData (entry path nulls it, resume path sets
|
||||||
|
// the fiber's buffer) from pure JS — scheduler-supervised here.
|
||||||
|
ret = AsyncifyScheduler.authorize(function () {
|
||||||
|
return __origFinishContextSwitch(newFiber);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (!isRoot) Fibers.__inFiberEntry -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRoot && !Fibers.nextFiber && Asyncify.currData) {
|
||||||
|
Fibers.__internallyParked.add(newFiber);
|
||||||
|
Fibers.__parkSleepBuf.set(newFiber, Asyncify.currData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trampoline heal ownership (subsumes inject-dyncall-shims §3c): a throw
|
||||||
|
// escaping the trampoline loop must not leave trampolineRunning wedged —
|
||||||
|
// that guard being stuck turns every later fiber swap into a silent no-op.
|
||||||
|
var __origTrampoline = Fibers.trampoline.bind(Fibers);
|
||||||
|
Fibers.trampoline = function () {
|
||||||
|
try {
|
||||||
|
return __origTrampoline();
|
||||||
|
} catch (e) {
|
||||||
|
Fibers.trampolineRunning = false;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Fibers.__staleRewindGuardInstalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap the embind mutators once the runtime has registered them.
|
||||||
if (typeof Module !== "undefined") {
|
if (typeof Module !== "undefined") {
|
||||||
if (Module["calledRun"]) {
|
if (Module["calledRun"]) {
|
||||||
AsyncifyScheduler._wrapMutators();
|
AsyncifyScheduler._wrapMutators();
|
||||||
|
|
@ -205,6 +553,6 @@ if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[wx-scheduler] scaffolding installed (S1, mailbox live)");
|
console.log("[wx-scheduler] scaffolding installed (S2, core live)");
|
||||||
}
|
}
|
||||||
// === End AsyncifyScheduler ===
|
// === End AsyncifyScheduler ===
|
||||||
|
|
|
||||||
122
tests/asyncify/asyncify-races-scheduler.spec.ts
Normal file
122
tests/asyncify/asyncify-races-scheduler.spec.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { test, expect, tryLoadApp } from '../e2e/utils/fixtures';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S2 scheduler-core gates (docs/features/async/17 §3d N1/N4, §4 S2).
|
||||||
|
* Runs against the races harness built with WX_SCHEDULER=1 (scheduler-only
|
||||||
|
* glue — the legacy handlesleep shim is NOT injected on scheduler builds).
|
||||||
|
* Both tests self-skip on legacy glue, so the file is safe in either variant.
|
||||||
|
*
|
||||||
|
* N1 — single-writer tripwire: Asyncify.currData is an accessor; a pure-JS
|
||||||
|
* write without scheduler authorization beacons (and throws in strict mode).
|
||||||
|
* The meta-test INTRODUCES a stray writer and expects the tripwire to fire —
|
||||||
|
* proving the alarm works, not merely that nobody tripped it.
|
||||||
|
*
|
||||||
|
* N4 — wake-never-rewinds-mid-transition: across the races battery (which
|
||||||
|
* stages overlapping parks, nested modals, out-of-order wakes) the
|
||||||
|
* scheduler's books must be coherent at settle: no queued wake left, every
|
||||||
|
* deferral drained, zero unplanned strays, battery green.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type SchedulerState = {
|
||||||
|
state(): string;
|
||||||
|
strayWrites: number;
|
||||||
|
strictStrays: boolean;
|
||||||
|
readyWakes: unknown[];
|
||||||
|
deferredWakes: number;
|
||||||
|
drainedWakes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function findSummary(logs: string[]) {
|
||||||
|
return logs.find((log) => log.includes('[ASYNCIFY_RACES] SUMMARY'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootAndSettle(
|
||||||
|
page: import('@playwright/test').Page,
|
||||||
|
testLogger: { consoleLogs: string[] },
|
||||||
|
): Promise<boolean> {
|
||||||
|
await page.goto('/standalone/asyncify-races/races_test.html');
|
||||||
|
const loaded = await tryLoadApp(page, 30000);
|
||||||
|
expect(loaded, 'races harness should load').toBe(true);
|
||||||
|
await expect
|
||||||
|
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
|
||||||
|
timeout: 60000,
|
||||||
|
message: 'battery should emit its SUMMARY line',
|
||||||
|
})
|
||||||
|
.not.toBeNull();
|
||||||
|
return page.evaluate(
|
||||||
|
() => !!(globalThis as unknown as { __wxScheduler?: unknown }).__wxScheduler,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('S2 scheduler core (WX_SCHEDULER=1 glue)', () => {
|
||||||
|
test('N4: battery leaves coherent books — wakes drained, no strays, battery green', async ({
|
||||||
|
page,
|
||||||
|
testLogger,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(180000);
|
||||||
|
const scheduler = await bootAndSettle(page, testLogger);
|
||||||
|
test.skip(!scheduler, 'legacy glue — scheduler core absent');
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() =>
|
||||||
|
page.evaluate(
|
||||||
|
() =>
|
||||||
|
(globalThis as unknown as { __wxScheduler: SchedulerState }).__wxScheduler
|
||||||
|
.readyWakes.length,
|
||||||
|
),
|
||||||
|
{ timeout: 30000, intervals: [250] },
|
||||||
|
)
|
||||||
|
.toBe(0);
|
||||||
|
|
||||||
|
const books = await page.evaluate(() => {
|
||||||
|
const S = (globalThis as unknown as { __wxScheduler: SchedulerState }).__wxScheduler;
|
||||||
|
return {
|
||||||
|
ready: S.readyWakes.length,
|
||||||
|
deferred: S.deferredWakes,
|
||||||
|
drained: S.drainedWakes,
|
||||||
|
strays: S.strayWrites,
|
||||||
|
state: S.state(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log(`[TEST] scheduler books: ${books.state}`);
|
||||||
|
|
||||||
|
expect(books.ready, 'no wake left queued after settle').toBe(0);
|
||||||
|
expect(books.drained, 'every deferred wake was drained').toBe(books.deferred);
|
||||||
|
expect(books.strays, 'no stray currData writes during the battery').toBe(0);
|
||||||
|
const fails = testLogger.consoleLogs.filter((l) => l.includes('[ASYNCIFY_RACES] FAIL '));
|
||||||
|
expect(fails, 'battery green under the scheduler core').toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('N1 meta: an introduced stray currData write trips the alarm', async ({
|
||||||
|
page,
|
||||||
|
testLogger,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(180000);
|
||||||
|
const scheduler = await bootAndSettle(page, testLogger);
|
||||||
|
test.skip(!scheduler, 'legacy glue — scheduler core absent');
|
||||||
|
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const S = (globalThis as unknown as { __wxScheduler: SchedulerState }).__wxScheduler;
|
||||||
|
const A = (globalThis as unknown as { Asyncify: { currData: number | null } }).Asyncify;
|
||||||
|
const before = S.strayWrites;
|
||||||
|
const saved = A.currData;
|
||||||
|
A.currData = saved; // value-preserving, still a stray WRITE
|
||||||
|
const counted = S.strayWrites === before + 1;
|
||||||
|
S.strictStrays = true;
|
||||||
|
let threw = false;
|
||||||
|
try {
|
||||||
|
A.currData = saved;
|
||||||
|
} catch {
|
||||||
|
threw = true;
|
||||||
|
}
|
||||||
|
S.strictStrays = false;
|
||||||
|
return { counted, threw };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.counted, 'stray write was counted').toBe(true);
|
||||||
|
expect(result.threw, 'strict mode throws on stray write').toBe(true);
|
||||||
|
const beacons = testLogger.consoleLogs.filter((l) => l.includes('stray-currdata-write'));
|
||||||
|
expect(beacons.length, 'stray write beaconed to the console').toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue