Skip to content

Commit 41b0345

Browse files
authored
fix(cli): prevent collab reopen from overwriting existing ydoc with blank document (SD-2138) (#2296)
* fix(cli): prevent collab reopen from overwriting existing ydoc with blank document SD-2138: When reopening a pre-existing ydoc in collaboration mode (no backing .docx), the bootstrap flow could incorrectly detect the room as empty and seed a blank document, destroying existing content. This happened because some providers fire "synced" before Yjs updates are fully applied to local shared types. Add two-layer defense: 1. Post-sync content settling (200ms window) to let XmlFragment populate 2. Post-claim re-check after the 1500ms settling period — if room is now populated, join instead of destructively seeding * refactor(cli): deduplicate room-state check in waitForContentSettling Replace manual fragment + meta map iteration with a call to detectRoomState(), keeping the heuristic in one place. * fix(cli): clear stale pending bootstrap marker on join-after-claim
1 parent 4d3bebd commit 41b0345

4 files changed

Lines changed: 306 additions & 186 deletions

File tree

apps/cli/src/lib/__tests__/bootstrap.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { Doc as YDoc, XmlElement } from 'yjs';
33
import {
44
DEFAULT_BOOTSTRAP_SETTLING_MS,
55
DEFAULT_BOOTSTRAP_JITTER_MS,
6+
waitForContentSettling,
67
detectRoomState,
78
resolveBootstrapDecision,
89
writeBootstrapMarker,
10+
clearBootstrapMarker,
911
claimBootstrap,
1012
detectBootstrapRace,
1113
type BootstrapMarker,
@@ -109,6 +111,15 @@ describe('writeBootstrapMarker', () => {
109111
expect(typeof marker.seededAt).toBe('string');
110112
});
111113

114+
test('clearBootstrapMarker removes the marker from the meta map', () => {
115+
const ydoc = new YDoc();
116+
writeBootstrapMarker(ydoc, 'doc');
117+
expect(ydoc.getMap('meta').get('bootstrap')).toBeDefined();
118+
119+
clearBootstrapMarker(ydoc);
120+
expect(ydoc.getMap('meta').get('bootstrap')).toBeUndefined();
121+
});
122+
112123
test('finalized marker makes detectRoomState return populated', () => {
113124
const ydoc = new YDoc();
114125
writeBootstrapMarker(ydoc, 'doc');
@@ -233,6 +244,46 @@ describe('claimBootstrap', () => {
233244
expect(decision).toEqual({ action: 'seed', source: 'doc' });
234245
});
235246

247+
test('SD-2138 regression: stale pending marker after join-after-claim causes false-empty on reconnect', async () => {
248+
// Simulates the exact scenario that causes data loss:
249+
// 1. Client wins claim (pending marker written)
250+
// 2. Content arrives during settling → client joins instead of seeding
251+
// 3. If pending marker is NOT cleared, a future reconnect sees:
252+
// - empty fragment (slow sync) + pending-only marker → 'empty' → destructive re-seed
253+
// 4. Clearing the marker ensures the room doesn't have a misleading pending signal
254+
const ydoc = new YDoc();
255+
256+
// Step 1: Win the claim — writes pending marker
257+
const claim = await claimBootstrap(ydoc, 0, 0);
258+
expect(claim.granted).toBe(true);
259+
260+
const marker = ydoc.getMap('meta').get('bootstrap') as BootstrapMarker;
261+
expect(marker.source).toBe('pending');
262+
263+
// Step 2: Content arrived during settling (simulate)
264+
const fragment = ydoc.getXmlFragment('supereditor');
265+
fragment.insert(0, [new XmlElement('p')]);
266+
expect(detectRoomState(ydoc)).toBe('populated');
267+
268+
// Step 3: Clear the pending marker (this is the fix)
269+
clearBootstrapMarker(ydoc);
270+
271+
// Step 4: Simulate future reconnect — new ydoc where only meta synced,
272+
// fragment hasn't arrived yet (slow-sync scenario)
273+
const reconnectYdoc = new YDoc();
274+
// No fragment content, no meta — room is clean after marker was cleared
275+
expect(detectRoomState(reconnectYdoc)).toBe('empty');
276+
277+
// Without the fix, the pending marker would persist and detectRoomState
278+
// would still return 'empty' — but the danger is that it LOOKS like a
279+
// fresh room rather than a room with a stale claim. With the marker
280+
// cleared, at least there's no misleading pending signal.
281+
282+
// The critical assertion: after clearing, the original ydoc's meta map
283+
// has no bootstrap key that could sync to new clients as a stale pending marker
284+
expect(ydoc.getMap('meta').get('bootstrap')).toBeUndefined();
285+
});
286+
236287
test('concurrent claimers: second claimer re-detects and joins after first seeds', async () => {
237288
// Simulates the full claim -> re-detect -> join path for a race loser
238289
const ydoc = new YDoc();
@@ -354,3 +405,82 @@ describe('DEFAULT_BOOTSTRAP_JITTER_MS', () => {
354405
expect(DEFAULT_BOOTSTRAP_JITTER_MS).toBeGreaterThan(0);
355406
});
356407
});
408+
409+
// ---------------------------------------------------------------------------
410+
// waitForContentSettling (SD-2138)
411+
// ---------------------------------------------------------------------------
412+
413+
describe('waitForContentSettling', () => {
414+
test('resolves immediately when fragment already has content', async () => {
415+
const ydoc = new YDoc();
416+
const fragment = ydoc.getXmlFragment('supereditor');
417+
fragment.insert(0, [new XmlElement('p')]);
418+
419+
const before = Date.now();
420+
await waitForContentSettling(ydoc, 500);
421+
expect(Date.now() - before).toBeLessThan(50);
422+
});
423+
424+
test('resolves immediately when meta map has finalized bootstrap marker', async () => {
425+
const ydoc = new YDoc();
426+
ydoc.getMap('meta').set('bootstrap', { version: 1, source: 'doc' });
427+
428+
const before = Date.now();
429+
await waitForContentSettling(ydoc, 500);
430+
expect(Date.now() - before).toBeLessThan(50);
431+
});
432+
433+
test('resolves immediately when meta map has non-bootstrap entries', async () => {
434+
const ydoc = new YDoc();
435+
ydoc.getMap('meta').set('docx', 'some-content');
436+
437+
const before = Date.now();
438+
await waitForContentSettling(ydoc, 500);
439+
expect(Date.now() - before).toBeLessThan(50);
440+
});
441+
442+
test('waits and resolves when fragment is populated during settling', async () => {
443+
const ydoc = new YDoc();
444+
const fragment = ydoc.getXmlFragment('supereditor');
445+
446+
// Populate fragment after 20ms
447+
setTimeout(() => {
448+
fragment.insert(0, [new XmlElement('p')]);
449+
}, 20);
450+
451+
const before = Date.now();
452+
await waitForContentSettling(ydoc, 500);
453+
const elapsed = Date.now() - before;
454+
455+
// Should resolve quickly after content arrives, not wait full 500ms
456+
expect(elapsed).toBeGreaterThanOrEqual(15);
457+
expect(elapsed).toBeLessThan(200);
458+
});
459+
460+
test('times out when no content arrives', async () => {
461+
const ydoc = new YDoc();
462+
463+
const before = Date.now();
464+
await waitForContentSettling(ydoc, 50);
465+
const elapsed = Date.now() - before;
466+
467+
expect(elapsed).toBeGreaterThanOrEqual(40);
468+
});
469+
470+
test('does not treat pending bootstrap marker as content', async () => {
471+
const ydoc = new YDoc();
472+
ydoc.getMap('meta').set('bootstrap', {
473+
version: 1,
474+
clientId: 999,
475+
seededAt: new Date().toISOString(),
476+
source: 'pending',
477+
});
478+
479+
const before = Date.now();
480+
await waitForContentSettling(ydoc, 50);
481+
const elapsed = Date.now() - before;
482+
483+
// Should wait full timeout since pending marker is not content
484+
expect(elapsed).toBeGreaterThanOrEqual(40);
485+
});
486+
});

apps/cli/src/lib/bootstrap.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,45 @@ export type ClaimResult = { granted: true } | { granted: false; competitor: Obse
7272
*/
7373
export type RaceDetectionResult = { raceSuspected: false } | { raceSuspected: true; competitor: ObservedCompetitor };
7474

75+
// ---------------------------------------------------------------------------
76+
// Post-sync content settling
77+
// ---------------------------------------------------------------------------
78+
79+
/**
80+
* Maximum time (ms) to wait for the XmlFragment to be populated after the
81+
* provider reports "synced". Some providers fire the synced event before Yjs
82+
* updates are fully applied to local shared types. This brief window avoids
83+
* false-empty room detection that leads to destructive re-seeding (SD-2138).
84+
*/
85+
const CONTENT_SETTLING_MAX_MS = 200;
86+
87+
/**
88+
* After the collaboration provider reports "synced", wait briefly for the
89+
* XmlFragment to be populated. Returns immediately if content is already
90+
* present, or after CONTENT_SETTLING_MAX_MS if nothing arrives.
91+
*/
92+
export function waitForContentSettling(ydoc: YDoc, maxWaitMs: number = CONTENT_SETTLING_MAX_MS): Promise<void> {
93+
if (detectRoomState(ydoc) === 'populated') return Promise.resolve();
94+
95+
const fragment = ydoc.getXmlFragment('supereditor');
96+
97+
return new Promise<void>((resolve) => {
98+
const timeout = setTimeout(() => {
99+
fragment.unobserve(observer);
100+
resolve();
101+
}, maxWaitMs);
102+
103+
const observer = () => {
104+
if (fragment.length > 0) {
105+
clearTimeout(timeout);
106+
fragment.unobserve(observer);
107+
resolve();
108+
}
109+
};
110+
fragment.observe(observer);
111+
});
112+
}
113+
75114
// ---------------------------------------------------------------------------
76115
// Room state detection
77116
// ---------------------------------------------------------------------------
@@ -121,6 +160,16 @@ export function resolveBootstrapDecision(
121160
// Bootstrap marker
122161
// ---------------------------------------------------------------------------
123162

163+
/**
164+
* Remove the bootstrap marker from the meta map. Used when a claim winner
165+
* discovers the room is already populated and joins instead of seeding —
166+
* leaving a stale pending marker would cause future reconnects to
167+
* misdetect the room as empty (SD-2138).
168+
*/
169+
export function clearBootstrapMarker(ydoc: YDoc): void {
170+
ydoc.getMap('meta').delete('bootstrap');
171+
}
172+
124173
export function writeBootstrapMarker(ydoc: YDoc, source: string): void {
125174
const metaMap = ydoc.getMap('meta');
126175
const marker: BootstrapMarker = {

apps/cli/src/lib/document.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ import type { CollaborationProfile } from './collaboration';
1111
import { createCollaborationRuntime } from './collaboration';
1212
import {
1313
DEFAULT_BOOTSTRAP_SETTLING_MS,
14+
waitForContentSettling,
1415
detectRoomState,
1516
resolveBootstrapDecision,
1617
claimBootstrap,
18+
clearBootstrapMarker,
1719
writeBootstrapMarker,
1820
detectBootstrapRace,
1921
type RoomState,
@@ -251,6 +253,11 @@ export async function openCollaborativeDocument(
251253
try {
252254
await runtime.waitForSync();
253255

256+
// SD-2138: Some providers fire "synced" before Yjs updates are fully
257+
// applied to local shared types. Give a brief window for the XmlFragment
258+
// to be populated from incoming server state before checking room state.
259+
await waitForContentSettling(runtime.ydoc);
260+
254261
const onMissing = profile.onMissing ?? 'seedFromDoc';
255262
let finalRoomState = detectRoomState(runtime.ydoc);
256263
let decision = resolveBootstrapDecision(finalRoomState, onMissing, doc != null);
@@ -264,6 +271,18 @@ export async function openCollaborativeDocument(
264271
// here would produce a dual-seed race.
265272
finalRoomState = detectRoomState(runtime.ydoc);
266273
decision = { action: 'join' };
274+
} else {
275+
// SD-2138: Re-check room state after the claim settling period.
276+
// Some providers fire "synced" before Yjs updates are fully applied,
277+
// so content from the server may have arrived during the settling
278+
// wait. If the room is now populated, join instead of seeding —
279+
// seeding here would destructively overwrite existing content.
280+
const postClaimState = detectRoomState(runtime.ydoc);
281+
if (postClaimState === 'populated') {
282+
clearBootstrapMarker(runtime.ydoc);
283+
finalRoomState = postClaimState;
284+
decision = { action: 'join' };
285+
}
267286
}
268287
}
269288

0 commit comments

Comments
 (0)