pcbjam/docs/features/ysync-review/19-undo-option1-feasibility.md
Gergő Törcsvári cd68114cdc
docs(ysync-review): 19 — miss-09 option 1 (local-ops-only undo) feasibility research
Verdict: ~3-5 days. pcbnew undo already UUID-guards stale pointers; core
work = porting that guard to eeschema, splitting connectivity out of the
SCH SKIP_UNDO gate, plugging the SKIP_UNDO removed-item leak, silencing
the incomplete-undo modal. Cross-linked from 09 and the overview index.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ejJEvS7ogef2o9gVTXjmp
2026-07-07 21:09:24 +02:00

7.3 KiB
Raw Blame History

Research 19 — Feasibility of miss-09 option 1: exclude remote applies from undo

Status: research (2026-07-07), not implemented Verdict: moderately hard but tractable — ~35 days. The pointer-invalidation risk 09 flagged is already solved on the pcbnew side; the core work is porting that resilience to eeschema, plus two small fork fixes to make SKIP_UNDO safe for our use.

Question

Miss 09's option 1 is "local-ops-only undo": remote applies stop landing on the receiving editor's undo stack, so Ctrl+Z can never revert a peer's work. Both commit classes support a SKIP_UNDO push flag — but 09 warned that KiCad's undo machinery assumes the stack mirrors model history, and that PICKED_ITEMS_LIST holds raw pointers which remote applies could strand. How bad is that really?

What makes it easier than 09 feared

The flag exists on both sides and skips only what we want (almost — see fix 2). SKIP_UNDO is honored by both commit classes (pcbnew/board_commit.h:40, eeschema/sch_commit.h:40; base signature include/commit.h:132). Under it the commit still does everything remote applies go through a real commit for: board/screen add/remove, view->Add/Remove/Update, connectivity dirty-marking and ratsnest (board_commit.cpp:463-467,516), teardrops, listener callbacks (OnItemsCompositeUpdate / OnSchItems*), and the model-change events. Only the undo-picker creation and the final SaveCopyInUndoList are gated (board_commit.cpp:325,356,455,606; sch_commit.cpp:266,295,363,448).

Exactly four Push sites to change, each already remote-tagged. All remote data funnels through doApply / doApplyItems per editor (wasm/bindings/eeschema_embind.cpp:1093,1218; wasm/bindings/pcbnew_embind.cpp:965,1061), every one bracketed by the s_applyingRemote guard — the exact local/remote signal, available at the Push call. Deletes, adopt, and the parked-dirty catch-up all funnel through doApplyItems; there is no fifth production path.

Change detection is unaffected. The local-edit→yjs emit path is the listener-trigger() + post-settle flushDiff, suppressed during remote applies by s_applyingRemote (eeschema_embind.cpp:652, pcbnew_embind.cpp:871) — entirely independent of whether the commit is undoable. Skipping undo disturbs neither the suppression during applies nor the emit on genuine local edits (including the emit of a local undo itself, which is what makes undo propagate today).

Pcbnew undo already survives items vanishing behind its back. Our fork's PutDataInPreviousState checks every picker against BOARD::ResolveItem( uuid ) and drops entries whose items no longer exist (pcbnew/undo_redo.cpp:428-437), and even re-resolves stale CHANGED pointers by UUID before swapping the clone image back in (:504, comment cites ExchangeFootprint-style swaps). Group parent/member pointers are rebuilt from KIIDs cached in the picker itself (undo_redo_container.h:114-115, rebuilt at undo_redo.cpp:633-658). So on the PCB side, a remote apply deleting or replacing an item referenced by a local undo entry degrades gracefully (entry dropped) instead of crashing.

The actual work

  1. Port the UUID guard to eeschema — the core task. eeschema/schematic_undo_redo.cpp::PutDataInPreviousState (:246) has no existence or re-resolution check: it dereferences the stored raw EDA_ITEM* directly (:270,399,431); only the group-relink post-pass uses Schematic().ResolveItem (:485,504). Since doApplyItems upserts full item subtrees as remove-by-uuid + re-add, a remote change (not just a delete) to an item referenced by a local undo entry means a dangling pointer → crash on Ctrl+Z. Fix: mirror pcbnew's pattern — existence-check each picker via SCHEMATIC::ResolveItem (eeschema/schematic.h:129), drop missing entries, re-resolve CHANGED pointers before SwapItemData. ~50100 lines, follows an in-tree precedent, plausibly upstreamable. This is the bulk of the effort.

  2. Un-bundle connectivity from SKIP_UNDO on the SCH side. Unlike pcbnew, SCH_COMMIT::pushSchEdit gates RecalculateConnections inside the same if( !( aCommitFlags & SKIP_UNDO ) ) block as SaveCopyInUndoList (sch_commit.cpp:448-463). Remote applies need that recalc — it is the reason they go through real commits. Small fork change: split the gate (or run the recalc from the binding after Push).

  3. Plug the removed-item leak under SKIP_UNDO. In the REMOVE branch the live item is flagged UR_TRANSIENT (board_commit.cpp:447) but with SKIP_UNDO no picker takes ownership and the clone is deleted (:490), so the removed item leaks; same shape on the SCH side (sch_commit.cpp:409-410). Existing SKIP_UNDO callers (DRC-marker flows, drc_tool.cpp:208 etc.) never remove user items, so upstream never hit this. Fix: delete removed items in the commit when the flag is set (or in the binding, which knows the removed set).

  4. Silence the "incomplete undo" modal. When pcbnew drops a stranded entry it pops a blocking wxMessageBox( "Incomplete undo/redo operation: some items not found" ) (pcbnew/undo_redo.cpp:628). In a collab session that is routine, not exceptional — downgrade to a log line (or toast) in the fork, and use the same quiet path in the eeschema port.

Plus wasm-side flag changes at the four Push sites (~one line each) and multi-client e2e coverage for undo-after-remote scenarios (delete-under-undo, change-under-undo, adopt-while-history-nonempty), which given the two-tab harness is roughly another day.

Residual semantic gaps (accepted, not blockers)

  • Full-clone restore clobbers concurrent field edits. Undoing a local move of an item restores the whole old item image (CHANGED swaps the full clone), so a peer's concurrent edit to another property of the same item is reverted too. Last-writer-wins collab undo — same as Figma; converges; not an operational rebase. Same granularity trade-off as 14.
  • Duplicate-KIID re-add edge. Local delete parks the item on the local undo stack (owned, DELETED pickers skip the existence check — undo_redo.cpp:423); if a peer re-adds the same uuid and the local user then undoes the delete, the stored item is re-added alongside the peer's copy with the same KIID (:569-572). Rare; the differ keys by uuid so it likely reconciles to one, but needs a test.
  • Pre-existing local entries at adopt time mostly reference replaced items and get dropped by the UUID guard on first undo — acceptable, and strictly better than the current adopt undo bomb.

Effort summary

Piece Where Size
SKIP_UNDO at the 4 remote Push sites wasm bindings trivial
SCH connectivity/SKIP_UNDO split kicad fork (sch_commit) small
Removed-item leak fix kicad fork (both commits) small
eeschema UUID guard port kicad fork (schematic_undo_redo) 12 days, core
Modal → quiet drop kicad fork (undo_redo) trivial
Two-client undo e2e tests/ ~1 day

All fork touches are small and upstream-plausible, consistent with keeping the fork close to upstream. Miss 09's option 3 (clear undo stack on adopt) becomes largely moot: with SKIP_UNDO, adopt creates no undo entry at all, and stranded pre-adopt entries are handled by the UUID guard.