diff --git a/tests/kicad/utils/trio.ts b/tests/kicad/utils/trio.ts index 32be8ec..11827b7 100644 --- a/tests/kicad/utils/trio.ts +++ b/tests/kicad/utils/trio.ts @@ -322,11 +322,19 @@ export async function closeTrio(trio: Trio): Promise { // ── Per-tab probes ─────────────────────────────────────────────────────────── -/** Silent save-to-MEMFS + read back — no onSave side effects. */ +/** Silent save-to-MEMFS + read back — no onSave side effects. Defers while + * collab fiber work is in flight: a bare-embind-stack save during a parked + * apply mis-dispatches (finding #10b) — the wait is JS-side, so it is safe. */ export function modelText(page: Page, cfg: ToolCfg): Promise { return page.evaluate( - ({ saveFn, ext }) => { - const w = window as unknown as { FS: FSApi; Module: Mod }; + async ({ saveFn, ext }) => { + const w = window as unknown as { + FS: FSApi; + Module: Mod & { kicadCollabFiberBusy?: () => boolean }; + }; + for (let i = 0; i < 200 && w.Module.kicadCollabFiberBusy?.(); i++) { + await new Promise((r) => setTimeout(r, 25)); + } const out = `/home/kicad/documents/_dump.${ext}`; (w.Module[saveFn] as (p: string) => unknown)(out); return w.FS.readFile(out, { encoding: "utf8" }); @@ -360,10 +368,14 @@ export interface DriftSummary { /** Item-level drift summary via the production comparator (browser-entry-v2). */ export function drift(page: Page, cfg: ToolCfg): Promise { return page.evaluate( - ({ saveFn, ext }) => { + async ({ saveFn, ext }) => { const w = window as unknown as { KicadCollabV2: { driftReport(f: string, p: string): DriftSummary | null }; + Module: { kicadCollabFiberBusy?: () => boolean }; }; + for (let i = 0; i < 200 && w.Module.kicadCollabFiberBusy?.(); i++) { + await new Promise((r) => setTimeout(r, 25)); + } return w.KicadCollabV2.driftReport(saveFn, `/home/kicad/documents/_drift.${ext}`); }, { saveFn: cfg.saveFn, ext: cfg.ext }, diff --git a/wasm/bindings/collab_common.h b/wasm/bindings/collab_common.h index b35b293..b99d024 100644 --- a/wasm/bindings/collab_common.h +++ b/wasm/bindings/collab_common.h @@ -11,6 +11,7 @@ #ifdef __EMSCRIPTEN__ +#include #include #include #include @@ -34,17 +35,60 @@ inline std::string toUtf8( const wxString& s ) { return std::string( s.utf8_str( * the s-expr formatters must therefore run through this. CallAfter queues * onto the app's pending-event list (drained every frame by the wasm main * loop, src/wasm/evtloop.cpp); COROUTINE::Call moves the body to the fiber. + * + * SERIALIZED (drift-trio finding #10, standalone-hardening 0008 §10): bodies + * run strictly one-at-a-time through a FIFO. The previous per-body + * fire-and-forget coroutine interleaved under load: when a body PARKED + * (asyncify suspension inside commit.Push — connectivity/GAL work), the main + * loop kept draining pending events and started the NEXT body — a local + * commit and a remote apply then ran interleaved on shared commit/listener + * state (s_applyingRemote is a single global), silently losing applies on the + * actively-editing receiver and, in the worst case, corrupting memory (fuzz + * S10: wasm OOB on an observer). The busy flag is park-safe: an asyncify + * suspension suspends the whole drain loop with the body and rewinds it + * transparently, while any other drain invocation no-ops on the flag; the + * suspended drain's own while-loop picks up whatever queued meanwhile. */ -inline void runOnFiber( wxEvtHandler* aHandler, std::function aBody ) +inline std::deque>& fiberQueue() { - aHandler->CallAfter( [aBody]() { - COROUTINE cor( [&aBody]( int ) -> int + static std::deque> q; + return q; +} + +inline bool& fiberBusy() +{ + static bool busy = false; + return busy; +} + +inline void drainFibers() +{ + if( fiberBusy() ) + return; // the running drain's while-loop covers the rest + + auto& q = fiberQueue(); + + while( !q.empty() ) + { + fiberBusy() = true; + + std::function body = std::move( q.front() ); + q.pop_front(); + + COROUTINE cor( [&body]( int ) -> int { - aBody(); + body(); return 0; } ); cor.Call( 0 ); - } ); + fiberBusy() = false; + } +} + +inline void runOnFiber( wxEvtHandler* aHandler, std::function aBody ) +{ + fiberQueue().push_back( std::move( aBody ) ); + aHandler->CallAfter( []() { drainFibers(); } ); } // ── C++ → JS wire emitters (no-ops without a JS listener) ─────────────────── diff --git a/wasm/bindings/eeschema_embind.cpp b/wasm/bindings/eeschema_embind.cpp index 59e2675..0f058f2 100644 --- a/wasm/bindings/eeschema_embind.cpp +++ b/wasm/bindings/eeschema_embind.cpp @@ -1978,6 +1978,11 @@ void kicadSaveSchematic( std::string path ) } +static bool kicadCollabFiberBusyProbe() +{ + return pcbjam_collab::fiberBusy() || !pcbjam_collab::fiberQueue().empty(); +} + EMSCRIPTEN_BINDINGS(eeschema) { // Programmatic save of the in-memory schematic (round-trip tests, README §A). function("kicadSaveSchematic", &kicadSaveSchematic); @@ -1998,6 +2003,7 @@ EMSCRIPTEN_BINDINGS(eeschema) { // registered once by kicad_editor_embind.cpp, dispatching on the active frame. // Programmatic file open (preferred over UI automation from the web app). function("kicadOpenFile", &kicadOpenFile); + function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe); // Read-only viewer lock (read-only-viewer). function("kicadSetReadOnly", &kicadSetReadOnly); // Yjs collaborative bridge entry points (same contract as pl_editor). diff --git a/wasm/bindings/kicad_editor_embind.cpp b/wasm/bindings/kicad_editor_embind.cpp index e9a6e86..ab2d9e4 100644 --- a/wasm/bindings/kicad_editor_embind.cpp +++ b/wasm/bindings/kicad_editor_embind.cpp @@ -451,7 +451,16 @@ static bool collabTestClearSelection() } +static bool kicadCollabFiberBusyProbe() +{ + return pcbjam_collab::fiberBusy() || !pcbjam_collab::fiberQueue().empty(); +} + EMSCRIPTEN_BINDINGS(kicad_editor) { + // Fiber-queue idle probe (drift-trio finding #10b): a bare-embind-stack + // save during a parked apply fiber mis-dispatches (table index OOB) — the + // JS side must defer scratch saves while collab fiber work is in flight. + function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe); // Programmatic file open (preferred over UI automation from the web app). function("kicadOpenFile", &kicadOpenFile); diff --git a/wasm/bindings/pcbnew_embind.cpp b/wasm/bindings/pcbnew_embind.cpp index 3bc530e..e2c59e2 100644 --- a/wasm/bindings/pcbnew_embind.cpp +++ b/wasm/bindings/pcbnew_embind.cpp @@ -2283,6 +2283,11 @@ std::string Pad_GetPinFunction(PAD* pad) { return pad->GetPinFunction().ToStdString(); } +static bool kicadCollabFiberBusyProbe() +{ + return pcbjam_collab::fiberBusy() || !pcbjam_collab::fiberQueue().empty(); +} + EMSCRIPTEN_BINDINGS(pcbnew) { // Register vector types for iteration register_vector("FootprintVector"); @@ -2321,6 +2326,7 @@ EMSCRIPTEN_BINDINGS(pcbnew) { // registered once by kicad_editor_embind.cpp, dispatching on the active frame. // Programmatic file open (preferred over UI automation from the web app). function("kicadOpenFile", &kicadOpenFile); + function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe); // Read-only viewer lock (read-only-viewer). function("kicadSetReadOnly", &kicadSetReadOnly); // Yjs collaborative bridge entry points (same contract as pl_editor / eeschema). diff --git a/web/standalone/src/wasm/collab/drift-detect.ts b/web/standalone/src/wasm/collab/drift-detect.ts index 014c88b..1797698 100644 --- a/web/standalone/src/wasm/collab/drift-detect.ts +++ b/web/standalone/src/wasm/collab/drift-detect.ts @@ -125,6 +125,11 @@ export function startDriftDetection(opts: DriftDetectOptions): DriftDetector { // unchanged since the last report, or the session cap is spent). Being fully // synchronous is what lets the session-end check finish during page unload. function computeDrift(): DriftReportBody | null { + // Defer while collab fiber work is in flight (0008 finding #10b): a save + // on the bare embind stack during a parked apply fiber mis-dispatches + // (table index OOB). The next Y-update trigger retries. + const busy = (opts.mod as { kicadCollabFiberBusy?: () => boolean }).kicadCollabFiberBusy; + if (busy?.()) return null; save(scratchPath); let text: unknown; try {