fix(ysync): review bugs 01–07 — listener registration, pad nets, child removals, dirty-set emit, targeted rebaseline, seed arbitration, stale hook (doc 17)

All seven ysync-review bugs fixed and verified; every repro's expected-fail
marker removed (they now run as regression tests). Also fixes two bugs found
while verifying (doc 17 F5/F6): file-seeded Y bodies are re-upserted in the
editor's serialization (doc-16 F4 was an artifact of bug 01), and the
0008-era "asyncify-fragile envelope parse" was really wrapInBoardEnvelope
emitting display layer names — canonical LSET::Name() fixes track/via/zone
v2 applies; makeFromBlob now logs parse errors instead of swallowing them.

Verified: shared 98, standalone collab 37, ysync e2e 20/20 (chromium),
collab regression set 21 passed / 3 pre-existing skips (firefox).

Bumps: kicad (board_commit child-removal listener notification),
web/pcbjam-shared (slot prune + arbitrated seed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgThWXtdvrYLK47EDFoGdq
This commit is contained in:
Gergő Törcsvári 2026-07-03 18:08:37 +02:00
commit ae5d41a3d7
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
18 changed files with 527 additions and 109 deletions

View file

@ -387,12 +387,62 @@ std::map<std::string, json> snapshotByUuid( SCH_EDIT_FRAME* aFrame )
std::map<std::string, json> g_baseline;
bool g_flushScheduled = false;
// Roots the listener saw change since the last flush (uuids, captured at callback
// time — removed items may be freed before the flush runs). The scalar snapshot
// diff is a LOSSY projection: rotations/mirrors (anchor unchanged), field text
// edits (the commit stages the SCH_FIELD, which is not a screen item), stroke
// properties etc. never move it (bug 04). Dirty roots emit their v2 blob
// unconditionally; the apply is an idempotent upsert and the TS layer drops
// no-op bodies, so a false positive costs one local serialization.
std::set<std::string> g_dirty;
// Lift a commit-staged item to the SCREEN item the differ tracks (a field/pin/cell
// lifts to its symbol/sheet/label/table — same promotion sch_commit's undo uses).
void noteDirty( SCH_ITEM* aItem )
{
if( !aItem )
return;
while( EDA_ITEM* p = aItem->GetParent() )
{
if( !p->IsType( { SCH_SYMBOL_T, SCH_TABLE_T, SCH_SHEET_T, SCH_LABEL_LOCATE_ANY_T } ) )
break;
aItem = static_cast<SCH_ITEM*>( p );
}
g_dirty.insert( toUtf8( aItem->m_Uuid.AsString() ) );
}
// Re-seed the diff baseline to the current model — after handing out a seed snapshot, or after
// applying a remote delta (so those items aren't re-broadcast as a spurious local diff/echo).
// Declares "current model == broadcast state", so pending dirty marks are stale too — on a
// sheet switch they'd otherwise emit the OLD sheet's items into the new sheet's room.
void rebaseline()
{
if( SCH_EDIT_FRAME* fr = schFrame() )
g_baseline = snapshotByUuid( fr );
g_dirty.clear();
}
// TARGETED rebaseline (bug 05): refresh baseline entries ONLY for the uuids a remote
// apply touched. A global rebaseline() would fold a concurrently-committed local edit
// (its flush is queued BEHIND the apply on the same pending-event list) into the
// baseline and silently swallow it; targeted, the edit still diffs and emits. The
// connectivity cleanup the apply's Push produced likewise stays diffable — the
// post-apply flush broadcasts it (idempotent on the original sender).
void rebaselineTouched( SCH_EDIT_FRAME* aFrame, const std::vector<std::string>& aIds )
{
for( const std::string& id : aIds )
{
g_baseline.erase( id );
KIID kid( wxString::FromUTF8( id.c_str() ) );
if( SCH_ITEM* live = aFrame->Schematic().ResolveItem( kid, nullptr, /*allowNull*/ true ) )
g_baseline[id] = itemToJson( live );
}
}
// Diff the current (settled, post-cleanup) model against the baseline and broadcast the change.
@ -411,10 +461,14 @@ void flushDiff()
// v2 items wire (per-item s-expr blobs), built from the same diff. Screen items
// are already root-level (fields live inside their symbols), so no lifting.
json wAdded = json::array(), wChanged = json::array();
json wAdded = json::array(), wChanged = json::array();
std::set<std::string> wDone;
auto blobFor = [&]( const std::string& id, json& aArr )
{
if( !wDone.insert( id ).second )
return;
KIID kid( wxString::FromUTF8( id.c_str() ) );
if( SCH_ITEM* item = fr->Schematic().ResolveItem( kid, nullptr, /*allowNull*/ true ) )
@ -443,13 +497,22 @@ void flushDiff()
removed.push_back( id );
}
// Dirty roots (bug 04): whatever the listener saw commit emits its blob on the
// v2 wire even when the scalar projection didn't move (rotation, field text,
// stroke edits). wDone dedups against the scalar-diff emits; deleted ids
// resolve null inside blobFor and skip (the removal loop covered them).
for( const std::string& id : g_dirty )
blobFor( id, wChanged );
g_dirty.clear();
g_baseline = std::move( cur );
if( !added.empty() || !changed.empty() || !removed.empty() )
{
emit( json{ { "added", added }, { "changed", changed }, { "removed", removed } } );
if( !wAdded.empty() || !wChanged.empty() || !removed.empty() )
emitItems( json{ { "added", wAdded }, { "changed", wChanged }, { "removed", removed } } );
}
}
// Coalesce all the listener callbacks of one commit (and any other edits in the same loop
@ -548,10 +611,10 @@ public:
if( item->Type() == SCH_SHEET_T )
scheduleSheetSave( static_cast<SCH_SHEET*>( item ) );
}
trigger();
trigger( aItems );
}
void OnSchItemsChanged( SCHEMATIC&, std::vector<SCH_ITEM*>& ) override { trigger(); }
void OnSchItemsRemoved( SCHEMATIC&, std::vector<SCH_ITEM*>& ) override { trigger(); }
void OnSchItemsChanged( SCHEMATIC&, std::vector<SCH_ITEM*>& v ) override { trigger( v ); }
void OnSchItemsRemoved( SCHEMATIC&, std::vector<SCH_ITEM*>& v ) override { trigger( v ); }
// The editor switched to a different sheet (a different .kicad_sch == a different
// collab room). Re-baseline so the first edit on the new sheet diffs against ITS
@ -565,10 +628,17 @@ public:
}
private:
void trigger()
// Capture the touched roots at callback time (fields/pins lift to their
// symbol — noteDirty), then coalesce into one post-settle flush.
void trigger( const std::vector<SCH_ITEM*>& aItems )
{
if( !s_applyingRemote )
scheduleFlush();
if( s_applyingRemote )
return;
for( SCH_ITEM* item : aItems )
noteDirty( item );
scheduleFlush();
}
};
@ -744,11 +814,15 @@ void doApplyItems( SCH_EDIT_FRAME* aFrame, const json& aWire )
SCH_COMMIT commit( aFrame );
bool staged = false;
std::vector<std::string> touched; // uuids this apply acts on (targeted rebaseline)
for( const json& rid : aWire.value( "removed", json::array() ) )
{
SCH_SHEET_PATH path;
KIID id( wxString::FromUTF8( rid.get<std::string>().c_str() ) );
touched.push_back( rid.get<std::string>() );
if( SCH_ITEM* item = sch.ResolveItem( id, &path, /*allowNull*/ true ) )
{
commit.Remove( item, path.LastScreen() );
@ -830,6 +904,7 @@ void doApplyItems( SCH_EDIT_FRAME* aFrame, const json& aWire )
}
item->SetParent( &sch );
touched.push_back( toUtf8( item->m_Uuid.AsString() ) );
commit.Add( item, aFrame->GetScreen() );
staged = true;
}
@ -843,10 +918,13 @@ void doApplyItems( SCH_EDIT_FRAME* aFrame, const json& aWire )
if( staged )
commit.Push( wxT( "Collaborative edit (items)" ) );
// Fold the applied state into the baseline so the post-apply listener flush
// doesn't re-broadcast it as a local diff (echo).
rebaseline();
// Fold ONLY the applied uuids into the baseline (echo suppression), then flush:
// anything else that now differs — a concurrent local edit, the connectivity
// cleanup this apply's Push produced — broadcasts as a normal local diff
// instead of being swallowed (bug 05).
rebaselineTouched( aFrame, touched );
s_applyingRemote = false;
scheduleFlush();
}
// Test/PoC move (the SCH_COMMIT body for kicadCollabTestMoveFirst, deferred via CallAfter).

View file

@ -185,6 +185,12 @@ json itemToJson( BOARD_ITEM* aItem )
{ "layer", itemLayer( aItem ) }, // devirtualized — aItem->GetLayer() mis-dispatches here
};
// Parent footprint uuid (or absent for roots). Carried in the baseline so a
// REMOVED child can still be attributed to its parent after the live item is
// gone — flushDiff lifts such removals to a parent re-blob on the v2 wire.
if( FOOTPRINT* fp = aItem->GetParentFootprint() )
j["parent"] = toUtf8( fp->m_Uuid.AsString() );
if( isTrackType( aItem->Type() ) )
{
auto* tr = static_cast<PCB_TRACK*>( aItem );
@ -290,10 +296,11 @@ std::string blobForItem( BOARD* aBoard, BOARD_ITEM* aItem )
}
// The rest of SaveSelection's footprint safety steps, minus the refPoint move
// (the wire carries absolute positions).
for( PAD* pad : copy.Pads() )
pad->SetNetCode( 0 );
// (the wire carries absolute positions) and minus SetNetCode(0): zeroing pad
// nets is a paste-into-FOREIGN-board safety, but collab peers edit the SAME
// board — nets must survive the wire. KiCad 10 formats pad nets by NAME and
// the parser resolves by name against the receiver's board (creating the net
// if missing), so no code remapping is needed on apply.
copy.SetLocked( false );
CLIPBOARD_IO io;
@ -333,9 +340,26 @@ BOARD_ITEM* makeFromBlob( BOARD& aBoard, const std::string& aBlob )
CLIPBOARD_IO io;
io.SetBoard( &aBoard );
io.SetReader( [&aBlob]() -> wxString { return wxString::FromUTF8( aBlob.c_str() ); } );
BOARD_ITEM* parsed = io.Parse(); // FOOTPRINT* (bare) | BOARD* (envelope) | nullptr
// Parse directly (not io.Parse(), whose catch(...) swallows the error): a
// failed apply must say WHY, or wire bugs surface as silent non-convergence.
BOARD_ITEM* parsed = nullptr; // FOOTPRINT* (bare) | BOARD* (envelope) | nullptr
try
{
parsed = io.PCB_IO_KICAD_SEXPR::Parse( wxString::FromUTF8( aBlob.c_str() ) );
}
catch( const IO_ERROR& e )
{
EM_ASM( { console.log( "[collab] pcbnew blob parse error: " + UTF8ToString( $0 ) ); },
std::string( e.What().utf8_str() ).c_str() );
return nullptr;
}
catch( ... )
{
EM_ASM( { console.log( "[collab] pcbnew blob parse error: unknown exception" ); } );
return nullptr;
}
if( !parsed )
return nullptr;
@ -384,8 +408,12 @@ std::string wrapInBoardEnvelope( BOARD& aBoard, const std::string& aItemSexpr )
const char* type = IsCopperLayer( id ) ? LAYER::ShowType( aBoard.GetLayerType( id ) )
: "user";
// CANONICAL name (LSET::Name), NOT GetLayerName(): the parser validates
// position 2 against the fixed layer hash, and user-visible names differ
// from canonical ones (e.g. "B.Courtyard" vs "B.CrtYd") — the envelope
// parse threw "not in fixed layer hash" for any board with such layers.
s += " (" + std::to_string( (int) id ) + " \""
+ std::string( aBoard.GetLayerName( id ).utf8_str() ) + "\" " + type + ")";
+ std::string( LSET::Name( id ).utf8_str() ) + "\" " + type + ")";
}
s += ") " + aItemSexpr + ")";
@ -562,12 +590,85 @@ std::map<std::string, json> snapshotByUuid( BOARD& aBoard )
std::map<std::string, json> g_baseline;
bool g_flushScheduled = false;
// Roots the listener saw change since the last flush (uuids, children lifted to
// their footprint at capture time). The scalar snapshot diff below is a LOSSY
// projection (id/type/x/y/layer + a few extras) — edits that don't move the
// projection (pad/zone property edits, anchor-centred rotations, endpoint drags)
// would otherwise never emit (bug 04). Dirty roots emit their v2 blob
// unconditionally; the wire apply is an idempotent upsert, so a false positive
// (a commit that changed nothing) costs one no-op echo.
std::set<std::string> g_dirty;
void noteDirty( BOARD_ITEM* aItem )
{
if( !aItem )
return;
if( FOOTPRINT* fp = aItem->GetParentFootprint() )
aItem = fp;
g_dirty.insert( toUtf8( aItem->m_Uuid.AsString() ) );
}
// Re-seed the diff baseline to the current model — after handing out a seed snapshot, or after
// applying a remote delta (so those items aren't re-broadcast as a spurious local diff/echo).
// Declares "current model == broadcast state", so pending dirty marks are stale too.
void rebaseline()
{
if( PCB_EDIT_FRAME* fr = pcbFrame() )
g_baseline = snapshotByUuid( *fr->GetBoard() );
g_dirty.clear();
}
// TARGETED rebaseline (bug 05): refresh baseline entries ONLY for the uuids a remote
// apply touched. A global rebaseline() here would fold a concurrently-committed local
// edit (its flush is queued BEHIND the apply on the same pending-event list) into the
// baseline and silently swallow it; with the targeted update the edit's uuids keep
// their pre-edit entries and the queued flush still emits it. Receiver-side cleanup
// the apply's Push produced likewise stays diffable — the post-apply flush broadcasts
// it, and re-application on the original sender is idempotent.
void rebaselineTouched( BOARD* aBoard, const std::vector<std::string>& aIds )
{
for( const std::string& id : aIds )
{
// Drop the stale entry — and any child entries it owned (their parent
// field carries the root uuid) — then re-snapshot whatever is live now.
g_baseline.erase( id );
for( auto it = g_baseline.begin(); it != g_baseline.end(); )
{
if( it->second.value( "parent", std::string() ) == id )
it = g_baseline.erase( it );
else
++it;
}
BOARD_ITEM* live = aBoard->ResolveItem( KIID( wxString::FromUTF8( id.c_str() ) ),
/*allowNull*/ true );
if( !live )
continue;
g_baseline[id] = itemToJson( live );
if( live->Type() == PCB_FOOTPRINT_T )
{
FOOTPRINT* f = static_cast<FOOTPRINT*>( live );
for( PCB_FIELD* fld : f->GetFields() )
{
if( fld )
g_baseline[toUtf8( fld->m_Uuid.AsString() )] = itemToJson( fld );
}
for( BOARD_ITEM* g : f->GraphicalItems() )
{
if( g->Type() == PCB_TEXT_T )
g_baseline[toUtf8( g->m_Uuid.AsString() )] = itemToJson( g );
}
}
}
}
// Diff the current (settled, post-cleanup) model against the baseline and broadcast the change.
@ -654,19 +755,44 @@ void flushDiff()
}
}
// v2 removals diverge from the legacy wire: a removed footprint CHILD whose
// parent survives lifts to the parent's re-blob (wChanged) — the new body
// carries the post-delete child set, and the receiver's parent-replace covers
// the deletion. A bare child removal would strand a dangling {item} slot in
// the Y-side parent body (bug 03). The legacy wire keeps the raw uuid list
// (its receiver skips footprint children anyway).
json wRemoved = json::array();
for( const auto& [id, j] : g_baseline )
{
if( !cur.count( id ) )
removed.push_back( id );
if( cur.count( id ) )
continue;
removed.push_back( id );
std::string parentId = j.value( "parent", std::string() );
if( !parentId.empty() && cur.count( parentId ) )
liftBlob( parentId, wChanged );
else
wRemoved.push_back( id );
}
// Dirty roots (bug 04): whatever the listener saw commit emits its blob on
// the v2 wire even when the scalar projection didn't move. wDone dedups
// against the scalar-diff emits above; deleted ids resolve null and skip.
for( const std::string& id : g_dirty )
liftBlob( id, wChanged );
g_dirty.clear();
g_baseline = std::move( cur );
if( !added.empty() || !changed.empty() || !removed.empty() )
{
emit( json{ { "added", added }, { "changed", changed }, { "removed", removed } } );
emitItems( json{ { "added", wAdded }, { "changed", wChanged }, { "removed", removed } } );
}
if( !wAdded.empty() || !wChanged.empty() || !wRemoved.empty() )
emitItems( json{ { "added", wAdded }, { "changed", wChanged }, { "removed", wRemoved } } );
}
// Coalesce all the listener callbacks of one commit (and any other edits in the same loop
@ -706,21 +832,33 @@ void scheduleFlush()
class COLLAB_LISTENER : public BOARD_LISTENER
{
public:
void OnBoardItemAdded( BOARD&, BOARD_ITEM* ) override { trigger(); }
void OnBoardItemsAdded( BOARD&, std::vector<BOARD_ITEM*>& ) override { trigger(); }
void OnBoardItemRemoved( BOARD&, BOARD_ITEM* ) override { trigger(); }
void OnBoardItemsRemoved( BOARD&, std::vector<BOARD_ITEM*>& ) override { trigger(); }
void OnBoardItemChanged( BOARD&, BOARD_ITEM* ) override { trigger(); }
void OnBoardItemsChanged( BOARD&, std::vector<BOARD_ITEM*>& ) override { trigger(); }
void OnBoardCompositeUpdate( BOARD&, std::vector<BOARD_ITEM*>&,
std::vector<BOARD_ITEM*>&,
std::vector<BOARD_ITEM*>& ) override { trigger(); }
void OnBoardItemAdded( BOARD&, BOARD_ITEM* i ) override { trigger( { i } ); }
void OnBoardItemsAdded( BOARD&, std::vector<BOARD_ITEM*>& v ) override { trigger( v ); }
void OnBoardItemRemoved( BOARD&, BOARD_ITEM* i ) override { trigger( { i } ); }
void OnBoardItemsRemoved( BOARD&, std::vector<BOARD_ITEM*>& v ) override { trigger( v ); }
void OnBoardItemChanged( BOARD&, BOARD_ITEM* i ) override { trigger( { i } ); }
void OnBoardItemsChanged( BOARD&, std::vector<BOARD_ITEM*>& v ) override { trigger( v ); }
void OnBoardCompositeUpdate( BOARD&, std::vector<BOARD_ITEM*>& a,
std::vector<BOARD_ITEM*>& r,
std::vector<BOARD_ITEM*>& c ) override
{
trigger( a );
trigger( r );
trigger( c );
}
private:
void trigger()
// Capture the touched roots at callback time (uuid strings — removed items
// may be freed before the flush runs), then coalesce into one flush.
void trigger( const std::vector<BOARD_ITEM*>& aItems )
{
if( !s_applyingRemote )
scheduleFlush();
if( s_applyingRemote )
return;
for( BOARD_ITEM* item : aItems )
noteDirty( item );
scheduleFlush();
}
};
@ -829,15 +967,30 @@ void doApplyItems( PCB_EDIT_FRAME* aFrame, const json& aWire )
BOARD_COMMIT commit( aFrame );
bool staged = false;
std::vector<std::string> touched; // root uuids this apply acts on (targeted rebaseline)
std::set<std::string> removedIds;
for( const json& rid : aWire.value( "removed", json::array() ) )
removedIds.insert( rid.get<std::string>() );
for( const std::string& rid : removedIds )
{
KIID id( wxString::FromUTF8( rid.get<std::string>().c_str() ) );
KIID id( wxString::FromUTF8( rid.c_str() ) );
touched.push_back( rid );
if( BOARD_ITEM* item = board->ResolveItem( id, /*allowNullptr*/ true ) )
{
// A child uuid in `removed` is covered by its parent's replace/remove.
if( item->GetParentFootprint() )
continue;
if( FOOTPRINT* pfp = item->GetParentFootprint() )
{
// Covered by the parent's own removal when the whole footprint
// goes. A BARE child removal must remove the child itself
// (bug 03 receiving half) — the sender now lifts these to a
// parent re-blob, but Y-rendered wires can still carry them.
if( removedIds.count( toUtf8( pfp->m_Uuid.AsString() ) ) )
continue;
}
commit.Remove( item );
staged = true;
@ -876,6 +1029,8 @@ void doApplyItems( PCB_EDIT_FRAME* aFrame, const json& aWire )
commit.Remove( existing );
}
touched.push_back( toUtf8( parsed->m_Uuid.AsString() ) );
commit.Add( parsed );
staged = true;
};
@ -888,10 +1043,12 @@ void doApplyItems( PCB_EDIT_FRAME* aFrame, const json& aWire )
if( staged )
commit.Push( wxT( "Collaborative edit (items)" ) );
// Fold the applied state into the baseline so the post-apply listener flush
// doesn't re-broadcast it as a local diff (echo).
rebaseline();
// Fold ONLY the applied uuids into the baseline (echo suppression), then flush:
// anything else that now differs — a concurrent local edit, cleanup this apply's
// Push produced — broadcasts as a normal local diff instead of being swallowed.
rebaselineTouched( board, touched );
s_applyingRemote = false;
scheduleFlush();
}
// Test/PoC move (the BOARD_COMMIT body for kicadCollabTestMoveFirst). Run inside a COROUTINE by