pcbjam/tests/apps/standalone/jspi-stack/driver.mjs

162 lines
5.6 KiB
JavaScript
Raw Normal View History

jspi: migration phases 0-7 — build knob, scheduler shim, test successor suite Toolchain: emsdk 6.0.6 (versions.sh; cache-hash keys on it). Build knob PCBJAM_ASYNC_BACKEND=jspi|asyncify: build-kicad-target.sh links editors with -sJSPI + -sJSPI_EXPORTS=@scripts/common/jspi-exports.txt + --pre-js jspi-scheduler.js (no DYNCALLS, no post-link asyncify pipeline); wx build stamps the backend and forces clean on flip or unknown provenance; docker/build.sh passes the knob, seeds the emscripten ports cache from the volume every launch, jspi postprocess = patch-env-shim only. scripts/common/shims/jspi-scheduler.js: the JSPI successor scheduler — token-wait registry, resume turnstile (one armed resume between engine re-entries, SP swaps only at microtask boundaries), green-region spill stacks (16-aligned tops), S1 embind mutator FIFO lane + parker wraps, S6 shutdown, libctx integration hooks (suspend/end/quarantine + g_current arm/clear), SuspendError attributor, lost-wake + stuck-window watchdogs, __wxWaitDump observability. Embind: PARKER registrations get emscripten::async() under PCBJAM_JSPI (wasm/bindings/pcbjam_async_policy.h). nanosleep yields route via the shim. Tests: tests/asyncify -> tests/jspi successor suite (jspi-stack red/green shadow-stack battery, jspi-coroutine MiniCoro harness, suspend-races semantic scenarios + __wxWaitDump books coherence); projects jspi-firefox/ jspi-chrome (asyncify-webkit retired — no JSPI in WebKit); unconditional Firefox JSPI pref; guard-beacons -> wait-beacons (+wxScheduler/libctxJspi families); Makefile.wasm links test apps against JSPI with the shim as a tracked link prerequisite. Web: WasmTool setRo await + __wxWaitDump forensics, open-flow contained promise, scheduler-shim.test.ts retargeted (8 green). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NDeBaKKhQztd8KiVtHuyXr
2026-08-13 07:06:24 +02:00
// jspi-stack driver — runs the red/green shadow-stack scenarios (#27364).
//
// node --experimental-wasm-jspi driver.mjs red
// node --experimental-wasm-jspi driver.mjs green-copy # Pyodide-style save/restore
// node --experimental-wasm-jspi driver.mjs green-region # per-activation stack region + SP swap
//
// Output contract (parsed by the future tests/jspi/jspi-stack.spec.ts):
// [JSPI_STACK] SCENARIO <name> corruptA=<n> corruptB=<n> corruptNested=<n> verdict=<RED|GREEN|UNEXPECTED>
//
// RED must observe corruption with mitigation OFF (proves the harness can see
// the bug). GREEN legs must be corruption-free across interleaved rounds and
// the nested case.
const DEPTH = 24;
const STOMP_DEPTH = 48;
const REGION_BYTES = 256 * 1024;
const mode = process.argv[2] ?? 'red';
const variant = process.argv[3] ?? 'single'; // single | pthread
const { default: factory } = await import(
variant === 'pthread' ? './stack_test_pt.mjs' : './stack_test.mjs');
// --- gate plumbing -----------------------------------------------------------
const gates = new Map(); // id -> resolve
let gateHook = null; // (id) => wrap/observe, set per mitigation
globalThis.__jspiGate = (id) => {
const base = new Promise((res) => gates.set(id, res));
return gateHook ? gateHook(id, base) : base;
};
function release(id) {
const r = gates.get(id);
if (!r) throw new Error(`no gate armed for id ${id}`);
gates.delete(id);
r(0);
}
const m = await factory();
const centralBase = m.stackSave();
// --- mitigations -------------------------------------------------------------
// Bookkeeping keyed by activation id. entryTop[id] = SP at promising entry.
const entryTop = new Map();
const actSp = new Map();
const regions = new Map(); // id -> malloc'd base (green-region)
const snapshots = new Map(); // id -> Uint8Array copy (green-copy)
// Wrap a promising-export call according to the active mitigation. Returns the
// export's promise. `centralRestore` puts the shared SP back for whatever runs
// next on the central stack.
function startActivation(id) {
if (mode === 'green-region') {
const base = regions.get(id) ?? m._malloc(REGION_BYTES);
regions.set(id, base);
const top = base + REGION_BYTES; // stacks grow down
const saved = m.stackSave();
m.stackRestore(top);
entryTop.set(id, top);
const p = m._activation(id, DEPTH);
m.stackRestore(saved);
return finishActivation(id, p);
}
entryTop.set(id, m.stackSave());
const p = m._activation(id, DEPTH);
return finishActivation(id, p);
}
// After an activation fully completes, its epilogue leaves the shared SP at
// ITS entry value — restore the central SP before any central-stack wasm runs.
function finishActivation(id, p) {
return p.then((v) => {
m.stackRestore(centralBase);
if (mode === 'green-region') {
const base = regions.get(id);
if (base) { m._free(base); regions.delete(id); }
}
return v;
});
}
if (mode === 'green-copy') {
gateHook = (id, base) => {
const sp = m.stackSave();
const top = entryTop.get(id);
snapshots.set(id, new Uint8Array(m.HEAPU8.buffer, sp, top - sp).slice());
return base.then((v) => {
// restore this activation's spilled bytes + SP right before it resumes
new Uint8Array(m.HEAPU8.buffer).set(snapshots.get(id), sp);
snapshots.delete(id);
m.stackRestore(sp);
return v;
});
};
} else if (mode === 'green-region') {
gateHook = (id, base) => {
actSp.set(id, m.stackSave());
return base.then((v) => {
m.stackRestore(actSp.get(id)); // point shared SP back into this region
return v;
});
};
}
// red: gateHook stays null — raw JSPI, no discipline.
// --- scenarios ---------------------------------------------------------------
async function interleavedRound() {
// A suspends deep; B suspends below A; A completes (epilogue resets SP above
// B's live frames); central stomp scribbles downward; B resumes and verifies.
const pA = startActivation(1);
const pB = startActivation(2);
release(1);
const corruptA = await pA;
if (mode === 'red') {
m._stomp(STOMP_DEPTH);
} else {
m.stackRestore(centralBase); // central discipline: own SP before central work
m._stomp(STOMP_DEPTH);
}
release(2);
const corruptB = await pB;
return { corruptA, corruptB };
}
async function nestedCase() {
// Second activation starts from the first activation's RESUME path — the
// "nested modal over a parked tool body" shape.
const pA = startActivation(3);
let corruptInner = -1;
const innerDone = (async () => {
// arm: when A's gate resolves, immediately start B before A's verify walk
const pB = startActivation(4);
release(4);
corruptInner = await pB;
})();
release(3);
const corruptOuter = await pA;
await innerDone;
return { corruptOuter, corruptInner };
}
let totalA = 0, totalB = 0, totalNested = 0;
if (variant === 'pthread') m._start_churn(); // cross-thread allocator churn during parks
const ROUNDS = mode === 'red' ? 1 : 3;
for (let i = 0; i < ROUNDS; i++) {
const { corruptA, corruptB } = await interleavedRound();
totalA += corruptA; totalB += corruptB;
}
const { corruptOuter, corruptInner } = await nestedCase();
totalNested = corruptOuter + corruptInner;
if (variant === 'pthread') m._stop_churn();
const corrupted = totalA + totalB + totalNested > 0;
const verdict =
mode === 'red' ? (corrupted ? 'RED' : 'UNEXPECTED')
: (corrupted ? 'UNEXPECTED' : 'GREEN');
console.log(
`[JSPI_STACK] SCENARIO ${mode} corruptA=${totalA} corruptB=${totalB} ` +
`corruptNested=${totalNested} verdict=${verdict}`
);
process.exit(verdict === 'UNEXPECTED' ? 1 : 0);