jspi cleanup: remove the asyncify-era residue — dead code, conditionals, pipeline scaffolding, stale prose

The runtime is JSPI-only; this removes everything that still pretended
otherwise. Three exhaustive sweeps (C++/JS+build+CI/tests+docs) drove
the inventory; every deletion verified by grep closure + full gates.

Broken-right-now fixes:
- deploy-staging.yml passed the retired opt_level input — the workflow
  could not even start. Removed.
- env.sh carried dead exports with a live -sASYNCIFY=1 inside
  (WASM_LDFLAGS/PTHREAD_LDFLAGS, zero consumers). Removed; the
  WASM_LEGACY_EXCEPTIONS rationale rewritten to the real reason.
- docker/build.sh exported PCBJAM_ASYNC_BACKEND (read nowhere). Gone.

Dead weight removed:
- binaryen submodule (nothing builds or invokes it), wasm-opt-bench
  workflow + scripts/bench/, get-wasm-opt.sh, diagnostics.js (242 lines
  of Asyncify-API-only code), the KICAD_PIPELINE background-postprocess
  scaffolding (existed to parallelize the deleted wasm-opt phase; the
  postprocess is a seconds-long node script and now runs inline),
  build-monitor's dead asyncify rows, sched-context orphan build
  output, dead .gitignore entries, the .jspi-assets spike dir (the two
  wf-result research JSONs moved to docs/features/async/migration-evidence/).
- bindings: fiber_park.h + its 12 embind registrations (broken-if-
  called under JSPI), the kicadOpenFileStart/OPEN_JOB starter route,
  main_stack_runner.h + 5 includes, the always-null context-sleep weak
  hook in nanosleep_yield.c.
- shim: the backend field (installed-flag idempotency instead),
  noteContextWait (dead both sides), the __wxAsyncifyDump alias (+ the
  WasmTool fallback and string-dump normalize branch).
- web: the emscripten-6-ignored mainScriptUrlOrBlob option in boot.ts
  (gerber-demo keeps it: it loads the deployed CDN release, which
  predates emscripten 6 — noted inline).

Conditionals: all 'backend === jspi' checks reduced to scheduler-
presence checks; races_quiescent re-keyed from Asyncify.state (vacuous)
to real backlog quiescence (resumeReady/mutatorQueue — NOT _windowLive,
which is the probing activation's own window by definition).

Renames (identifiers only, no file renames): ASYNC_LINK_FLAGS→
JSPI_LINK_FLAGS and Makefile ASYNC_LDFLAGS→JSPI_LDFLAGS,
kicadCollabFiberBusy→kicadCollabBusy (embind + web + tests),
collab_common.h fiber*→apply*/coroutine naming, asyncifySignatures→
wasmTrapSignatures (lists byte-identical).

Tests: the two remaining vacuous [wx-asyncify]/fiber-resume-refused
asserts re-keyed to live JSPI beacons; eeschema-load's failure message
no longer sends the developer to a deleted script; wait-beacons' dead
families/parser deleted; lane-0 legacy-glue guards removed (lane 0 is
unconstructible); the embind test.fail re-gated with the JSPI reason
(plain embind invokers cannot suspend — verified still failing);
lint-determinism now scans tests/jspi (166 files clean);
eeschema-collab local-move gated to chromium (~50% flaky on FF even
solo; pcbnew twin covers both engines).

Docs: DEBUG.md rewritten as the JSPI debugging guide; build.md
describes the single-phase build; docs/features/async/README.md
banner-marked historical and repointed at the NEW
23-jspi-runtime.md (current architecture: export census, turnstile,
libcontext ownership + refusal contract, embind call shapes, the
em-pthread service-wrapper trick, exception policy, known gaps).

Gates on the cleaned tree: test:e2e 725 passed / 0 failed (after the
quiescence-probe fix; the 3 other reds were verified contention flakes
solo-green or the documented FF gate), web 76/0, jspi 18/18 both
engines, vitest 295/295 + 17/17, all lints green, live-app census
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016X9eh1s5sTx1o9Em9KBuwR
This commit is contained in:
Viktor Vaczi 2026-08-14 09:25:32 +02:00
commit 9c475a804e
120 changed files with 1522 additions and 2974 deletions

View file

@ -1,7 +1,7 @@
/*
* Shared plumbing for the per-editor collab binding TUs (eeschema_embind.cpp,
* pcbnew_embind.cpp) — the frame-type-free half of the bridge: string/JSON
* wire emitters to window.kicadCollab, the CallAfter+COROUTINE fiber idiom,
* wire emitters to window.kicadCollab, the CallAfter+COROUTINE apply queue,
* and the frame-generic test hooks. Header-only (the collab_presence_style.h
* pattern), so the build script needs no extra objects and the merged
* kicad_editor image links it without ODR issues.
@ -28,112 +28,110 @@ namespace pcbjam_collab {
inline std::string toUtf8( const wxString& s ) { return std::string( s.utf8_str() ); }
/**
* Run a body on the editor's main loop AND on a libcontext fiber stack — the
* exact context native tool edits run in. Embind ccalls / bare CallAfter
* stacks mis-dispatch asyncify-instrumented virtual calls (invoke_* through a
* stale table type traps, or silently no-ops); commits, GAL overlay work and
* 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.
* Run a body on the editor's main loop AND inside a COROUTINE — the exact
* context native tool edits run in. 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 onto its own
* coroutine stack. Commits, GAL overlay work and the s-expr formatters run
* through this.
*
* 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.
* run strictly one-at-a-time through a FIFO. A body that SUSPENDS (a JSPI
* suspension inside commit.Push — connectivity/GAL work) returns early from
* COROUTINE::Call while its coroutine is still in flight. Without the queue
* 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
* suspension-safe: while a suspended body is in flight every other drain
* invocation no-ops on the flag, and the body's own coroutine tail
* re-schedules the drain when it completes.
*/
inline std::deque<std::function<void()>>& fiberQueue()
inline std::deque<std::function<void()>>& applyQueue()
{
static std::deque<std::function<void()>> q;
return q;
}
inline bool& fiberBusy()
inline bool& applyBusy()
{
static bool busy = false;
return busy;
}
/* The in-flight body. HEAP-allocated and pinned for the body's whole life:
* when a body PARKS (asyncify suspension inside commit.Push), COROUTINE::Call
* RETURNS EARLY — the later asyncify rewind re-enters the fiber through the
* SAME callable at the SAME addresses (dynCall_vi → fcontext_entry →
* callerStub → the wrapper). Stack-local cor/body (the original runOnFiber
* AND the first serialized version) were destroyed on that early return, so
* the rewind called through freed objects — "table index is out of bounds"
* at rewind, memory corruption downstream (finding #10b's symbolized stack).
* `done` is the ONLY completion signal; Call() returning is not. */
struct FiberSlot
* when a body SUSPENDS (a JSPI suspension inside commit.Push), COROUTINE::Call
* RETURNS EARLY — the later resume re-enters the coroutine through the SAME
* callable at the SAME addresses. Stack-local cor/body (the original
* runOnCoroutine AND the first serialized version) were destroyed on that
* early return, so the resume ran through freed objects — memory corruption
* downstream (finding #10b's symbolized stack). `done` is the ONLY completion
* signal; Call() returning is not. */
struct ApplySlot
{
COROUTINE<int, int>* cor = nullptr;
std::function<void()>* body = nullptr;
bool done = false;
};
inline FiberSlot& activeFiberSlot()
inline ApplySlot& activeApplySlot()
{
static FiberSlot s;
static ApplySlot s;
return s;
}
inline wxEvtHandler*& fiberHandler()
inline wxEvtHandler*& applyHandler()
{
static wxEvtHandler* h = nullptr;
return h;
}
inline void drainFibers();
inline void drainApplies();
inline void reapFiber()
inline void reapApply()
{
FiberSlot& slot = activeFiberSlot();
ApplySlot& slot = activeApplySlot();
delete slot.cor;
delete slot.body;
slot.cor = nullptr;
slot.body = nullptr;
slot.done = false;
fiberBusy() = false;
applyBusy() = false;
}
inline void drainFibers()
inline void drainApplies()
{
FiberSlot& slot = activeFiberSlot();
ApplySlot& slot = activeApplySlot();
if( fiberBusy() )
if( applyBusy() )
{
if( !slot.done )
return; // parked body still in flight — its tail re-drains
return; // suspended body still in flight — its tail re-drains
reapFiber(); // completed via rewind since the last drain
reapApply(); // completed since the last drain
}
auto& q = fiberQueue();
auto& q = applyQueue();
while( !q.empty() )
{
fiberBusy() = true;
applyBusy() = true;
slot.done = false;
slot.body = new std::function<void()>( std::move( q.front() ) );
q.pop_front();
slot.cor = new COROUTINE<int, int>( []( int ) -> int
{
FiberSlot& sl = activeFiberSlot();
ApplySlot& sl = activeApplySlot();
( *sl.body )();
sl.done = true;
// If we parked, no drain is pending by the time the rewind
// completes — schedule the reap + next body from the fiber tail
// (CallAfter only queues; safe here).
if( wxEvtHandler* h = fiberHandler() )
h->CallAfter( []() { drainFibers(); } );
// If we suspended, no drain is pending by the time the body
// completes — schedule the reap + next body from the coroutine
// tail (CallAfter only queues; safe here).
if( wxEvtHandler* h = applyHandler() )
h->CallAfter( []() { drainApplies(); } );
return 0;
} );
@ -141,17 +139,17 @@ inline void drainFibers()
slot.cor->Call( 0 );
if( !slot.done )
return; // parked — cor/body stay pinned for the rewind
return; // suspended — cor/body stay pinned for the resume
reapFiber();
reapApply();
}
}
inline void runOnFiber( wxEvtHandler* aHandler, std::function<void()> aBody )
inline void runOnCoroutine( wxEvtHandler* aHandler, std::function<void()> aBody )
{
fiberHandler() = aHandler;
fiberQueue().push_back( std::move( aBody ) );
aHandler->CallAfter( []() { drainFibers(); } );
applyHandler() = aHandler;
applyQueue().push_back( std::move( aBody ) );
aHandler->CallAfter( []() { drainApplies(); } );
}
// ── C++ → JS wire emitters (no-ops without a JS listener) ───────────────────
@ -205,14 +203,14 @@ inline void emitViewport( double aCx, double aCy, double aPxPerIu, int aW, int a
// ── frame-generic test hooks (ysync miss 09) ────────────────────────────────
/** Run Edit>Undo exactly like the UI would (main-loop + fiber stack) —
/** Run Edit>Undo exactly like the UI would (main loop + apply coroutine) —
* exercises the local-ops-only undo policy and the stale-picker UUID guard. */
inline bool testUndo( EDA_BASE_FRAME* aFrame )
{
if( !aFrame )
return false;
runOnFiber( aFrame, [aFrame]() { aFrame->GetToolManager()->RunAction( ACTIONS::undo ); } );
runOnCoroutine( aFrame, [aFrame]() { aFrame->GetToolManager()->RunAction( ACTIONS::undo ); } );
return true;
}

View file

@ -250,7 +250,7 @@ struct CORE
* (center + half-extents, IU) into this canvas — contain, never crop:
* the follower's zoom is derived from ITS OWN canvas size, so leaders
* and followers on different monitors see the same world region.
* Fiber like every other view mutation from JS. */
* Apply coroutine like every other view mutation from JS. */
void fitViewport( double aCx, double aCy, double aHalfW, double aHalfH )
{
EDA_DRAW_FRAME* fr = frame();
@ -258,7 +258,7 @@ struct CORE
if( !fr || aHalfW <= 0 || aHalfH <= 0 )
return;
pcbjam_collab::runOnFiber( fr, [this, fr, aCx, aCy, aHalfW, aHalfH]() {
pcbjam_collab::runOnCoroutine( fr, [this, fr, aCx, aCy, aHalfW, aHalfH]() {
KIGFX::VIEW* view = fr->GetCanvas()->GetView();
const VECTOR2I& sz = view->GetScreenPixelSize();
@ -279,7 +279,7 @@ struct CORE
}
/** kicadCollabSetViewport (0005): pan to a world position (comment panel
* "jump to pin"). Fiber like every other view mutation from JS. */
* "jump to pin"). Apply coroutine like every other view mutation from JS. */
void panTo( double aCx, double aCy )
{
EDA_DRAW_FRAME* fr = frame();
@ -287,7 +287,7 @@ struct CORE
if( !fr )
return;
pcbjam_collab::runOnFiber( fr, [this, fr, aCx, aCy]() {
pcbjam_collab::runOnCoroutine( fr, [this, fr, aCx, aCy]() {
fr->GetCanvas()->GetView()->SetCenter( VECTOR2D( aCx, aCy ) );
fr->GetCanvas()->ForceRefresh();
emitViewportIfChanged();
@ -311,7 +311,8 @@ struct CORE
return;
// Screen→world via the non-virtual VIEW::ToWorld (the virtual
// VIEW_CONTROLS::GetMousePosition is an asyncify dispatch risk here).
// VIEW_CONTROLS::GetMousePosition mis-dispatched here under the
// retired asyncify runtime; the direct call stays).
wxPoint p = aEvt.GetPosition();
VECTOR2D world = fr->GetCanvas()->GetView()->ToWorld( VECTOR2D( p.x, p.y ), true );
@ -327,9 +328,9 @@ struct CORE
// ── remote render ─────────────────────────────────────────────────────
// Repaint the remote-peers overlay. Runs in CallAfter + COROUTINE: the
// first MakeOverlay() view->Add and the items' virtual ViewBBox() need the
// fiber stack (asyncify virtual dispatch — same constraint as the apply).
// Repaint the remote-peers overlay. Runs in CallAfter + COROUTINE via the
// apply queue — serialized with the applies, same constraint as every
// other view mutation from JS.
void redrawOverlay()
{
redrawScheduled = false;
@ -407,7 +408,7 @@ struct CORE
return;
redrawScheduled = true;
pcbjam_collab::runOnFiber( fr, [this]() { redrawOverlay(); } );
pcbjam_collab::runOnCoroutine( fr, [this]() { redrawOverlay(); } );
}
// ── JS entry-point bodies ─────────────────────────────────────────────

View file

@ -60,7 +60,6 @@
#include <pcbjam_remote_lock.h>
#include "collab_common.h"
#include "open_gate.h"
#include "main_stack_runner.h"
#include "collab_presence_core.h"
#include "collab_presence_style.h"
#include "pcbjam_theme.h"
@ -85,7 +84,7 @@ using json = nlohmann::json;
#ifndef KICAD_MERGED_EMBIND
bool kicadOpenFile( std::string path )
{
// Held across every Asyncify park of the load; see open_gate.h.
// Held across every suspension of the load; see open_gate.h.
pcbjam_open::BusyGuard busy;
if( pcbjam_open::testParkMs() > 0 )
@ -302,10 +301,10 @@ SCH_ITEM* makeItem( const json& j )
else if( type == "SCH_SHAPE" )
{
// Reconstruct from the geometry itemToJson emits. Committing a *new* SCH_SHAPE used to
// trap in SCH_COMMIT::Push's CHT_ADD path (GAL view->Add → an asyncify invoke_viii
// mis-dispatch, "memory access out of bounds") because doApply ran off a fiber stack;
// doApply now runs inside a COROUTINE (kicadCollabApply) so the add dispatches correctly,
// exactly as a native draw does. (0006/0007.) NB FILL_T::NO_FILL == 1, not 0.
// trap in SCH_COMMIT::Push's CHT_ADD path under the retired asyncify runtime because
// doApply ran off the coroutine context; doApply runs inside a COROUTINE
// (kicadCollabApply), serialized with local edits, exactly as a native draw does.
// (0006/0007.) NB FILL_T::NO_FILL == 1, not 0.
SHAPE_T st = (SHAPE_T) j.value( "stype", (int) SHAPE_T::RECTANGLE );
int layer = j.value( "layer", (int) LAYER_NOTES );
int width = j.value( "width", 0 );
@ -577,10 +576,11 @@ void flushDiff()
}
// Coalesce all the listener callbacks of one commit (and any other edits in the same loop
// turn) into a single post-settle diff. flushDiff runs inside a COROUTINE: its v2 items
// emit serializes items via SCH_IO_KICAD_SEXPR::Format (itemBlob), whose virtual dispatch
// is only reliable on the libcontext fiber stack — on the bare CallAfter stack it traps
// and silently kills the whole flush, legacy emit included (same lesson as doApply, 0007).
// turn) into a single post-settle diff. flushDiff runs inside a COROUTINE via the apply
// queue: its v2 items emit serializes items via SCH_IO_KICAD_SEXPR::Format (itemBlob), and
// queueing it with the applies keeps emits from interleaving with a suspended apply body
// (under the retired asyncify runtime the bare CallAfter stack additionally trapped here —
// same lesson as doApply, 0007).
void scheduleFlush()
{
if( g_flushScheduled )
@ -589,7 +589,7 @@ void scheduleFlush()
g_flushScheduled = true;
if( SCH_EDIT_FRAME* fr = schFrame() )
pcbjam_collab::runOnFiber( fr, []() { flushDiff(); } );
pcbjam_collab::runOnCoroutine( fr, []() { flushDiff(); } );
else
flushDiff();
}
@ -598,9 +598,9 @@ void scheduleFlush()
// to the child .kicad_sch file and tell the standalone (window.kicadCollab.onSheetCreated),
// so the child is persisted/registered the moment it's created — without waiting for the
// user to enter it or save the project. Otherwise the parent's `(sheet … child)` reference
// dangles for peers / on reload. Deferred onto the fiber stack (CallAfter + COROUTINE):
// SCH_IO_KICAD_SEXPR::Format's virtual dispatch traps on the bare listener/CallAfter stack,
// same as flushDiff/doApply. The sheet is re-resolved by uuid in the deferred body so a
// dangles for peers / on reload. Deferred onto the apply coroutine (CallAfter + COROUTINE),
// same as flushDiff/doApply, so the save serializes with any in-flight apply.
// The sheet is re-resolved by uuid in the deferred body so a
// since-deleted sheet (e.g. an immediate undo) is a no-op rather than a dangling pointer.
void scheduleSheetSave( SCH_SHEET* aSheet )
{
@ -616,7 +616,7 @@ void scheduleSheetSave( SCH_SHEET* aSheet )
std::string childAbs = toUtf8( childFn.GetFullPath() );
std::string uuid = toUtf8( aSheet->m_Uuid.AsString() );
pcbjam_collab::runOnFiber( fr, [fr, childAbs, uuid]() {
pcbjam_collab::runOnCoroutine( fr, [fr, childAbs, uuid]() {
KIID kid( wxString::FromUTF8( uuid.c_str() ) );
SCH_ITEM* item = fr->Schematic().ResolveItem( kid, nullptr, /*allowNull*/ true );
@ -834,24 +834,23 @@ void schedulePresenceSelCheck()
namespace {
// SCH_SYMBOL::Move() / SCH_LABEL_BASE::Move() move their child fields (reference, value, …) via
// an inner `field.Move()` — itself a virtual call that mis-dispatches in the apply context, so
// the field text is left behind at its old position while the body moves. Re-move the fields
// with a devirtualized call so the labels follow the symbol on the peer. (The inner call is a
// harmless no-op when it mis-dispatches — the fields stay put — so this doesn't double-move.)
// an inner `field.Move()`. Under the retired asyncify runtime that inner call silently no-oped
// in the apply context — the field text was left behind at its old position while the body
// moved — so the fields are re-moved here with a devirtualized call. Kept as-is through the
// JSPI migration; the collab move/drift suites pin the peer's field positions.
void moveFields( std::vector<SCH_FIELD>& aFields, const VECTOR2I& aDelta )
{
for( SCH_FIELD& field : aFields )
field.SCH_FIELD::Move( aDelta );
}
// Move an item to an absolute position for the `changed` path. SCH_ITEM::Move() is virtual;
// dispatching it through the vtable from the apply/CallAfter context hits the asyncify
// call_indirect mis-dispatch and silently NO-OPS (so symbols/junctions/labels never moved on
// the peer — only SCH_LINE worked, via its direct SetStart/EndPoint path). GetPosition() reads
// fine (it's a plain virtual read; see 0003 / eeschema_collab_asyncify_apply). The fix:
// devirtualize Move() with an explicit class-qualified call, which is statically bound — a
// plain wasm `call`, not an instrumented call_indirect — so it actually executes. Composite
// items additionally need their child fields moved (see moveFields).
// Move an item to an absolute position for the `changed` path. Under the retired asyncify
// runtime the virtual SCH_ITEM::Move(), dispatched from the apply/CallAfter context, silently
// NO-OPED (symbols/junctions/labels never moved on the peer — only SCH_LINE worked, via its
// direct SetStart/EndPoint path; see 0003 / eeschema_collab_asyncify_apply). Move() is
// therefore devirtualized with explicit class-qualified calls — statically bound plain wasm
// calls, correct on any runtime, so they stay. Composite items additionally need their child
// fields moved (see moveFields).
void moveItemTo( SCH_ITEM* aItem, const VECTOR2I& aNewPos )
{
VECTOR2I delta = aNewPos - aItem->GetPosition();
@ -887,9 +886,8 @@ void moveItemTo( SCH_ITEM* aItem, const VECTOR2I& aNewPos )
}
// The actual model mutation, via SCH_COMMIT so connectivity/ERC recompute as for a UI
// edit. (Editor write ops like SCH_ITEM::Move are called through invoke_vii, whose
// asyncify-instrumented dynCall trampoline traps on a stale type — fixed at the JS shim
// layer in scripts/common/shims/dyncall-binding.js.tmpl; see 0003.)
// edit. (0003 decoded an asyncify-era dynCall trap on this path; the shim that healed
// it retired with that runtime.)
void doApply( SCH_EDIT_FRAME* aFrame, const json& aDelta )
{
SCHEMATIC& sch = aFrame->Schematic();
@ -1140,17 +1138,16 @@ void collabTestMove( SCH_EDIT_FRAME* aFrame, SCH_ITEM* aItem, SCH_SCREEN* aScree
// JS → C++. Apply a remote per-item delta by uuid, through SCH_COMMIT so connectivity/
// ERC/hierarchy recompute the same way a UI edit would (0003 §apply).
//
// SCH_COMMIT must run in the editor's Asyncify-rooted main loop — invoking it from this
// embind ccall, or from an emscripten_async_call/setTimeout callback, traps with an
// "indirect call signature mismatch" because those are not the asyncify root (0001 §5).
// wxEvtHandler::CallAfter queues onto the app's pending-event list, which the wasm main
// loop drains every frame via ProcessPendingEvents() (src/wasm/evtloop.cpp) — i.e. the
// exact context real UI edits run in. So defer the whole mutation there.
// SCH_COMMIT must run on the editor's main loop, not on this embind ccall or an
// emscripten_async_call/setTimeout callback: wxEvtHandler::CallAfter queues onto the
// app's pending-event list, which the wasm main loop drains every frame via
// ProcessPendingEvents() (src/wasm/evtloop.cpp) — i.e. the exact context real UI edits
// run in. So defer the whole mutation there.
void schCollabApply( std::string aJson )
{
// Open-in-flight guard (open_gate.h): never touch the model while a
// kicadOpenFile Asyncify chain is parked mid-load — commits/virtuals on a
// half-built schematic mis-dispatch ("indirect call signature mismatch").
// kicadOpenFile chain is suspended mid-load — commits/virtuals would walk
// a half-built schematic mid-mutation.
// Callers gate on kicadOpenFileBusy; fuzzed by tests/kicad/collab-load-fuzz.spec.ts.
if( pcbjam_open::busy() )
return;
@ -1165,12 +1162,11 @@ void schCollabApply( std::string aJson )
if( !fr )
return;
// Defer to the editor's main-loop context + fiber stack (runOnFiber) so SCH_COMMIT runs
// like a normal edit: SCH_COMMIT::Push's CHT_ADD of a *new* SCH_SHAPE/SCH_SYMBOL
// dispatches GAL virtuals (view->Add → ViewGetLayers) through asyncify-instrumented
// invoke_*; off the fiber stack those mis-dispatch and trap inside KiCad core, which the
// bridge can't devirtualize. On the fiber stack they dispatch correctly. (0007.)
pcbjam_collab::runOnFiber( fr, [fr, delta]() { doApply( fr, delta ); } );
// Defer to the editor's main loop + apply coroutine (runOnCoroutine) so SCH_COMMIT runs
// like a normal edit: a commit body that suspends (connectivity/GAL work) returns early
// from COROUTINE::Call, so applies must serialize with each other and with local edits
// or they interleave on shared commit/listener state. (0007, drift-trio #10.)
pcbjam_collab::runOnCoroutine( fr, [fr, delta]() { doApply( fr, delta ); } );
}
@ -1211,7 +1207,7 @@ void schCollabApplyItems( std::string aJson )
if( !fr )
return;
pcbjam_collab::runOnFiber( fr, [fr, wire]() { doApplyItems( fr, wire ); } );
pcbjam_collab::runOnCoroutine( fr, [fr, wire]() { doApplyItems( fr, wire ); } );
}
@ -1353,7 +1349,7 @@ bool schCollabTestRemoveItem( std::string aId )
SCH_SCREEN* screen = path.LastScreen();
pcbjam_collab::runOnFiber( fr, [fr, item, screen]() {
pcbjam_collab::runOnCoroutine( fr, [fr, item, screen]() {
SCH_COMMIT commit( fr );
commit.Remove( item, screen );
commit.Push( wxT( "Collab test remove" ) );
@ -1382,7 +1378,7 @@ bool schCollabTestRotateItem( std::string aId, double aDeg )
SCH_SCREEN* screen = path.LastScreen();
int steps = ( (int) ( aDeg / 90.0 + ( aDeg >= 0 ? 0.5 : -0.5 ) ) % 4 + 4 ) % 4;
pcbjam_collab::runOnFiber( fr, [fr, item, screen, steps]() {
pcbjam_collab::runOnCoroutine( fr, [fr, item, screen, steps]() {
SCH_COMMIT commit( fr );
commit.Modify( item, screen );
@ -1427,7 +1423,7 @@ bool schCollabTestSetFieldText( std::string aId, std::string aText )
SCH_SCREEN* screen = path.LastScreen();
wxString text = wxString::FromUTF8( aText.c_str() );
pcbjam_collab::runOnFiber( fr, [fr, sym, screen, text]() {
pcbjam_collab::runOnCoroutine( fr, [fr, sym, screen, text]() {
SCH_COMMIT commit( fr );
commit.Modify( sym, screen );
sym->SetValueFieldText( text );
@ -1440,7 +1436,7 @@ bool schCollabTestSetFieldText( std::string aId, std::string aText )
// ── drift-trio phase B action hooks (standalone-hardening 0008 §5) ───────────
// Creation/mutation primitives for the trio harness's action catalog. Each
// drives a REAL SCH_COMMIT on the fiber stack, so the SCHEMATIC_LISTENER →
// drives a REAL SCH_COMMIT on the apply coroutine, so the SCHEMATIC_LISTENER →
// flushDiff emit path runs exactly as for a UI edit. Names are tool-unique
// (registered outside the KICAD_MERGED_EMBIND guard — same convention as
// kicadCollabTestSetFieldText), so the merged image needs no dispatcher.
@ -1467,7 +1463,7 @@ static std::string schCollabTestCommitAdd( SCH_ITEM* aItem, const wxChar* aMsg )
std::string id = toUtf8( aItem->m_Uuid.AsString() );
wxString msg( aMsg );
pcbjam_collab::runOnFiber( fr, [fr, aItem, screen, msg]() {
pcbjam_collab::runOnCoroutine( fr, [fr, aItem, screen, msg]() {
SCH_COMMIT commit( fr );
commit.Add( aItem, screen );
commit.Push( msg );
@ -1549,7 +1545,7 @@ std::string schCollabTestAddSymbol( std::string aLibId, int aX, int aY, std::str
std::string id = toUtf8( sym->m_Uuid.AsString() );
pcbjam_collab::runOnFiber( fr, [fr, sym, screen]() {
pcbjam_collab::runOnCoroutine( fr, [fr, sym, screen]() {
SCH_COMMIT commit( fr );
commit.Add( sym, screen );
commit.Push( wxT( "Collab test add symbol" ) );
@ -1600,7 +1596,7 @@ bool schCollabTestMirrorSchItem( std::string aId, bool aHorizontal )
if( !item )
return false;
pcbjam_collab::runOnFiber( fr, [fr, aId, aHorizontal]() { // re-resolve on the fiber (S4)
pcbjam_collab::runOnCoroutine( fr, [fr, aId, aHorizontal]() { // re-resolve on the coroutine (S4)
SCH_SHEET_PATH path;
SCH_ITEM* live = fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ),
&path, /*allowNull*/ true );
@ -1643,7 +1639,7 @@ std::string schCollabTestDuplicateSchItem( std::string aId, int aDx, int aDy )
std::string id = toUtf8( dup->m_Uuid.AsString() );
pcbjam_collab::runOnFiber( fr, [fr, dup, screen]() {
pcbjam_collab::runOnCoroutine( fr, [fr, dup, screen]() {
SCH_COMMIT commit( fr );
commit.Add( dup, screen );
commit.Push( wxT( "Collab test duplicate" ) );
@ -2038,9 +2034,9 @@ void kicadSaveSchematic( std::string path )
}
static bool kicadCollabFiberBusyProbe()
static bool kicadCollabBusyProbe()
{
return pcbjam_collab::fiberBusy() || !pcbjam_collab::fiberQueue().empty();
return pcbjam_collab::applyBusy() || !pcbjam_collab::applyQueue().empty();
}
EMSCRIPTEN_BINDINGS(eeschema) {
@ -2065,7 +2061,7 @@ EMSCRIPTEN_BINDINGS(eeschema) {
function("kicadOpenFile", &kicadOpenFile PCBJAM_PARKER_POLICY);
function("kicadOpenFileBusy", &kicadOpenFileBusy);
function("kicadTestSetOpenPark", &kicadTestSetOpenPark);
function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe);
function("kicadCollabBusy", &kicadCollabBusyProbe);
// Read-only viewer lock (read-only-viewer).
function("kicadSetReadOnly", &kicadSetReadOnly);
// Yjs collaborative bridge entry points (same contract as pl_editor).

View file

@ -1,180 +0,0 @@
/*
* Test-only repro lever for the DECODED production board-load trap
* (docs/features/async/15-timer-park-repro.md round 3, 2026-07-31):
* resuming a KiCad coroutine while its body is asyncify-parked inside
* handleSleep.
*
* Two suspension protocols share one context on wasm. A coroutine suspended
* by a real yield (fiber_swap) has valid rewind data in its fiber struct; a
* coroutine whose body parked via handleSleep (lib-bridge wait,
* emscripten_sleep) does NOT — its live state is in the sleep's buffer,
* invisible to the fiber machinery and to TOOL_MANAGER. Resume() then swaps
* into the STALE fiber data: finishContextSwitch → doRewind → "unreachable
* executed", and every later entry reads poisoned Asyncify state ("index out
* of bounds"). Impossible natively — a coroutine cannot be suspended without
* yielding.
*
* The lever stages the prod state machine exactly:
* start(parkMs): Call() a coroutine that immediately KiYield()s — this
* writes VALID suspension data once and clears the fresh-entry path, the
* state every long-lived tool loop is in.
* prime(): Resume() it legitimately — the body then emscripten_sleep()s
* (the internal park; prime's Resume ghost-returns per the epoch
* machinery) and afterwards KiYield()s again.
* poke(): Resume() DURING the sleep — the fatal prod operation. Unfixed
* runtime: rewinds the stale suspension → the exact prod trap. Fixed
* runtime: the jump is refused (null INVOCATION_ARGS, same contract as
* jump-ghost) and the body completes undisturbed; a later poke() after
* the second KiYield resumes it for real.
*
* Production is inert: nothing runs unless start() is called.
*/
#pragma once
#include <cstdio>
#include <string>
#include <emscripten.h>
#include <tool/coroutine.h>
namespace pcbjam_fiber_park
{
struct State
{
// 0 idle · 1 yielded-once (primed suspension) · 2 in the sleep park ·
// 3 woke, yielded again · 4 resumed past second yield · 5 body returned
int phase = 0;
int parkMs = 0;
int pokes = 0;
// Second coroutine (poisoned-attribution scenario): 0 idle · 1 yielded ·
// 2 completed. Starting it while the FIRST body is asyncify-parked makes
// libcontext attribute the jump's old side to that parked fiber
// (g_current_context is stale), writing a fresh suspension into its
// struct — the exact laundering that let the prod resume bypass the
// swap_suspended guard.
int phase2 = 0;
};
inline State& state()
{
static State s_state;
return s_state;
}
inline COROUTINE<int, int>*& co()
{
static COROUTINE<int, int>* s_co = nullptr;
return s_co;
}
inline int fiberBody( int )
{
state().phase = 1;
co()->KiYield();
state().phase = 2;
if( state().parkMs > 0 )
emscripten_sleep( state().parkMs );
state().phase = 3;
co()->KiYield();
state().phase = 4;
return 0;
}
/** Call() + first KiYield: coroutine now has VALID fiber suspension data. */
inline bool start( int aParkMs )
{
if( co() && co()->Running() )
return false; // one in flight; the spec drives one cycle at a time
delete co();
state() = State();
state().parkMs = aParkMs;
co() = new COROUTINE<int, int>( fiberBody );
co()->Call( 0 );
return state().phase == 1;
}
/** Legitimate Resume into the primed yield; the body then parks. Ghost-returns. */
inline bool prime()
{
if( !co() )
return false;
return co()->Resume();
}
/**
* Resume() regardless of the body's suspension state — what TOOL_MANAGER does
* on the next event, unaware the body is asyncify-parked. Counted so the spec
* can correlate pokes with phases.
*/
inline bool poke()
{
if( !co() )
return false;
++state().pokes;
return co()->Resume();
}
inline COROUTINE<int, int>*& co2()
{
static COROUTINE<int, int>* s_co2 = nullptr;
return s_co2;
}
inline int fiberBody2( int )
{
state().phase2 = 1;
co2()->KiYield();
state().phase2 = 2;
return 0;
}
/**
* Start a SECOND coroutine while the first body is asyncify-parked. Because
* g_current_context still points at the parked fiber, libcontext attributes
* this jump's old side to it: the swap writes a fresh (foreign) suspension
* into the PARKED fiber's struct and re-marks it swap_suspended — the
* laundering that lets a later Resume bypass the C++ guard. The JS
* stale-rewind guard (handlesleep.js) must still quarantine it.
*/
inline bool startSecond()
{
if( !co() )
return false; // scenario needs the first coroutine in flight
if( co2() && co2()->Running() )
return false;
delete co2();
state().phase2 = 0;
co2() = new COROUTINE<int, int>( fiberBody2 );
co2()->Call( 0 );
return state().phase2 == 1;
}
/** Resume the second coroutine past its yield (cleanup / completion). */
inline bool pokeSecond()
{
if( !co2() )
return false;
return co2()->Resume();
}
inline std::string stateJson()
{
char buf[144];
snprintf( buf, sizeof( buf ),
"{\"phase\":%d,\"pokes\":%d,\"parkMs\":%d,\"running\":%s,\"phase2\":%d}",
state().phase, state().pokes, state().parkMs,
( co() && co()->Running() ) ? "true" : "false", state().phase2 );
return buf;
}
} // namespace pcbjam_fiber_park

View file

@ -25,7 +25,6 @@
#include <wx/app.h>
#include <wx/string.h>
#include "open_gate.h"
#include "main_stack_runner.h"
#include "pcbjam_async_policy.h"
using namespace emscripten;
@ -38,8 +37,8 @@ static GERBVIEW_FRAME* gerbFrame()
static bool openFileSet( const std::vector<wxString>& aFiles )
{
// Held across every Asyncify park of the load (open_gate.h): the layer load
// parks, and a wx timer dispatched into a half-built layer set traps.
// Held across every suspension of the load (open_gate.h): the layer load
// suspends, and a wx timer dispatched into a half-built layer set traps.
pcbjam_open::BusyGuard busy;
GERBVIEW_FRAME* frame = gerbFrame();

View file

@ -43,9 +43,7 @@
#include "pcbjam_libs_reload.h"
#include "pcbjam_async_policy.h"
#include "open_gate.h"
#include "main_stack_runner.h"
#include "timer_park.h"
#include "fiber_park.h"
using namespace emscripten;
@ -141,7 +139,7 @@ bool schCollabTestClearSelection();
// standalone bundles compile from their own binding TU.
static bool kicadOpenFile( std::string path )
{
// Held across every Asyncify park of the load; see open_gate.h.
// Held across every suspension of the load; see open_gate.h.
pcbjam_open::BusyGuard busy;
if( pcbjam_open::testParkMs() > 0 )
@ -167,43 +165,6 @@ static bool kicadOpenFile( std::string path )
return ok;
}
// Phase F (docs/features/async/22 §10, the awaited-ccall entry class): the
// open body above, driven from a DISPATCH CONTEXT instead of the main stack.
// Run there, every wait inside the load parks the context through the
// registry — the main stack never parks in place, which is the last
// production member of the overlapped-wake class the D-on beacon sweep named.
//
// THE TOKEN IS PASSED IN, NOT RETURNED. Running the job on a dispatch context
// Asyncify-suspends THIS embind frame while the load parks, so any return
// value is delivered as an unwind PLACEHOLDER (0) into a rewind JS discards —
// the same gotcha the fiber-park levers document. So the shim wrapper mints
// the wait token in pure JS (no swap), hands it in here, and awaits its
// promise; this starter returns void and its own placeholder return is
// harmless. The job resolves the token when the load completes.
extern "C" void wxWasmRunOnDispatchContext( void ( *fn )( void* ), void* arg );
extern "C" void wxWasmResolveWait( int aToken, int aResult );
namespace
{
struct OPEN_JOB
{
std::string path;
int token;
};
void kicadOpenFileJob( void* aArg )
{
std::unique_ptr<OPEN_JOB> job( static_cast<OPEN_JOB*>( aArg ) );
const bool ok = kicadOpenFile( job->path );
wxWasmResolveWait( job->token, ok ? 1 : 0 );
}
} // namespace
static void kicadOpenFileStart( int token, std::string path )
{
wxWasmRunOnDispatchContext( &kicadOpenFileJob, new OPEN_JOB{ std::move( path ), token } );
}
// JS-pollable open-in-flight probe (open_gate.h): the web shell defers the
// collab/presence attach until the open chain has truly completed.
static bool kicadOpenFileBusy()
@ -218,7 +179,7 @@ static void kicadTestSetOpenPark( int aMs )
}
// Test-only (timer-park repro, timer_park.h): a one-shot wx timer whose
// Notify() Asyncify-parks — the deterministic concurrent-park window.
// Notify() suspends — the deterministic concurrent-suspension window.
static bool kicadTestArmTimerPark( int aDelayMs, int aParkMs )
{
return pcbjam_timer_park::arm( aDelayMs, aParkMs );
@ -229,44 +190,6 @@ static std::string kicadTestTimerParkState()
return pcbjam_timer_park::stateJson();
}
// Test-only (fiber_park.h): Resume() into a foreign-parked coroutine — the
// decoded prod board-load trap family. NOTE: these are sync embind bindings
// for MANUAL probing on Chromium only. Do not build specs on them: the
// mutating levers suspend, and no embind shape delivers that correctly
// (plain registration throws on strict-JSPI Firefox; emscripten::async()
// re-executes its invoker when the awaited promise settles). The runtime
// contracts they staged are pinned by the jspi-coroutine harness (18 cases)
// and tests/kicad/coroutine-lifecycle.spec.ts instead.
static bool kicadTestFiberParkStart( int aParkMs )
{
return pcbjam_fiber_park::start( aParkMs );
}
static bool kicadTestFiberParkPrime()
{
return pcbjam_fiber_park::prime();
}
static bool kicadTestFiberParkPoke()
{
return pcbjam_fiber_park::poke();
}
static std::string kicadTestFiberParkState()
{
return pcbjam_fiber_park::stateJson();
}
static bool kicadTestFiberParkStartSecond()
{
return pcbjam_fiber_park::startSecond();
}
static bool kicadTestFiberParkPokeSecond()
{
return pcbjam_fiber_park::pokeSecond();
}
// Canvas-only chrome toggle (features/mobile): hide/show every AUI pane
// except the central draw canvas, plus the menubar and status bar, so the GAL
@ -607,29 +530,23 @@ static bool collabTestClearSelection()
}
static bool kicadCollabFiberBusyProbe()
static bool kicadCollabBusyProbe()
{
return pcbjam_collab::fiberBusy() || !pcbjam_collab::fiberQueue().empty();
return pcbjam_collab::applyBusy() || !pcbjam_collab::applyQueue().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);
// Apply-queue idle probe (drift-trio finding #10b): a scratch save taken
// while a collab apply is in flight (queued, or suspended mid-commit)
// would serialize a half-mutated model — the JS side must defer scratch
// saves until this reads false.
function("kicadCollabBusy", &kicadCollabBusyProbe);
// Programmatic file open (preferred over UI automation from the web app).
function("kicadOpenFile", &kicadOpenFile PCBJAM_PARKER_POLICY);
function("kicadOpenFileStart", &kicadOpenFileStart);
function("kicadOpenFileBusy", &kicadOpenFileBusy);
function("kicadTestSetOpenPark", &kicadTestSetOpenPark);
function("kicadTestArmTimerPark", &kicadTestArmTimerPark);
function("kicadTestTimerParkState", &kicadTestTimerParkState);
function("kicadTestFiberParkStart", &kicadTestFiberParkStart);
function("kicadTestFiberParkPrime", &kicadTestFiberParkPrime);
function("kicadTestFiberParkPoke", &kicadTestFiberParkPoke);
function("kicadTestFiberParkState", &kicadTestFiberParkState);
function("kicadTestFiberParkStartSecond", &kicadTestFiberParkStartSecond);
function("kicadTestFiberParkPokeSecond", &kicadTestFiberParkPokeSecond);
// Canvas-only mobile mode (features/mobile).
function("kicadSetChrome", &kicadSetChrome);

View file

@ -1,86 +0,0 @@
/*
* Main-stack runner: the KiCad half of wx's nested-loop bounce
* (pcbjam docs/features/async/19, 20 D3).
*
* A quasi-modal's nested event loop parks its whole stack for the dialog's
* lifetime. When that stack is a TOOL coroutine's, the park suspends the
* fiber's body where the fiber layer cannot see it: the stale-fiber guard
* quarantines the fiber, then REFUSES its own resume, and the dialog stops
* responding to clicks (only the titlebar x still works, because that path is
* ungated). That is the Symbol Properties hang.
*
* wx detects "this nested loop is about to park on a non-main stack" — it can,
* cheaply and exactly, by comparing a frame address against
* emscripten_stack_get_base()/end() — but it must not know what a coroutine
* is. So it calls this runner, and KiCad's TOOL_MANAGER moves the loop onto
* the main stack via its own RunMainStack mechanism, which suspends the
* coroutine the legitimate way: a fiber swap the layer records
* (swap_suspended = true), so no quarantine, no refused resume.
*
* This lives in pcbjam's binding layer rather than in KiCad or wx precisely
* because it is the only place that may know about both.
*/
#pragma once
#include <wx/app.h>
#include <eda_base_frame.h>
#include <tool/tool_manager.h>
#include <wx/wasm/private/mainstack.h>
namespace pcbjam_main_stack
{
/**
* Run aFunc on the main stack if a tool coroutine is currently active.
*
* Returns 1 when the body has been run (bounced, or run inline because there
* was no coroutine to bounce off), 0 when there was nothing to run it with —
* no frame or no tool manager — in which case wx parks in place exactly as it
* did before this hook existed.
*/
inline int run_on_main_stack( void ( *aFunc )( void* ), void* aArg )
{
if( !wxTheApp )
return 0;
auto* frame = dynamic_cast<EDA_BASE_FRAME*>( wxTheApp->GetTopWindow() );
if( !frame )
return 0;
TOOL_MANAGER* toolMgr = frame->GetToolManager();
if( !toolMgr )
return 0;
// RunOnMainStackIfActiveTool runs the body inline when no coroutine is
// running. Either way the body HAS run, so report it as handled — letting
// wx fall through would run the nested loop a second time.
bool ran = false;
toolMgr->RunOnMainStackIfActiveTool(
[aFunc, aArg, &ran]()
{
ran = true;
aFunc( aArg );
} );
return ran ? 1 : 0;
}
/**
* Installs at static-init time. Only stores a function pointer, so it is safe
* before wx exists; every lookup above happens lazily per call, since frames
* come and go. Included by more than one binding TU — setting the same pointer
* twice is idempotent.
*/
struct INSTALLER
{
INSTALLER() { wxWasmSetMainStackRunner( &run_on_main_stack ); }
};
inline INSTALLER g_installer;
} // namespace pcbjam_main_stack

View file

@ -1,18 +1,18 @@
/*
* Truthful "kicadOpenFile in flight" signal for the web shell.
*
* kicadOpenFile runs OpenProjectFiles under Asyncify: the embind call unwinds
* back to JS long before the load finishes, and the chain stays parked (and
* resumes, and parks again) across the whole multi-second load. Any bare
* embind entry that walks the model while that chain is parked mid-mutation
* (collab snapshot, presence bind) can virtual-dispatch through a half-built
* item and trap ("indirect call signature mismatch" — same class as the wx
* dispatch interlock and the drift-trio #10b fiber-busy probe, but through a
* JS entry neither of those covers).
* kicadOpenFile runs OpenProjectFiles as a suspending (promising) export: the
* embind call hands JS a Promise long before the load finishes, and the chain
* stays suspended (and resumes, and suspends again) across the whole
* multi-second load. Any bare embind entry that walks the model while that
* chain is suspended mid-mutation (collab snapshot, presence bind) reads a
* half-built item graph and can trap — same class as the wx dispatch
* interlock and the collab apply-busy probe, but through a JS entry neither
* of those covers.
*
* The guard is RAII on the open's C++ stack frame: an Asyncify unwind does not
* run destructors and a rewind resumes past the constructor, so the count is
* held for the park's entire lifetime and drops exactly when OpenProjectFiles
* The guard is RAII on the open's C++ stack frame: a suspension keeps the
* whole C++ stack (and with it this frame) alive, so the count is held for
* the suspension's entire lifetime and drops exactly when OpenProjectFiles
* truly returns (the same primitive as wxWasmDispatchGuard). A trap escaping
* the open leaves the count stuck — the JS poll times out and degrades.
*/
@ -30,21 +30,22 @@ inline int& busyCount()
}
/**
* Held for the whole open. Two counters, same Asyncify-RAII trick:
* Held for the whole open. Two counters, same suspension-RAII trick:
*
* - `busyCount` is OURS: it answers kicadOpenFileBusy() for the web shell and
* gates the collab entries (JS → embind reentry).
* - `wxWasmDispatchGuard` enrolls the open in the WX DISPATCH INTERLOCK. This
* matters because `kicadOpenFile` enters through embind, not through a wx
* dispatch entry point, so without it `wxWasmDispatchParked()` reads FALSE
* for the entire load: every park (progress pump, thread-pool futex wait,
* lib bridge) lets the pump dispatch a QUEUED WX TIMER into the half-built
* board — src/wasm/timer.cpp fires it because nothing looks parked — and
* the handler walks half-mutated widget/board state ("index out of bounds",
* the same signature as the symbol-chooser crash the interlock was built
* for). Holding the guard makes those timers defer (retry 17 ms later)
* until the load truly completes. Paints keep running; the progress
* dialog's own pump is the designed exception (it zeroes the count).
* for the entire load: every suspension (progress pump, thread-pool futex
* wait, lib bridge) lets the pump dispatch a QUEUED WX TIMER into the
* half-built board — src/wasm/timer.cpp fires it because nothing looks
* parked — and the handler walks half-mutated widget/board state ("index
* out of bounds", the same signature as the symbol-chooser crash the
* interlock was built for). Holding the guard makes those timers defer
* (retry 17 ms later) until the load truly completes. Paints keep running;
* the progress dialog's own pump is the designed exception (it zeroes the
* count).
*/
struct BusyGuard
{
@ -63,11 +64,12 @@ inline bool busy()
/**
* Test-only deterministic park (tests/kicad/collab-load-fuzz.spec.ts): with a
* nonzero value, kicadOpenFile Asyncify-parks for this many ms on entry and
* again after OpenProjectFiles returns — busy guard held, model fully loaded.
* Natural in-load parks (thread-pool futex waits) are scheduler-dependent and
* never happen on a fast idle machine, so the guard would be untestable in CI
* without this window. 0 (the default) is a no-op in production.
* nonzero value, kicadOpenFile suspends for this many ms on entry and again
* after OpenProjectFiles returns — busy guard held, model fully loaded.
* Natural in-load suspensions (thread-pool futex waits) are
* scheduler-dependent and never happen on a fast idle machine, so the guard
* would be untestable in CI without this window. 0 (the default) is a no-op
* in production.
*/
inline int& testParkMs()
{

View file

@ -15,8 +15,8 @@
*
* Header-only (the collab_common.h pattern); common-code includes only, so the
* merged kicad_editor TU (deliberately eeschema/pcbnew-header-free) can use it.
* Runs on the fiber stack: LoadLibraryEntry Asyncify-suspends in the JS bridge,
* and the tree sync dispatches GAL/tree virtuals that trap off-fiber.
* Runs on the apply coroutine: LoadLibraryEntry suspends in the JS bridge, so
* the reload must serialize with the collab applies and local edits.
*/
#pragma once
@ -52,7 +52,7 @@ inline void reloadLibrary( std::string aKind, std::string aNickname )
const bool fp = aKind == "footprint";
const wxString nick = wxString::FromUTF8( aNickname.c_str() );
pcbjam_collab::runOnFiber( top, [top, fp, nick]()
pcbjam_collab::runOnCoroutine( top, [top, fp, nick]()
{
LIBRARY_MANAGER& mgr = Pgm().GetLibraryManager();
const LIBRARY_TABLE_TYPE type =

View file

@ -42,7 +42,7 @@ namespace pcbjam_theme {
* which have no chrome API. */
inline void ( *g_afterThemeApplied )() = nullptr;
/** Set the chrome appearance FLAG only — no widget traffic, no fiber. Safe
/** Set the chrome appearance FLAG only — no widget traffic, no coroutine. Safe
* from the browser main thread at any point (it writes one bool in shared
* wasm memory); the embedder calls it at onRuntimeInitialized, BEFORE main()
* spawns on the KiCad pthread, so the first widget paint is already themed.
@ -79,7 +79,7 @@ inline void syncChromeAppearance( bool aDark )
}
/** Apply `aTheme` ("pcbjam-dark", "_builtin_default", …) to one frame. Runs
* on the frame's fiber: CommonSettingsChanged reaches tool/view internals
* on the apply coroutine: CommonSettingsChanged reaches tool/view internals
* that must not run from a bare JS callback. Null frame no-ops (the merged
* dispatcher calls every editor, open or not). */
inline void setColorTheme( EDA_DRAW_FRAME* aFrame, const std::string& aTheme )
@ -87,7 +87,7 @@ inline void setColorTheme( EDA_DRAW_FRAME* aFrame, const std::string& aTheme )
if( !aFrame )
return;
pcbjam_collab::runOnFiber( aFrame, [aFrame, aTheme]() {
pcbjam_collab::runOnCoroutine( aFrame, [aFrame, aTheme]() {
// The shell only ever sends our dark theme name or the builtin
// default, so the chrome appearance rides on that distinction.
const bool dark = aTheme != "_builtin_default";
@ -116,7 +116,7 @@ inline void setColorTheme( EDA_DRAW_FRAME* aFrame, const std::string& aTheme )
// unconditionally reloads colors and recaches the view.
aFrame->CommonSettingsChanged( 0 );
// Queued from INSIDE the fiber body, AFTER CommonSettingsChanged: the
// Queued from INSIDE the coroutine body, AFTER CommonSettingsChanged: the
// menubar rebuild it triggers is itself a CallAfter on this same
// handler, so FIFO puts the re-assert behind the rebuilt (shown)
// menubar.

View file

@ -53,10 +53,8 @@
#include "collab_common.h"
#include "collab_presence_core.h"
#include "open_gate.h"
#include "main_stack_runner.h"
#include "pcbjam_async_policy.h"
#include "timer_park.h"
#include "fiber_park.h"
#include "collab_presence_style.h"
#include "pcbjam_theme.h"
#include "pcbjam_libs_reload.h"
@ -89,7 +87,7 @@ using json = nlohmann::json;
#ifndef KICAD_MERGED_EMBIND
bool kicadOpenFile( std::string path )
{
// Held across every Asyncify park of the load; see open_gate.h.
// Held across every suspension of the load; see open_gate.h.
pcbjam_open::BusyGuard busy;
if( pcbjam_open::testParkMs() > 0 )
@ -129,7 +127,7 @@ void kicadTestSetOpenPark( int aMs )
}
// Test-only (timer-park repro, timer_park.h): a one-shot wx timer whose
// Notify() Asyncify-parks — the deterministic concurrent-park window.
// Notify() suspends — the deterministic concurrent-suspension window.
bool kicadTestArmTimerPark( int aDelayMs, int aParkMs )
{
return pcbjam_timer_park::arm( aDelayMs, aParkMs );
@ -140,38 +138,6 @@ std::string kicadTestTimerParkState()
return pcbjam_timer_park::stateJson();
}
// Test-only (fiber-resume-park repro, fiber_park.h): Resume() into an
// asyncify-parked coroutine — the decoded prod board-load trap.
bool kicadTestFiberParkStart( int aParkMs )
{
return pcbjam_fiber_park::start( aParkMs );
}
bool kicadTestFiberParkPrime()
{
return pcbjam_fiber_park::prime();
}
bool kicadTestFiberParkPoke()
{
return pcbjam_fiber_park::poke();
}
std::string kicadTestFiberParkState()
{
return pcbjam_fiber_park::stateJson();
}
bool kicadTestFiberParkStartSecond()
{
return pcbjam_fiber_park::startSecond();
}
bool kicadTestFiberParkPokeSecond()
{
return pcbjam_fiber_park::pokeSecond();
}
// Read-only viewer lock (read-only-viewer): flips the process-global
// PCBJAM_READ_ONLY flag consumed by TOOL_MANAGER (view-only action allowlist)
// and the selection tools (nothing selectable), and mirrors it onto the
@ -205,9 +171,9 @@ bool kicadSetReadOnly( bool aReadOnly )
// trigger; the real change set is a diff of the full model taken after the
// edit's BOARD_COMMIT::Push — connectivity cleanup included — has returned,
// so peers converge by re-applying already-clean geometry). See eeschema 0007.
// - apply = BOARD_COMMIT run inside a CallAfter + COROUTINE fiber stack, the exact
// context native tool edits run in, so GAL view->Add of a freshly-constructed
// item dispatches its asyncify-instrumented virtuals correctly (eeschema 0007).
// - apply = BOARD_COMMIT run inside a CallAfter + COROUTINE, the exact context native
// tool edits run in, serialized with every other apply/local edit through the
// collab_common.h apply queue (eeschema 0007).
//
// Scope of this first commit (0004 §"first PoC", matching eeschema commit-3's first cut):
// position/geometry sync of existing items — changed (move/reshape) and removed work for
@ -232,12 +198,12 @@ bool isTrackType( KICAD_T t )
return t == PCB_TRACE_T || t == PCB_ARC_T || t == PCB_VIA_T;
}
// Read an item's layer WITHOUT the virtual GetLayer(). That virtual mis-dispatches in the
// non-coroutine emit/snapshot context — it returns 0 (F_Cu) for EVERY item (the same asyncify
// call_indirect class as eeschema's Move()), which silently put every collab-added item/track/
// text on the top copper layer on the peer. A class-qualified `BOARD_ITEM::GetLayer()` is a
// statically-bound (direct) call that just reads m_layer, bypassing call_indirect. Zones keep
// their layer in m_layerSet (not m_layer), so use their non-virtual GetFirstLayer().
// Read an item's layer WITHOUT the virtual GetLayer(). Under the retired asyncify runtime
// that virtual mis-dispatched in the non-coroutine emit/snapshot context — it returned 0
// (F_Cu) for EVERY item, silently putting every collab-added item/track/text on the top
// copper layer on the peer. The class-qualified `BOARD_ITEM::GetLayer()` is a statically-
// bound (direct) call that just reads m_layer — correct on any runtime, so it stays. Zones
// keep their layer in m_layerSet (not m_layer), so use their non-virtual GetFirstLayer().
int itemLayer( BOARD_ITEM* aItem )
{
if( aItem->Type() == PCB_ZONE_T )
@ -314,8 +280,9 @@ json itemToJson( BOARD_ITEM* aItem )
}
// Vias and zones reconstruct NATIVELY on `added` (the s-expr clipboard blob's `(kicad_pcb …)`
// envelope parse — used for footprints — is asyncify-fragile in wasm for these, the same wall
// that deferred the eeschema symbol blob). So emit the geometry their makeItem needs.
// envelope parse — used for footprints — proved fragile for these under the retired asyncify
// runtime, the same wall that deferred the eeschema symbol blob; the native path stays as the
// simpler, pinned-by-tests route). So emit the geometry their makeItem needs.
if( aItem->Type() == PCB_VIA_T )
{
auto* via = static_cast<PCB_VIA*>( aItem );
@ -342,7 +309,7 @@ json itemToJson( BOARD_ITEM* aItem )
j["poly"] = pts;
}
// A board-level graphic text (Place→Text) also reconstructs NATIVELY (same asyncify reason as
// A board-level graphic text (Place→Text) also reconstructs NATIVELY (same reason as
// via/zone): emit its size / stroke / angle so makeItem can rebuild it. Footprint child text
// is synced by move, not `added`, so this is only the board PCB_TEXT case.
else if( aItem->Type() == PCB_TEXT_T )
@ -632,7 +599,7 @@ BOARD_ITEM* makeItem( BOARD& aBoard, const json& j )
item = tr;
}
// Via / zone: reconstruct natively from emitted geometry (the envelope-blob parse is
// asyncify-fragile for these — see itemToJson). The blob is still emitted as a fallback.
// skipped for these — see itemToJson). The blob is still emitted as a fallback.
else if( type == "PCB_VIA" && j.contains( "drill" ) )
{
auto* via = new PCB_VIA( &aBoard );
@ -714,7 +681,7 @@ BOARD_ITEM* makeItem( BOARD& aBoard, const json& j )
// Set an existing item's geometry from a `changed` delta. Tracks reshape via their endpoints
// (independent — like an eeschema wire); everything else moves to an absolute position.
// SetStart/SetEnd/SetPosition run inside the apply COROUTINE (see kicadCollabApply), the same
// fiber context native edits use, so the virtual dispatch resolves correctly.
// context native edits use, serialized through the apply queue.
void applyChanged( BOARD_ITEM* aItem, const json& j )
{
if( isTrackType( aItem->Type() ) && j.contains( "sx" ) )
@ -915,7 +882,7 @@ void flushDiff()
// Attach an s-expr clipboard blob ONLY for types makeItem reconstructs from it
// (footprints, board graphics, …). Tracks/vias/zones/text rebuild NATIVELY from the
// fields itemToJson already emitted, so they need no blob — and skipping it avoids a
// wasted SaveSelection plus the asyncify-fragile envelope parse for those.
// wasted SaveSelection plus the envelope parse for those.
if( live && !isTrackType( live->Type() ) && live->Type() != PCB_ZONE_T
&& live->Type() != PCB_TEXT_T )
withBlob["sexpr"] = blobForItem( board, live );
@ -971,10 +938,11 @@ void flushDiff()
// Coalesce all the listener callbacks of one commit (and any other edits in the same loop
// turn) into a single post-settle diff.
// flushDiff runs inside a COROUTINE: the v2 items emit serializes ROOT items via
// CLIPBOARD_IO Format (blobForItem), whose virtual dispatch is only reliable on the
// libcontext fiber stack — on the bare CallAfter stack it can trap and silently kill
// the whole flush, legacy emit included (same lesson as doApply / eeschema 0007).
// flushDiff runs inside a COROUTINE via the apply queue: the v2 items emit serializes
// ROOT items via CLIPBOARD_IO Format (blobForItem), and running it in the same queue
// as the applies keeps emits from interleaving with a suspended apply body (under the
// retired asyncify runtime the bare CallAfter stack additionally trapped here — same
// lesson as doApply / eeschema 0007).
void scheduleFlush()
{
if( g_flushScheduled )
@ -983,7 +951,7 @@ void scheduleFlush()
g_flushScheduled = true;
if( PCB_EDIT_FRAME* fr = pcbFrame() )
pcbjam_collab::runOnFiber( fr, []() { flushDiff(); } );
pcbjam_collab::runOnCoroutine( fr, []() { flushDiff(); } );
else
flushDiff();
}
@ -1246,10 +1214,9 @@ void doApplyItems( PCB_EDIT_FRAME* aFrame, const json& aWire )
}
// Test/PoC move (the BOARD_COMMIT body for kicadCollabTestMoveFirst). Run inside a COROUTINE by
// the caller: `BOARD_ITEM::Move` is virtual, and dispatched off the app main stack (a bare
// CallAfter) it hits the asyncify call_indirect mis-dispatch and silently NO-OPS — the commit
// dirties the board but the item doesn't move. On the fiber stack (where native tool edits and
// doApply run) it dispatches correctly. (Same lesson as eeschema's devirtualized move.)
// the caller — the context native tool edits and doApply run in, serialized through the apply
// queue. (Under the retired asyncify runtime the virtual `BOARD_ITEM::Move` additionally
// mis-dispatched off that context; same lesson as eeschema's devirtualized move.)
void collabTestMove( PCB_EDIT_FRAME* aFrame, BOARD_ITEM* aItem, int aDx, int aDy )
{
BOARD_COMMIT commit( aFrame );
@ -1344,8 +1311,8 @@ pcbjam_presence::CORE& presenceCore()
// Draw ONE item's selection box under the given style. Exact-geometry
// outline (style shape 5): footprints hug their bounding hull,
// everything else its transformed shape. Runs on the coroutine fiber
// (virtual dispatch), falls back to the bbox on anything that can't
// everything else its transformed shape. Runs on the apply
// coroutine, falls back to the bbox on anything that can't
// produce a polygon.
auto drawItem = [&]( BOARD_ITEM* item, const std::string& name,
const KIGFX::COLOR4D& itemColor,
@ -1447,19 +1414,18 @@ void schedulePresenceSelCheck()
// JS → C++. Apply a remote per-item delta by uuid, through BOARD_COMMIT so connectivity/ratsnest
// recompute the same way a UI edit would (0004 §apply).
//
// BOARD_COMMIT must run in the editor's Asyncify-rooted main loop — invoking it from this embind
// ccall, or from a setTimeout callback, traps with an "indirect call signature mismatch" (those
// aren't the asyncify root). wxEvtHandler::CallAfter queues onto the app's pending-event list,
// drained every frame by the wasm main loop (src/wasm/evtloop.cpp) — the exact context real UI
// edits run in. Additionally run the mutation inside a COROUTINE so it executes on a libcontext
// fiber stack: BOARD_COMMIT::Push's CHT_ADD of a freshly-built item dispatches GAL virtuals
// (view->Add → ViewGetLayers) through asyncify-instrumented invoke_*; off the fiber stack those
// mis-dispatch and trap inside KiCad core, on it they dispatch correctly (eeschema 0007).
// BOARD_COMMIT must run on the editor's main loop, not on this embind ccall or a setTimeout
// callback: wxEvtHandler::CallAfter queues onto the app's pending-event list, drained every
// frame by the wasm main loop (src/wasm/evtloop.cpp) — the exact context real UI edits run in.
// Additionally run the mutation inside a COROUTINE via the collab_common.h apply queue: a
// commit body that suspends (connectivity/GAL work) returns early from COROUTINE::Call, so
// applies must serialize with each other and with local edits or they interleave on shared
// commit/listener state (eeschema 0007, drift-trio #10).
void pcbCollabApply( std::string aJson )
{
// Open-in-flight guard (open_gate.h): never touch the model while a
// kicadOpenFile Asyncify chain is parked mid-load — commits/virtuals on a
// half-built board mis-dispatch ("indirect call signature mismatch").
// kicadOpenFile chain is suspended mid-load — commits/virtuals would walk
// a half-built board mid-mutation.
// Callers gate on kicadOpenFileBusy; fuzzed by tests/kicad/collab-load-fuzz.spec.ts.
if( pcbjam_open::busy() )
return;
@ -1474,7 +1440,7 @@ void pcbCollabApply( std::string aJson )
if( !fr )
return;
pcbjam_collab::runOnFiber( fr, [fr, delta]() { doApply( fr, delta ); } );
pcbjam_collab::runOnCoroutine( fr, [fr, delta]() { doApply( fr, delta ); } );
}
@ -1495,7 +1461,7 @@ void pcbCollabApplyItems( std::string aJson )
if( !fr )
return;
pcbjam_collab::runOnFiber( fr, [fr, wire]() { doApplyItems( fr, wire ); } );
pcbjam_collab::runOnCoroutine( fr, [fr, wire]() { doApplyItems( fr, wire ); } );
}
@ -1631,9 +1597,9 @@ std::string pcbCollabTestMoveFirst( int aDx, int aDy )
return;
movedId = toUtf8( item->m_Uuid.AsString() );
// Main stack + fiber (runOnFiber), so the virtual Move()
// dispatches instead of no-opping — same wrapping as doApply.
pcbjam_collab::runOnFiber( fr, [fr, item, aDx, aDy]() {
// Main loop + apply coroutine (runOnCoroutine) — same
// wrapping as doApply.
pcbjam_collab::runOnCoroutine( fr, [fr, item, aDx, aDy]() {
collabTestMove( fr, item, aDx, aDy );
} );
} );
@ -1997,9 +1963,8 @@ std::string kicadCollabTestItemBlob( std::string aId )
// ── ysync-review repro hooks ─────────────────────────────────────────────────
// Local-edit test hooks for the ysync-review repro e2e (docs/features/
// ysync-review on the ysync-review branch): each drives a REAL BOARD_COMMIT on
// the app main stack inside a COROUTINE fiber (the collabTestMove wrapping —
// virtual item mutators mis-dispatch off the fiber stack), so the
// COLLAB_LISTENER → flushDiff emit path runs exactly as for a UI edit. Each
// the app main loop inside the apply COROUTINE (the collabTestMove wrapping),
// so the COLLAB_LISTENER → flushDiff emit path runs exactly as for a UI edit. Each
// returns false when the uuid doesn't resolve, letting the spec distinguish
// "hook missed the item" from "differ missed the edit" (bug 04).
@ -2035,7 +2000,7 @@ bool pcbCollabTestRemoveItem( std::string aId )
if( !item )
return false;
pcbjam_collab::runOnFiber( fr, [fr, item]() {
pcbjam_collab::runOnCoroutine( fr, [fr, item]() {
BOARD_COMMIT commit( fr );
commit.Remove( item );
commit.Push( wxT( "Collab test remove" ) );
@ -2055,7 +2020,7 @@ bool pcbCollabTestRotateItem( std::string aId, double aDeg )
if( !item )
return false;
pcbjam_collab::runOnFiber( fr, [fr, item, aDeg]() {
pcbjam_collab::runOnCoroutine( fr, [fr, item, aDeg]() {
BOARD_COMMIT commit( fr );
commit.Modify( item );
item->Rotate( item->GetPosition(), EDA_ANGLE( aDeg, DEGREES_T ) );
@ -2077,7 +2042,7 @@ bool pcbCollabTestSetPadSize( std::string aId, int aW, int aH )
PAD* pad = static_cast<PAD*>( item );
pcbjam_collab::runOnFiber( fr, [fr, pad, aW, aH]() {
pcbjam_collab::runOnCoroutine( fr, [fr, pad, aW, aH]() {
BOARD_COMMIT commit( fr );
commit.Modify( pad );
pad->SetSize( PADSTACK::ALL_LAYERS, VECTOR2I( aW, aH ) );
@ -2098,7 +2063,7 @@ bool pcbCollabTestMoveEndpoint( std::string aId, int aDx, int aDy )
if( !item || ( !isTrackType( item->Type() ) && item->Type() != PCB_SHAPE_T ) )
return false;
pcbjam_collab::runOnFiber( fr, [fr, item, aDx, aDy]() {
pcbjam_collab::runOnCoroutine( fr, [fr, item, aDx, aDy]() {
BOARD_COMMIT commit( fr );
commit.Modify( item );
@ -2121,7 +2086,7 @@ bool pcbCollabTestMoveEndpoint( std::string aId, int aDx, int aDy )
// ── drift-trio phase B action hooks (standalone-hardening 0008 §5) ───────────
// Creation/mutation primitives for the trio harness's action catalog. Each
// drives a REAL BOARD_COMMIT on the fiber stack, so the BOARD_LISTENER →
// drives a REAL BOARD_COMMIT on the apply coroutine, so the BOARD_LISTENER →
// flushDiff emit path runs exactly as for a UI edit. Names are tool-unique
// (registered outside the KICAD_MERGED_EMBIND guard — same convention as
// kicadCollabTestSetPadSize), so the merged image needs no dispatcher.
@ -2140,7 +2105,7 @@ static std::string pcbCollabTestCommitAdd( BOARD_ITEM* aItem, const wxChar* aMsg
std::string id = toUtf8( aItem->m_Uuid.AsString() );
wxString msg( aMsg );
pcbjam_collab::runOnFiber( fr, [fr, aItem, msg]() {
pcbjam_collab::runOnCoroutine( fr, [fr, aItem, msg]() {
BOARD_COMMIT commit( fr );
commit.Add( aItem );
commit.Push( msg );
@ -2221,7 +2186,7 @@ bool pcbCollabTestFlipBoardItem( std::string aId )
if( !testResolve( fr, aId ) )
return false;
pcbjam_collab::runOnFiber( fr, [fr, aId]() { // re-resolve on the fiber (S4)
pcbjam_collab::runOnCoroutine( fr, [fr, aId]() { // re-resolve on the coroutine (S4)
BOARD_ITEM* item = testResolve( fr, aId );
if( !item )
@ -2251,7 +2216,7 @@ bool pcbCollabTestSetFootprintField( std::string aId, std::string aField, std::s
if( !isRef && aField != "Value" )
return false;
pcbjam_collab::runOnFiber( fr, [fr, aId, text, isRef]() { // re-resolve on the fiber (S4)
pcbjam_collab::runOnCoroutine( fr, [fr, aId, text, isRef]() { // re-resolve on the coroutine (S4)
BOARD_ITEM* live = testResolve( fr, aId );
if( !live || live->Type() != PCB_FOOTPRINT_T )
@ -2279,7 +2244,7 @@ bool pcbCollabTestSetBoardItemLocked( std::string aId, bool aLocked )
if( !testResolve( fr, aId ) )
return false;
pcbjam_collab::runOnFiber( fr, [fr, aId, aLocked]() { // re-resolve on the fiber (S4)
pcbjam_collab::runOnCoroutine( fr, [fr, aId, aLocked]() { // re-resolve on the coroutine (S4)
BOARD_ITEM* item = testResolve( fr, aId );
if( !item )
@ -2294,7 +2259,7 @@ bool pcbCollabTestSetBoardItemLocked( std::string aId, bool aLocked )
return true;
}
// By-uuid variant of MoveFirst (same fiber + BOARD_COMMIT body).
// By-uuid variant of MoveFirst (same coroutine + BOARD_COMMIT body).
bool pcbCollabTestMoveBoardItem( std::string aId, int aDx, int aDy )
{
PCB_EDIT_FRAME* fr = pcbFrame();
@ -2302,11 +2267,11 @@ bool pcbCollabTestMoveBoardItem( std::string aId, int aDx, int aDy )
if( !testResolve( fr, aId ) )
return false;
// Re-resolve ON the fiber: a remote remove can apply between scheduling
// Re-resolve ON the coroutine: a remote remove can apply between scheduling
// and running, and doApplyItems FREES removed items — a captured pointer
// would be dangling and the commit would resurrect a deleted item
// (drift-trio S4 move-vs-delete). Vanished => the move loses, silently.
pcbjam_collab::runOnFiber( fr, [fr, aId, aDx, aDy]() {
pcbjam_collab::runOnCoroutine( fr, [fr, aId, aDx, aDy]() {
if( BOARD_ITEM* item = testResolve( fr, aId ) )
collabTestMove( fr, item, aDx, aDy );
} );
@ -2330,7 +2295,7 @@ std::string pcbCollabTestDuplicateBoardItem( std::string aId, int aDx, int aDy )
std::string id = toUtf8( dup->m_Uuid.AsString() );
pcbjam_collab::runOnFiber( fr, [fr, dup]() {
pcbjam_collab::runOnCoroutine( fr, [fr, dup]() {
BOARD_COMMIT commit( fr );
commit.Add( dup );
commit.Push( wxT( "Collab test duplicate" ) );
@ -2389,9 +2354,9 @@ std::string Pad_GetPinFunction(PAD* pad) {
return pad->GetPinFunction().ToStdString();
}
static bool kicadCollabFiberBusyProbe()
static bool kicadCollabBusyProbe()
{
return pcbjam_collab::fiberBusy() || !pcbjam_collab::fiberQueue().empty();
return pcbjam_collab::applyBusy() || !pcbjam_collab::applyQueue().empty();
}
EMSCRIPTEN_BINDINGS(pcbnew) {
@ -2436,13 +2401,7 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
function("kicadTestSetOpenPark", &kicadTestSetOpenPark);
function("kicadTestArmTimerPark", &kicadTestArmTimerPark);
function("kicadTestTimerParkState", &kicadTestTimerParkState);
function("kicadTestFiberParkStart", &kicadTestFiberParkStart);
function("kicadTestFiberParkPrime", &kicadTestFiberParkPrime);
function("kicadTestFiberParkPoke", &kicadTestFiberParkPoke);
function("kicadTestFiberParkState", &kicadTestFiberParkState);
function("kicadTestFiberParkStartSecond", &kicadTestFiberParkStartSecond);
function("kicadTestFiberParkPokeSecond", &kicadTestFiberParkPokeSecond);
function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe);
function("kicadCollabBusy", &kicadCollabBusyProbe);
// Read-only viewer lock (read-only-viewer).
function("kicadSetReadOnly", &kicadSetReadOnly);
// Yjs collaborative bridge entry points (same contract as pl_editor / eeschema).

View file

@ -18,7 +18,6 @@
#include <wx/window.h>
#include <nlohmann/json.hpp>
#include "open_gate.h"
#include "main_stack_runner.h"
#include "pcbjam_async_policy.h"
#include <eda_draw_frame.h>
#include <kiid.h>
@ -40,7 +39,7 @@ using json = nlohmann::json;
// File→Open.
bool kicadOpenFile( std::string path )
{
// Held across every Asyncify park of the load; see open_gate.h.
// Held across every suspension of the load; see open_gate.h.
pcbjam_open::BusyGuard busy;
if( pcbjam_open::testParkMs() > 0 )
@ -315,7 +314,7 @@ void addBlob( DS_DATA_MODEL& aModel, const json& j )
void kicadCollabApply( std::string aJson )
{
// Open-in-flight guard (open_gate.h): never touch the model while a
// kicadOpenFile Asyncify chain is parked mid-load; see kicadOpenFileBusy.
// kicadOpenFile chain is suspended mid-load; see kicadOpenFileBusy.
if( pcbjam_open::busy() )
return;

View file

@ -1,27 +1,24 @@
/*
* Test-only deterministic repro lever for the production board-load trap
* family ("index out of bounds" / "unreachable executed" in doRewind —
* gal-refresh-timer investigation, docs/features/async/14-open-settle-gate.md
* lineage).
* Test-only deterministic lever: a SUSPENDING wx-timer handler running
* concurrently with the main loop's per-frame suspension (gal-refresh-timer
* investigation, docs/features/async/14-open-settle-gate.md lineage).
*
* The surviving hypothesis after the v0.1.19 crash log (2026-07-31): a wx
* timer callback is a FRESH JS→wasm entry (emscripten_async_call →
* TimerCallbackFunc::Run → Notify). The main loop is Asyncify-parked in
* wxWasmYieldToBrowser for most of wall-clock time, so a timer handler that
* itself parks creates TWO live Asyncify contexts over the single-slot
* Asyncify.currData — the emscripten #9153 family the handlesleep.js shim
* silently repairs. Add a fiber swap (the collab entries run on
* TOOL_MANAGER coroutines — emscripten_fiber_swap, which bypasses the shim's
* allocateData accounting entirely) and the prod trap's exact second stack
* (doRewind → finishContextSwitch under __asyncjs__wxWasmYieldToBrowser)
* becomes constructible on demand.
* A wx timer callback is a FRESH JS→wasm entry (emscripten_async_call →
* TimerCallbackFunc::Run → Notify). The main loop spends most wall-clock time
* suspended in wxWasmYieldToBrowser, so a timer handler that itself suspends
* puts a second suspended activation in flight over the shared scheduler
* state — the concurrency shape the scheduler's turnstile and the dispatch
* interlock must absorb. While the handler is suspended,
* wxWasmDispatchParked() reads true and every other due timer spins the 17 ms
* retry loop — the [wx-timer] storm diagnostic window this lever opens on
* demand.
*
* Natural occurrences need a timer handler that parks mid-paint (GAL init /
* lib bridge) — scheduler-dependent, never reproduced locally in 10+
* Natural occurrences need a timer handler that suspends mid-paint (GAL init
* / lib bridge) — scheduler-dependent, never reproduced locally in 10+
* attempts. This lever makes the window deterministic: arm a one-shot wx
* timer whose Notify() emscripten_sleep()s for a fixed time; the e2e then
* hammers fiber-based collab entries through the window and asserts the
* runtime SURVIVES (red on a build where the hypothesis holds).
* hammers coroutine-based collab entries through the window and asserts the
* runtime SURVIVES.
*
* Production is unaffected: nothing fires unless kicadTestArmTimerPark is
* called.