Skip to content

Commit 2f1933c

Browse files
committed
fix(collab-doc): unstick seed retry and persist tailed edits
Cursor review: - ensureServerSeed clears serverSeedStarted when it aborts at the streamHasContent fence, so a fail-closed Redis xLen (or a genuine peer-seed) no longer strands the room unseeded with no retry. - Persistence dirty-tracking now marks a doc edited on any post-seed update, including a peer's edit relayed via the tailer (REDIS_ORIGIN), tracked via a seededObserved flag. The last task to leave persists real edits even if it only tailed them; the seed transition itself is still never counted, so a seeded-but-unedited doc is never projected back over the file.
1 parent be2c9ad commit 2f1933c

1 file changed

Lines changed: 26 additions & 12 deletions

File tree

apps/realtime/src/handlers/file-doc.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,16 @@ interface FileDocRoom {
106106
/** The last collaborator to edit here, for persist attribution (blob metadata) only. */
107107
lastEditorUserId: string | null
108108
/**
109-
* True once a genuine USER edit has been applied here. Persistence is gated on it so a doc that was
110-
* only seeded (or only received a copilot merge) is NEVER projected back over the file: copilot writes
111-
* the file durably itself, and a seed captured from possibly-stale markdown must not clobber a
112-
* concurrent external write.
109+
* True once a genuine edit (a user edit — local OR a peer's, relayed via the tailer) has been applied
110+
* here AFTER seeding. Persistence is gated on it so a doc that was only seeded is NEVER projected back
111+
* over the file: the seed is captured from possibly-stale markdown and must not clobber a concurrent
112+
* external write, whereas real edits are not otherwise durable and must be persisted by whichever task
113+
* is last to leave — even one that only tailed the edits.
113114
*/
114115
edited: boolean
116+
/** Whether this room has observed its doc become seeded — so a post-seed update counts as an edit but
117+
* the seed transition itself does not. See the `doc.on('update')` edit-tracking below. */
118+
seededObserved: boolean
115119
/** The pending debounced persist timer, if any. */
116120
persistTimer: ReturnType<typeof setTimeout> | null
117121
}
@@ -343,7 +347,13 @@ async function ensureServerSeed(
343347
if (fileDocRooms.get(name) !== room || isDocSeeded(room.doc)) return
344348
// Fence against a peer that seeded while we held a stale lock (a rare long stall): if the stream
345349
// already has content it is seeded, so never write a SECOND seed on top — that would split-brain.
346-
if (await store.streamHasContent(name)) return
350+
if (await store.streamHasContent(name)) {
351+
// Clear the guard so a later join can retry. Safe in both cases: a genuine peer-seed arrives via
352+
// the tailer (and shouldSeed/this fence then no-op), and a fail-closed Redis `xLen` error must not
353+
// strand the room unseeded forever.
354+
room.serverSeedStarted = false
355+
return
356+
}
347357
// Build the seed (file content + seed flag, or just the flag for an empty/missing file), then
348358
// PUBLISH it to the shared stream AWAITED *before* seeding the local doc. The doc is marked seeded
349359
// only once the seed is durably shared — so if the publish fails we leave the doc unseeded and the
@@ -486,6 +496,7 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
486496
workspaceId: null,
487497
lastEditorUserId: null,
488498
edited: false,
499+
seededObserved: false,
489500
persistTimer: null,
490501
}
491502
// Register synchronously BEFORE the async catch-up so a concurrent join sees this room, not a second.
@@ -502,13 +513,16 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom {
502513
// in the stream) and SEED_ORIGIN — the seed is published EXPLICITLY and AWAITED under the seed lock
503514
// (so it lands before the lock releases), which a fire-and-forget publish here couldn't guarantee.
504515
if (origin !== REDIS_ORIGIN && origin !== SEED_ORIGIN) getFileDocStore().publish(name, update)
505-
// Persist ONLY genuine USER edits (socket origin), debounced. A seed or a bare copilot merge must
506-
// NOT project back over the file — copilot writes the file durably itself, so persisting a
507-
// seeded-but-unedited doc (built from possibly-stale markdown) could clobber that concurrent write.
508-
if (originSocketId(origin)) {
509-
room.edited = true
510-
schedulePersist(name, room)
511-
}
516+
// Edit tracking for persistence. Mark the doc dirty on any update applied AFTER it was seeded — a
517+
// local user edit (socket origin) OR a peer's edit relayed via the tailer (REDIS_ORIGIN) — so
518+
// whichever task is last to leave persists real edits, even one that only tailed them. The seed
519+
// transition itself is never counted (nor a purely-local copilot merge, already durable via
520+
// copilot's direct file write), so a seeded-but-unedited doc is never projected back over the file.
521+
const seededBefore = room.seededObserved
522+
if (isDocSeeded(room.doc)) room.seededObserved = true
523+
if (originSocketId(origin) || (seededBefore && origin === REDIS_ORIGIN)) room.edited = true
524+
// Debounce a persist for LOCAL user edits only (peers debounce their own).
525+
if (originSocketId(origin)) schedulePersist(name, room)
512526
})
513527

514528
awareness.on('update', ({ added, updated, removed }: AwarenessChange, origin: unknown) => {

0 commit comments

Comments
 (0)