You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(files): close realEdited data-loss race + elect a settle writer
Independent audit surfaced two real gaps:
- file-doc-store: realEdited was latched AFTER appendUpdate's awaits, but the
edit already sits in room.doc synchronously. A concurrent agent-frame
compaction could read realEdited=false, snapshot that real content, and stamp
it a no-persist agent frame — a lost edit. Latch it synchronously (same tick
as the doc mutation) before any await. Deterministic falsifiable test added.
- rich-markdown-editor: at settle every tab applied the final body, and a
non-leader's local microtask runs before the leader's final propagates, so
both insert the tail (Yjs keeps both) -> duplicated tail. Elect a single
settle writer (reliable — awareness is long converged by settle), reading
leadership before clearing the announcement. Corrects the overclaiming
idempotency comment and the handoff pick-up comment.
Adds a y-tiptap internals upgrade-guardrail test.
Copy file name to clipboardExpand all lines: apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown.test.ts
Copy file name to clipboardExpand all lines: apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx
+33-16Lines changed: 33 additions & 16 deletions
Original file line number
Diff line number
Diff line change
@@ -908,7 +908,10 @@ export function LoadedRichMarkdownEditor({
908
908
// Single-writer election: only the leader (min clientID among clients announcing they apply this
909
909
// stream) writes it into the shared doc, so multiple tabs/windows watching the same live copilot
910
910
// stream don't each insert it and duplicate content. A non-leader renders the leader's ops via
911
-
// Yjs; re-checked each frame, so it converges to one writer the moment awareness propagates.
911
+
// Yjs; re-checked each frame, so a co-leader stops the moment awareness propagates. (The pick-up
912
+
// direction — a successor beginning to write after the leader tab closes — waits for the next
913
+
// content frame to run a tick; a stream that already delivered its last frame is covered by
914
+
// settle and the durable write, so at worst a brief end-of-stream display lag, never a loss.)
912
915
// Bounded residual (accepted): if two tabs start the SAME stream within the awareness-propagation
913
916
// window they briefly both lead and duplicate a frame or two — a rare, transient, never-persisted
914
917
// glitch (SYNC_NO_PERSIST keeps it out of storage; the durable edit_content write reconciles the
@@ -962,27 +965,41 @@ export function LoadedRichMarkdownEditor({
962
965
cancelAnimationFrame(streamRafRef.current)
963
966
streamRafRef.current=null
964
967
}
965
-
// Settle: apply the FINAL body so the Y.Doc exactly equals the streamed result. The mid-stream
966
-
// leader REUSES its up-to-date shadow (just catching a throttled last frame); a client that never
967
-
// applied mid-stream (a non-leader, a held `update`, or a pre-seed stream) opens a FRESH shadow
968
-
// seeded from the CURRENT doc. Reconciling current→final is idempotent — a client that settles after
969
-
// another already wrote the final reconciles to a noop — so there is NO settle-time election and no
970
-
// base-shadow duplication, and a lone client (incl. an `update`) still applies rather than waiting on
971
-
// the durable merge. That durable `edit_content` write then lands as a noop diff too.
968
+
// Settle: apply the FINAL body so the Y.Doc exactly equals the streamed result — but ONLY the
969
+
// elected writer applies it (the same min-clientID election the streaming tick uses). Without this,
970
+
// N tabs watching one run each open a fresh shadow and reconcile current→final; a non-leader's local
971
+
// settle microtask runs BEFORE the leader's final propagates (a server round-trip), so both insert
972
+
// the same tail and Yjs keeps both (it does not dedupe identical text from two clients) → a
973
+
// duplicated tail. The election is reliable here (unlike the bounded startup window): the stream ran
974
+
// for seconds, so awareness is long converged. Each tab reads leadership BEFORE clearing its own
975
+
// announcement — a remote clear is a network round-trip, always slower than these local microtasks,
976
+
// so every tab sees the same announcer set and agrees on one leader. The leader reuses its
977
+
// up-to-date shadow (catching a throttled last frame) or, if it never applied mid-stream (a held
978
+
// `update`, or a pre-seed stream), opens a FRESH shadow from the current doc; a non-leader applies
979
+
// nothing (the leader's final broadcasts to it) and frees any shadow it still held. The durable
980
+
// `edit_content` write then lands as a noop diff for everyone.
0 commit comments