fix(load): open-settle gate — kicadOpenFileBusy probe + collab entry guards for the parked-open embind trap (indirect call signature mismatch) + deterministic collab-load-fuzz e2e

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
This commit is contained in:
Gergő Törcsvári 2026-07-30 14:17:48 +02:00
commit a26ef4ebeb
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
11 changed files with 964 additions and 17 deletions

View file

@ -59,6 +59,7 @@
#include <tool/coroutine.h>
#include <pcbjam_remote_lock.h>
#include "collab_common.h"
#include "open_gate.h"
#include "collab_presence_core.h"
#include "collab_presence_style.h"
#include "pcbjam_theme.h"
@ -82,6 +83,12 @@ 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.
pcbjam_open::BusyGuard busy;
if( pcbjam_open::testParkMs() > 0 )
emscripten_sleep( pcbjam_open::testParkMs() );
KIWAY_PLAYER* frame =
wxTheApp ? static_cast<KIWAY_PLAYER*>( wxTheApp->GetTopWindow() ) : nullptr;
@ -91,8 +98,28 @@ bool kicadOpenFile( std::string path )
if( wxWindow* blocking = frame->Kiway().GetBlockingDialog() )
blocking->Close( true );
return frame->OpenProjectFiles(
bool ok = frame->OpenProjectFiles(
std::vector<wxString>( 1, wxString::FromUTF8( path.c_str() ) ) );
// Test-only post-load park (open_gate.h): model fully loaded, gate still
// closed — the deterministic window the collab-load-fuzz spec hammers.
if( pcbjam_open::testParkMs() > 0 )
emscripten_sleep( pcbjam_open::testParkMs() );
return ok;
}
// JS-pollable open-in-flight probe (open_gate.h): the web shell defers the
// collab/presence attach until the open chain has truly completed.
bool kicadOpenFileBusy()
{
return pcbjam_open::busy();
}
// Test-only (collab-load-fuzz): arm the deterministic open parks.
void kicadTestSetOpenPark( int aMs )
{
pcbjam_open::testParkMs() = aMs;
}
// Read-only viewer lock (read-only-viewer): flips the process-global
@ -1119,6 +1146,13 @@ void collabTestMove( SCH_EDIT_FRAME* aFrame, SCH_ITEM* aItem, SCH_SCREEN* aScree
// 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").
// Callers gate on kicadOpenFileBusy; fuzzed by tests/kicad/collab-load-fuzz.spec.ts.
if( pcbjam_open::busy() )
return;
json delta = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
if( delta.is_discarded() )
@ -1142,6 +1176,10 @@ void schCollabApply( std::string aJson )
// registers the change listener on first call.
std::string schCollabSnapshot()
{
if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see schCollabApply
return json{ { "added", json::array() }, { "changed", json::array() },
{ "removed", json::array() } }.dump();
ensureBridge();
json added = snapshotItems( schFrame() );
@ -1158,6 +1196,9 @@ std::string schCollabSnapshot()
// (LoadContent + SCH_COMMIT must run where native edits run).
void schCollabApplyItems( std::string aJson )
{
if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see schCollabApply
return;
json wire = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
if( wire.is_discarded() )
@ -1177,6 +1218,10 @@ void schCollabApplyItems( std::string aJson )
// Registers the listener + rebaselines exactly like kicadCollabSnapshot.
std::string schCollabSnapshotItems()
{
if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see schCollabApply
return json{ { "added", json::array() }, { "changed", json::array() },
{ "removed", json::array() } }.dump();
SCH_EDIT_FRAME* fr = schFrame();
json added = json::array();
@ -2016,6 +2061,8 @@ 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("kicadOpenFileBusy", &kicadOpenFileBusy);
function("kicadTestSetOpenPark", &kicadTestSetOpenPark);
function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe);
// Read-only viewer lock (read-only-viewer).
function("kicadSetReadOnly", &kicadSetReadOnly);

View file

@ -40,6 +40,7 @@
#include <project.h>
#include "pcbjam_libs_reload.h"
#include "open_gate.h"
using namespace emscripten;
@ -135,6 +136,12 @@ 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.
pcbjam_open::BusyGuard busy;
if( pcbjam_open::testParkMs() > 0 )
emscripten_sleep( pcbjam_open::testParkMs() );
KIWAY_PLAYER* frame =
wxTheApp ? static_cast<KIWAY_PLAYER*>( wxTheApp->GetTopWindow() ) : nullptr;
@ -144,8 +151,28 @@ static bool kicadOpenFile( std::string path )
if( wxWindow* blocking = frame->Kiway().GetBlockingDialog() )
blocking->Close( true );
return frame->OpenProjectFiles(
bool ok = frame->OpenProjectFiles(
std::vector<wxString>( 1, wxString::FromUTF8( path.c_str() ) ) );
// Test-only post-load park (open_gate.h): model fully loaded, gate still
// closed — the deterministic window the collab-load-fuzz spec hammers.
if( pcbjam_open::testParkMs() > 0 )
emscripten_sleep( pcbjam_open::testParkMs() );
return ok;
}
// 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()
{
return pcbjam_open::busy();
}
// Test-only (collab-load-fuzz): arm the deterministic open parks.
static void kicadTestSetOpenPark( int aMs )
{
pcbjam_open::testParkMs() = aMs;
}
@ -500,6 +527,8 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe);
// Programmatic file open (preferred over UI automation from the web app).
function("kicadOpenFile", &kicadOpenFile);
function("kicadOpenFileBusy", &kicadOpenFileBusy);
function("kicadTestSetOpenPark", &kicadTestSetOpenPark);
// Canvas-only mobile mode (features/mobile).
function("kicadSetChrome", &kicadSetChrome);

56
wasm/bindings/open_gate.h Normal file
View file

@ -0,0 +1,56 @@
/*
* 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).
*
* 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
* truly returns (the same primitive as wxWasmDispatchGuard). A trap escaping
* the open leaves the count stuck the JS poll times out and degrades.
*/
#pragma once
namespace pcbjam_open
{
inline int& busyCount()
{
static int s_count = 0;
return s_count;
}
struct BusyGuard
{
BusyGuard() { ++busyCount(); }
~BusyGuard() { --busyCount(); }
};
/** JS-pollable: is a kicadOpenFile chain still in flight (possibly parked)? */
inline bool busy()
{
return busyCount() > 0;
}
/**
* 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.
*/
inline int& testParkMs()
{
static int s_ms = 0;
return s_ms;
}
} // namespace pcbjam_open

View file

@ -52,6 +52,7 @@
#include <nlohmann/json.hpp>
#include "collab_common.h"
#include "collab_presence_core.h"
#include "open_gate.h"
#include "collab_presence_style.h"
#include "pcbjam_theme.h"
#include "pcbjam_libs_reload.h"
@ -84,6 +85,12 @@ 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.
pcbjam_open::BusyGuard busy;
if( pcbjam_open::testParkMs() > 0 )
emscripten_sleep( pcbjam_open::testParkMs() );
KIWAY_PLAYER* frame =
wxTheApp ? static_cast<KIWAY_PLAYER*>( wxTheApp->GetTopWindow() ) : nullptr;
@ -93,8 +100,28 @@ bool kicadOpenFile( std::string path )
if( wxWindow* blocking = frame->Kiway().GetBlockingDialog() )
blocking->Close( true );
return frame->OpenProjectFiles(
bool ok = frame->OpenProjectFiles(
std::vector<wxString>( 1, wxString::FromUTF8( path.c_str() ) ) );
// Test-only post-load park (open_gate.h): model fully loaded, gate still
// closed — the deterministic window the collab-load-fuzz spec hammers.
if( pcbjam_open::testParkMs() > 0 )
emscripten_sleep( pcbjam_open::testParkMs() );
return ok;
}
// JS-pollable open-in-flight probe (open_gate.h): the web shell defers the
// collab/presence attach until the open chain has truly completed.
bool kicadOpenFileBusy()
{
return pcbjam_open::busy();
}
// Test-only (collab-load-fuzz): arm the deterministic open parks.
void kicadTestSetOpenPark( int aMs )
{
pcbjam_open::testParkMs() = aMs;
}
// Read-only viewer lock (read-only-viewer): flips the process-global
@ -1382,6 +1409,13 @@ void schedulePresenceSelCheck()
// mis-dispatch and trap inside KiCad core, on it they dispatch correctly (eeschema 0007).
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").
// Callers gate on kicadOpenFileBusy; fuzzed by tests/kicad/collab-load-fuzz.spec.ts.
if( pcbjam_open::busy() )
return;
json delta = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
if( delta.is_discarded() )
@ -1400,6 +1434,9 @@ void pcbCollabApply( std::string aJson )
// (the blob parse + commit must run where native edits run — see above).
void pcbCollabApplyItems( std::string aJson )
{
if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see pcbCollabApply
return;
json wire = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
if( wire.is_discarded() )
@ -1418,6 +1455,10 @@ void pcbCollabApplyItems( std::string aJson )
// change listener on first call.
std::string pcbCollabSnapshot()
{
if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see pcbCollabApply
return json{ { "added", json::array() }, { "changed", json::array() },
{ "removed", json::array() } }.dump();
BOARD* board = ensureBridge();
json added = json::array();
@ -1441,6 +1482,10 @@ std::string pcbCollabSnapshot()
// listener + rebaselines exactly like kicadCollabSnapshot.
std::string pcbCollabSnapshotItems()
{
if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see pcbCollabApply
return json{ { "added", json::array() }, { "changed", json::array() },
{ "removed", json::array() } }.dump();
BOARD* board = ensureBridge();
json added = json::array();
@ -2339,6 +2384,8 @@ 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("kicadOpenFileBusy", &kicadOpenFileBusy);
function("kicadTestSetOpenPark", &kicadTestSetOpenPark);
function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe);
// Read-only viewer lock (read-only-viewer).
function("kicadSetReadOnly", &kicadSetReadOnly);

View file

@ -17,6 +17,7 @@
#include <wx/string.h>
#include <wx/window.h>
#include <nlohmann/json.hpp>
#include "open_gate.h"
#include <eda_draw_frame.h>
#include <kiid.h>
#include <pcbjam_read_only.h>
@ -37,6 +38,12 @@ using json = nlohmann::json;
// File→Open.
bool kicadOpenFile( std::string path )
{
// Held across every Asyncify park of the load; see open_gate.h.
pcbjam_open::BusyGuard busy;
if( pcbjam_open::testParkMs() > 0 )
emscripten_sleep( pcbjam_open::testParkMs() );
KIWAY_PLAYER* frame =
wxTheApp ? static_cast<KIWAY_PLAYER*>( wxTheApp->GetTopWindow() ) : nullptr;
@ -46,8 +53,28 @@ bool kicadOpenFile( std::string path )
if( wxWindow* blocking = frame->Kiway().GetBlockingDialog() )
blocking->Close( true );
return frame->OpenProjectFiles(
bool ok = frame->OpenProjectFiles(
std::vector<wxString>( 1, wxString::FromUTF8( path.c_str() ) ) );
// Test-only post-load park (open_gate.h): model fully loaded, gate still
// closed — the deterministic window the collab-load-fuzz spec hammers.
if( pcbjam_open::testParkMs() > 0 )
emscripten_sleep( pcbjam_open::testParkMs() );
return ok;
}
// JS-pollable open-in-flight probe (open_gate.h): the web shell defers the
// collab attach until the open chain has truly completed.
bool kicadOpenFileBusy()
{
return pcbjam_open::busy();
}
// Test-only (collab-load-fuzz): arm the deterministic open parks.
void kicadTestSetOpenPark( int aMs )
{
pcbjam_open::testParkMs() = aMs;
}
// Read-only viewer lock (read-only-viewer): flips the process-global
@ -285,6 +312,11 @@ void addBlob( DS_DATA_MODEL& aModel, const json& j )
// resulting model mutations are not re-emitted as local changes.
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.
if( pcbjam_open::busy() )
return;
json delta = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
if( delta.is_discarded() )
@ -431,6 +463,10 @@ extern "C" void kicadCollabOnSave( const char* aPath )
// join and to (re)baseline the differ. Idempotent.
std::string kicadCollabSnapshot()
{
if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see kicadCollabApply
return json{ { "added", json::array() }, { "changed", json::array() },
{ "removed", json::array() } }.dump();
std::map<std::string, json> cur = snapshotMap();
json added = json::array();
@ -455,6 +491,10 @@ std::string kicadCollabSnapshot()
// differ exactly like kicadCollabSnapshot, so a v2 consumer gets no echo either.
std::string kicadCollabSnapshotItems()
{
if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see kicadCollabApply
return json{ { "added", json::array() }, { "changed", json::array() },
{ "removed", json::array() } }.dump();
DS_DATA_MODEL& model = DS_DATA_MODEL::GetTheInstance();
json added = json::array();
@ -474,6 +514,9 @@ std::string kicadCollabSnapshotItems()
// drop any pre-existing item that shares an appended uuid (replace-by-uuid).
void kicadCollabApplyItems( std::string aJson )
{
if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see kicadCollabApply
return;
json wire = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
if( wire.is_discarded() )
@ -573,6 +616,8 @@ std::string kicadCollabTestAddText( std::string aText, double aX, double aY )
EMSCRIPTEN_BINDINGS(pl_editor) {
// Programmatic file open (preferred over UI automation from the web app).
function("kicadOpenFile", &kicadOpenFile);
function("kicadOpenFileBusy", &kicadOpenFileBusy);
function("kicadTestSetOpenPark", &kicadTestSetOpenPark);
// Read-only viewer lock (read-only-viewer).
function("kicadSetReadOnly", &kicadSetReadOnly);
function("kicadSaveDrawingSheet", &kicadSaveDrawingSheet);