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
131 lines
4.4 KiB
C++
131 lines
4.4 KiB
C++
// jspi-stack — shadow-stack red/green harness (emscripten #27364).
|
|
//
|
|
// JSPI switches the NATIVE wasm stack per promising activation, but the C/C++
|
|
// linear-memory spill stack (__stack_pointer) is shared module state. Two
|
|
// concurrently-suspended activations therefore interleave their spill frames
|
|
// in one region, and a completed activation's epilogue resets __stack_pointer
|
|
// over a still-suspended activation's live frames. This harness makes that
|
|
// corruption OBSERVABLE (red leg) and proves a mitigation closes it (green
|
|
// leg). Mitigation policy lives entirely in the JS driver so the same binary
|
|
// serves both legs; see driver.mjs.
|
|
//
|
|
// Wx-free and libcontext-free on purpose: a failure here can only be the
|
|
// primitive (the sched-context doctrine).
|
|
|
|
#include <emscripten.h>
|
|
#include <emscripten/em_js.h>
|
|
#include <emscripten/stack.h>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
|
|
// The suspend point. Under -sJSPI every EM_ASYNC_JS import is wrapped in
|
|
// WebAssembly.Suspending automatically. The driver resolves gates by id, in
|
|
// whatever order the scenario prescribes.
|
|
EM_ASYNC_JS(int, js_gate, (int id), {
|
|
return await globalThis.__jspiGate(id);
|
|
});
|
|
|
|
namespace {
|
|
|
|
// Per-frame canary: value depends on activation id, recursion depth, and slot,
|
|
// so a frame overwritten by ANY other frame (same or different activation)
|
|
// cannot verify.
|
|
inline uint32_t canary(int id, int depth, int slot) {
|
|
return 0x9E3779B9u * (uint32_t)(id * 1000003 + depth * 8191 + slot + 1);
|
|
}
|
|
|
|
constexpr int SLOTS = 64; // 256 B of spill payload per frame
|
|
|
|
// Recurse to `depth`, stamping canaries into a stack buffer at every level;
|
|
// suspend at the bottom; verify every frame's canaries on the way back up.
|
|
// The buffer is spilled to the shadow stack (address taken via the volatile
|
|
// pointer, so it cannot live in registers/locals only). noinline keeps one
|
|
// real spill frame per recursion level.
|
|
__attribute__((noinline))
|
|
int canary_frame(int id, int depth) {
|
|
uint32_t buf[SLOTS];
|
|
volatile uint32_t* p = buf;
|
|
for (int i = 0; i < SLOTS; i++) p[i] = canary(id, depth, i);
|
|
|
|
int corrupt = 0;
|
|
if (depth > 0) {
|
|
corrupt = canary_frame(id, depth - 1);
|
|
} else {
|
|
js_gate(id); // park this activation; driver decides when it wakes
|
|
}
|
|
|
|
for (int i = 0; i < SLOTS; i++) {
|
|
if (p[i] != canary(id, depth, i)) corrupt++;
|
|
}
|
|
return corrupt;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
extern "C" {
|
|
|
|
// A promising activation (must be in JSPI_EXPORTS): recurse `depth` frames,
|
|
// suspend at the bottom, return the number of corrupted canary words observed
|
|
// while unwinding. 0 == clean.
|
|
EMSCRIPTEN_KEEPALIVE
|
|
int activation(int id, int depth) {
|
|
return canary_frame(id, depth);
|
|
}
|
|
|
|
// Plain (non-promising) export: deep central-stack work that scribbles its own
|
|
// frames. This is the "other wasm work" that grows down over a suspended
|
|
// activation's live frames once another activation's epilogue reset the SP.
|
|
EMSCRIPTEN_KEEPALIVE
|
|
__attribute__((noinline))
|
|
int stomp(int depth) {
|
|
uint32_t buf[SLOTS];
|
|
volatile uint32_t* p = buf;
|
|
for (int i = 0; i < SLOTS; i++) p[i] = 0xDEADBEEFu;
|
|
int acc = (int)p[depth % SLOTS];
|
|
if (depth > 0) acc ^= stomp(depth - 1);
|
|
return acc;
|
|
}
|
|
|
|
// Introspection for the driver's mitigation bookkeeping.
|
|
EMSCRIPTEN_KEEPALIVE uintptr_t stack_current(void) { return emscripten_stack_get_current(); }
|
|
EMSCRIPTEN_KEEPALIVE uintptr_t stack_base(void) { return emscripten_stack_get_base(); }
|
|
EMSCRIPTEN_KEEPALIVE uintptr_t stack_end(void) { return emscripten_stack_get_end(); }
|
|
|
|
} // extern "C"
|
|
|
|
#ifdef __EMSCRIPTEN_PTHREADS__
|
|
// Production shape: worker threads never suspend, but they churn the shared
|
|
// allocator while main-thread activations sit suspended. The churn thread
|
|
// must not perturb suspended activations' spill frames.
|
|
#include <atomic>
|
|
#include <thread>
|
|
#include <cstdlib>
|
|
|
|
namespace {
|
|
std::atomic<bool> g_churn{false};
|
|
std::thread g_churn_thread;
|
|
}
|
|
|
|
extern "C" {
|
|
EMSCRIPTEN_KEEPALIVE void start_churn(void) {
|
|
g_churn = true;
|
|
g_churn_thread = std::thread([] {
|
|
while (g_churn) {
|
|
void* blocks[32];
|
|
for (auto& b : blocks) b = std::malloc(64 + (rand() % 512));
|
|
for (auto& b : blocks) std::free(b);
|
|
}
|
|
});
|
|
}
|
|
EMSCRIPTEN_KEEPALIVE void stop_churn(void) {
|
|
g_churn = false;
|
|
if (g_churn_thread.joinable()) g_churn_thread.join();
|
|
}
|
|
} // extern "C"
|
|
#endif
|
|
|
|
int main() {
|
|
// Driver-controlled; nothing to do. Keep the runtime alive for export calls.
|
|
EM_ASM({ console.log("[JSPI_STACK] READY"); });
|
|
return 0;
|
|
}
|