pcbjam/features/yjs-bridge/0006-eeschema-apply-findings.md
Gergő Törcsvári 04b011d032
feat(eeschema): collab SCH_SHAPE emit + defensive text; defer shape/symbol add (asyncify trap); un-skip apply e2e
- itemToJson hand-maps SCH_SHAPE geometry (SHAPE_T, start/end, arc center,
  bezier ctrl pts, stroke width, fill) for emit + the changed/move path.
- makeItem: parent + default-size SCH_TEXT (mirrors createNewText).
- SCH_SHAPE/SCH_SYMBOL `added` reconstruction deferred: committing a new
  shape/symbol traps in SCH_COMMIT::Push from the programmatic apply context
  (asyncify invoke_* mis-dispatch, not the boundary 'signature mismatch' the
  dyncall shim catches). makeItem returns nullptr -> graceful 'no converter'.
- Un-skipped + extended the headless apply e2e (move via sx/sy line form,
  delete, text-add); apply works headless (old 'Push no-ops' belief was stale).
- features/yjs-bridge/0006: full findings (bugs 1+2 don't reproduce, etc).

No kicad-fork change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:56:42 +02:00

6.3 KiB

eeschema collab apply — bugfix session findings (2026-06-04)

Investigation + fixes for the four reported eeschema collab apply bugs, plus the SCH_SHAPE / SCH_SYMBOL converters. Ground truth was gathered with a headless probe spec and with the real web app driven in two tabs (?collab=1, demo.kicad_sch, 75 items incl. 26 SCH_SYMBOL) — the only place real GAL painting happens.

Key correction: apply works headless

The e2e harness's kicadOpenFile returns false, but the schematic + screens ARE built, so SCH_COMMIT::Push does take effect headless (move/delete/add all mutate the model). The earlier belief that "Push no-ops headless" predated the dyncall-shim fix. What headless cannot do is paint (the GL window is incomplete → the window.cpp(731) GetScreenPositionOfClientOrigin: no window assert). So apply logic is testable headless; rendering must be checked in the real app. The skipped apply test is now un-skipped and extended (move via the sx/sy/ex/ey line form, delete, text add, shape add).

Bug-by-bug

  1. "Adding text freezes" — does NOT reproduce through the collab bridge. Verified in the real app: a text added locally in tab A (via the onDelta emit sink, the exact path the native SCHEMATIC_LISTENER uses) propagates over BroadcastChannel and tab B applies it and stays responsive. Single-tab kicadCollabApply of a constructed SCH_TEXT also stays responsive. Conclusion: the user's freeze is the interactive text placement (createNewText → DIALOG_TEXT_PROPERTIES::ShowQuasiModal, a modal nested event loop — a known Asyncify-wasm hang), not the collab apply/emit path. Defensive change anyway: makeItem now parents the text to the schematic and applies the default text size, mirroring createNewText.

  2. "Delete doesn't apply" — does NOT reproduce. C++ apply-delete works headless (kicadCollabGetPos → "" after a removed delta) AND end-to-end in two tabs (tab B drops the item, count decrements). Likely already fixed by the converters + the CallAfter deferral now in place.

  3. Circles / SCH_SHAPE don't sync — REAL gap. Emit side added (itemToJson hand-maps SHAPE_T + start/end, arc center, bezier control points, stroke width, fill). But added reconstruction is DEFERRED — same wasm trap class as SCH_SYMBOL (below). Bisected with EM_ASM markers: makeItem + commit.Add succeed; the trap is inside SCH_COMMIT::Push's CHT_ADD path — the GAL view->Add of a new SCH_SHAPE — "memory access out of bounds". The identical view->Add succeeds when the shape is loaded from file and when an existing shape is moved (CHT_MODIFY, verified), so it's the asyncify invoke_viii mis-dispatch of a SCH_SHAPE add-path virtual, only from the programmatic CallAfter/apply context. The dyncall shim catches only the "signature mismatch" variant; this trap fires inside the wrong function → not safely retryable. (SetParent/fill-value/layer-warmup were all ruled out by experiment.)

  4. Wire moves only partially converge — basic two-tab move does converge (verified: tab A kicadCollabTestMoveFirst → tab B matches, count stable, no dup). The connected-wire re-split divergence was not deeply reproduced; it remains a known connectivity-CRDT limitation (both peers run RecalculateConnections on apply). Also found: for a SCH_LINE, the changed path's bare item->Move() is a no-op (line endpoints only move when flagged) — but the emit side always sends sx/sy/ex/ey, so real wire moves take the working SetStart/EndPoint branch.

SCH_SYMBOL — deferred (wasm parser trap)

The robust approach is the s-expr clipboard blob (0001 §3 mechanism #2): serialize a single item with SCH_IO_KICAD_SEXPR::Format(SCH_SELECTION*, …, /*clipboard*/ true) and reconstruct with LoadContent. Emit works (itemToSexpr produced a valid (symbol(lib_id …)) blob). Reconstruction traps: LoadContent → the s-expr parser hits RuntimeError: table index is out of bounds via invoke_iii (the exception trampoline) when called from the apply/CallAfter context — even though the same parser works during file load. This is the same Asyncify + indirect-call family as the original SCH_ITEM::Move trap; the dyncall shim only catches "signature mismatch", not "table index out of bounds", and a C++ catch(...) cannot catch a wasm trap (it escapes and leaves s_applyingRemote stuck true). So the blob path was removed. Symbol position/move already syncs via the generic changed path; only live symbol-add is unsupported. Next step for symbols: either (a) extend the dyncall/invoke shim to fall back to getWasmTableEntry on "table index out of bounds" too, or (b) route the parse through a tool coroutine, then re-enable the blob converter for SCH_SYMBOL (+ bus entries, tables, sheets).

Shared blocker — the asyncify invoke_* mis-dispatch from the apply context

Three deferred items (SCH_SHAPE add, SCH_SYMBOL add, and historically SCH_ITEM::Move) all hit the same root cause: a virtual call dispatched through an asyncify-instrumented invoke_* / dynCall_* trampoline mis-dispatches when invoked from the programmatic CallAfter/apply context (it works from the file-load / main-loop-boot context). The dyncall shim (scripts/common/shims/dyncall-binding.js.tmpl) fixed the Move case because that one trapped with "indirect call signature mismatch" at the call boundary (safe to fall back to getWasmTableEntry). The shape (invoke_viii → "memory access out of bounds") and symbol (invoke_iii → "table index is out of bounds") cases trap inside the mis-dispatched function, so a blind retry is unsafe. Next step for both: either extend the shim to catch these at the boundary, or route apply through the TOOL_MANAGER so it runs inside a tool coroutine (the asyncify root real edits use) — then re-enable the SCH_SHAPE hand converter and the SCH_SYMBOL s-expr-blob converter.

Files touched

  • wasm/bindings/eeschema_embind.cpp — SCH_SHAPE emit fields (itemToJson), defensive SCH_TEXT construction (SetParent + default size). SCH_SHAPE/SCH_SYMBOL added reconstruction documented + deferred. (No kicad-fork change.)
  • tests/kicad/eeschema-collab.spec.ts — un-skipped + extended the single-page apply test (move / delete / text-add).