Skip to content

Sync: a failed diff can no longer wedge the whole session - #50

Merged
nyblnet merged 1 commit into
mainfrom
claude/practical-carson-746f0e
Jul 25, 2026
Merged

Sync: a failed diff can no longer wedge the whole session#50
nyblnet merged 1 commit into
mainfrom
claude/practical-carson-746f0e

Conversation

@nyblnet

@nyblnet nyblnet commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Context

A background report flagged RangeError: Maximum call stack size exceeded in crdt.ts on a single large text edit (~300KB), with the note: "Consider also whether session.flush() should be defensive so one bad diff can't wedge the differ."

The crdt.ts half was already fixed by 7e73247 (splice-without-spread in applyTxtToState) — the report was made against a pre-fix checkout. Verified empirically rather than taken on trust, same repro against both versions:

100KB 200KB 300KB 500KB
7e73247^ OK RangeError RangeError RangeError
main OK OK OK OK

The flush() half was not addressed, and is the more interesting defect. That's what this PR fixes.

The bug

flush() advances shadow only after a successful diff, so an uncaught throw from state.diff() poisons every later flush: the same delta is re-diffed, throws again, and nothing syncs for the rest of the session — on any slide, not just the element that broke. Nothing surfaces; peers just stop receiving.

That amplifier is what turned the 200KB stack overflow into total silent collab failure rather than one broken element. The trigger is gone; the amplifier stayed armed for the next differ bug.

Recovery

  • Advance the shadow past the poison delta. Re-diffing it would just re-throw, and a wedged differ is strictly worse than one lost delta.
  • Ship a state snapshot. diff() mints ops and advances registers as it goes, so a mid-diff throw leaves changes stamped but never broadcast — they live only in our doc values. That is exactly the offline-fork case, and a snapshot is how those travel (hello() does the same). forkPending re-sends on reconnect.

Deliberately not a SyncNotice: those codes name conditions a user can act on (too large, room full). A differ crash is a bug — console is the right surface, and a user-facing string would mean new copy in all 7 catalogs for a should-never-happen path.

Verification

In the browser against the real session (dev server; session temporarily exposed, then reverted):

  • the reported 300KB repro through session.flush() — no throw, 6ms
  • forced differ failure — flush() does not propagate, snap broadcast, forkPending set
  • the repair snapshot carries the edit the failed diff never emitted
  • after recovery — 0 ops from re-diffing the poison delta (shadow advanced), 1 op from a subsequent healthy edit: not wedged

Convergence rig ALL PASS (45,362 checks; SEEDS=300 → 46,402). tsc -b clean.

One file, 48 insertions. crdt.ts untouched.

session.flush() only advances `shadow` AFTER a successful diff, so an
uncaught throw from state.diff() poisons every later flush: the same delta
is re-diffed, throws again, and nothing syncs for the rest of the session —
on any slide, not just the element that broke. No error surfaces; peers
just stop receiving.

That amplifier is what turned the ~200KB-text stack overflow (fixed in
7e73247, splice-without-spread in applyTxtToState) into TOTAL, silent
collab failure rather than one broken element. The trigger is gone; the
amplifier stayed armed for the next differ bug. This disarms it.

RECOVERY, best-effort:
  - Advance the shadow past the poison delta. Re-diffing it would just
    re-throw, and a wedged differ is strictly worse than one lost delta.
  - Ship a state snapshot. diff() mints ops and advances its registers as
    it goes, so a mid-diff throw leaves changes stamped but never
    broadcast — they now live only in our doc VALUES. That is exactly the
    offline-fork case, and a snapshot is how those travel (see hello()).
    forkPending re-sends it on the next reconnect too.

Deliberately NOT a SyncNotice: those codes name conditions a user can act
on (too large, room full). A differ crash is a bug — console is the right
surface, and a user-facing string would mean new copy in all 7 catalogs
for a should-never-happen path.

VERIFIED in the browser against the real session (dev server, session
temporarily exposed then reverted):
  - the reported 300KB repro through session.flush(): no throw, 6ms
  - forced differ failure: flush() does not propagate, `snap` broadcast,
    forkPending set
  - the repair snapshot CARRIES the edit the failed diff never emitted
  - after recovery: 0 ops from re-diffing the poison delta (shadow
    advanced), 1 op from a subsequent healthy edit — not wedged
Convergence rig ALL PASS (45,362 checks; SEEDS=300 → 46,402). tsc clean.
@nyblnet
nyblnet merged commit 57bf7d5 into main Jul 25, 2026
1 check passed
@nyblnet
nyblnet deleted the claude/practical-carson-746f0e branch July 25, 2026 09:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant