Skip to content

Commit 6b38a85

Browse files
committed
chore: replace blind sleep-and-check bootstrap with reactive observe-based claim detection
1 parent 489ab0c commit 6b38a85

4 files changed

Lines changed: 396 additions & 26 deletions

File tree

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

Lines changed: 157 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@ import { describe, expect, test } from 'bun:test';
22
import { Doc as YDoc, XmlElement } from 'yjs';
33
import {
44
DEFAULT_BOOTSTRAP_SETTLING_MS,
5+
DEFAULT_BOOTSTRAP_JITTER_MS,
56
detectRoomState,
67
resolveBootstrapDecision,
78
writeBootstrapMarker,
89
claimBootstrap,
10+
detectBootstrapRace,
911
type BootstrapMarker,
1012
} from '../bootstrap';
1113

14+
// ---------------------------------------------------------------------------
15+
// detectRoomState
16+
// ---------------------------------------------------------------------------
17+
1218
describe('detectRoomState', () => {
1319
test('returns "empty" for a fresh ydoc', () => {
1420
const ydoc = new YDoc();
@@ -54,6 +60,10 @@ describe('detectRoomState', () => {
5460
});
5561
});
5662

63+
// ---------------------------------------------------------------------------
64+
// resolveBootstrapDecision
65+
// ---------------------------------------------------------------------------
66+
5767
describe('resolveBootstrapDecision', () => {
5868
test('populated room always joins', () => {
5969
expect(resolveBootstrapDecision('populated', 'seedFromDoc', true)).toEqual({ action: 'join' });
@@ -62,26 +72,30 @@ describe('resolveBootstrapDecision', () => {
6272
expect(resolveBootstrapDecision('populated', 'error', true)).toEqual({ action: 'join' });
6373
});
6474

65-
test('empty + seedFromDoc + hasDoc seed from doc', () => {
75+
test('empty + seedFromDoc + hasDoc -> seed from doc', () => {
6676
expect(resolveBootstrapDecision('empty', 'seedFromDoc', true)).toEqual({ action: 'seed', source: 'doc' });
6777
});
6878

69-
test('empty + seedFromDoc + no doc seed from blank', () => {
79+
test('empty + seedFromDoc + no doc -> seed from blank', () => {
7080
expect(resolveBootstrapDecision('empty', 'seedFromDoc', false)).toEqual({ action: 'seed', source: 'blank' });
7181
});
7282

73-
test('empty + blank seed from blank regardless of hasDoc', () => {
83+
test('empty + blank -> seed from blank regardless of hasDoc', () => {
7484
expect(resolveBootstrapDecision('empty', 'blank', true)).toEqual({ action: 'seed', source: 'blank' });
7585
expect(resolveBootstrapDecision('empty', 'blank', false)).toEqual({ action: 'seed', source: 'blank' });
7686
});
7787

78-
test('empty + error error', () => {
88+
test('empty + error -> error', () => {
7989
const result = resolveBootstrapDecision('empty', 'error', true);
8090
expect(result.action).toBe('error');
8191
expect((result as { reason: string }).reason).toContain('onMissing');
8292
});
8393
});
8494

95+
// ---------------------------------------------------------------------------
96+
// writeBootstrapMarker
97+
// ---------------------------------------------------------------------------
98+
8599
describe('writeBootstrapMarker', () => {
86100
test('writes marker to meta map with correct shape', () => {
87101
const ydoc = new YDoc();
@@ -102,30 +116,34 @@ describe('writeBootstrapMarker', () => {
102116
});
103117
});
104118

119+
// ---------------------------------------------------------------------------
120+
// claimBootstrap
121+
// ---------------------------------------------------------------------------
122+
105123
describe('claimBootstrap', () => {
106-
test('returns true when this client owns the marker', async () => {
124+
test('returns granted when this client owns the marker', async () => {
107125
const ydoc = new YDoc();
108-
const result = await claimBootstrap(ydoc, 0);
109-
expect(result).toBe(true);
126+
const result = await claimBootstrap(ydoc, 0, 0);
127+
expect(result.granted).toBe(true);
110128

111129
const marker = ydoc.getMap('meta').get('bootstrap') as BootstrapMarker;
112130
expect(marker.clientId).toBe(ydoc.clientID);
113131
});
114132

115133
test('claim marker has source "pending"', async () => {
116134
const ydoc = new YDoc();
117-
await claimBootstrap(ydoc, 0);
135+
await claimBootstrap(ydoc, 0, 0);
118136

119137
const marker = ydoc.getMap('meta').get('bootstrap') as BootstrapMarker;
120138
expect(marker.source).toBe('pending');
121139
});
122140

123-
test('returns false when another client overwrites the marker during settling', async () => {
141+
test('returns denied with competitor info when another client overwrites during settling', async () => {
124142
const ydoc = new YDoc();
125143
const otherClientId = ydoc.clientID + 1;
126144
const metaMap = ydoc.getMap('meta');
127145

128-
const promise = claimBootstrap(ydoc, 10);
146+
const promise = claimBootstrap(ydoc, 20, 0);
129147

130148
// Overwrite with the other client's marker during the settling window
131149
setTimeout(() => {
@@ -138,11 +156,67 @@ describe('claimBootstrap', () => {
138156
}, 2);
139157

140158
const result = await promise;
141-
expect(result).toBe(false);
159+
expect(result.granted).toBe(false);
160+
if (!result.granted) {
161+
expect(result.competitor.observedOtherClientId).toBe(otherClientId);
162+
expect(result.competitor.observedSource).toBe('pending');
163+
expect(typeof result.competitor.observedAt).toBe('string');
164+
}
165+
});
166+
167+
test('observe detects late-arriving marker after sleep ends', async () => {
168+
// Simulates network latency: the competing marker arrives just before
169+
// the final read, but the observe handler catches it reactively.
170+
const ydoc = new YDoc();
171+
const otherClientId = ydoc.clientID + 1;
172+
const metaMap = ydoc.getMap('meta');
173+
174+
const promise = claimBootstrap(ydoc, 5, 0);
175+
176+
// Overwrite at ~4ms — very close to when the sleep ends
177+
setTimeout(() => {
178+
metaMap.set('bootstrap', {
179+
version: 1,
180+
clientId: otherClientId,
181+
seededAt: new Date().toISOString(),
182+
source: 'pending',
183+
});
184+
}, 4);
185+
186+
const result = await promise;
187+
expect(result.granted).toBe(false);
188+
});
189+
190+
test('returns denied gracefully when marker is removed during settling', async () => {
191+
const ydoc = new YDoc();
192+
const metaMap = ydoc.getMap('meta');
193+
194+
const promise = claimBootstrap(ydoc, 20, 0);
195+
196+
// Another process deletes the bootstrap key during settling
197+
setTimeout(() => {
198+
metaMap.delete('bootstrap');
199+
}, 2);
200+
201+
const result = await promise;
202+
expect(result.granted).toBe(false);
203+
if (!result.granted) {
204+
expect(result.competitor.observedOtherClientId).toBe(0);
205+
expect(result.competitor.observedSource).toBe('unknown');
206+
}
207+
});
208+
209+
test('jitter=0 disables random delay', async () => {
210+
const ydoc = new YDoc();
211+
const before = Date.now();
212+
await claimBootstrap(ydoc, 0, 0);
213+
const elapsed = Date.now() - before;
214+
// With jitter=0 and settling=0, should complete almost instantly
215+
expect(elapsed).toBeLessThan(50);
142216
});
143217

144218
test('stale pending marker does not block subsequent bootstrap detection', async () => {
145-
// Simulates Finding 1: claimer crashes after writing pending marker
219+
// Simulates: claimer crashes after writing pending marker
146220
const ydoc = new YDoc();
147221
ydoc.getMap('meta').set('bootstrap', {
148222
version: 1,
@@ -160,7 +234,7 @@ describe('claimBootstrap', () => {
160234
});
161235

162236
test('concurrent claimers: second claimer re-detects and joins after first seeds', async () => {
163-
// Simulates the full claimre-detectjoin path for a race loser
237+
// Simulates the full claim -> re-detect -> join path for a race loser
164238
const ydoc = new YDoc();
165239
const otherClientId = ydoc.clientID + 1;
166240
const metaMap = ydoc.getMap('meta');
@@ -180,12 +254,16 @@ describe('claimBootstrap', () => {
180254
});
181255
});
182256

257+
// ---------------------------------------------------------------------------
258+
// claim loser always yields
259+
// ---------------------------------------------------------------------------
260+
183261
describe('claim loser always yields', () => {
184262
test('loser yields even when winner marker is still pending (room looks empty)', () => {
185263
// After a failed claim, the loser sees the room with only a pending
186264
// marker (the winner hasn't finalized yet). detectRoomState returns
187265
// 'empty' but the loser must NOT re-seed — they must yield.
188-
// This tests the contract that document.ts enforces: claim loser join.
266+
// This tests the contract that document.ts enforces: claim loser -> join.
189267
const ydoc = new YDoc();
190268
ydoc.getMap('meta').set('bootstrap', {
191269
version: 1,
@@ -206,8 +284,73 @@ describe('claim loser always yields', () => {
206284
});
207285
});
208286

287+
// ---------------------------------------------------------------------------
288+
// detectBootstrapRace
289+
// ---------------------------------------------------------------------------
290+
291+
describe('detectBootstrapRace', () => {
292+
test('returns raceSuspected: false when no competing marker arrives', async () => {
293+
const ydoc = new YDoc();
294+
writeBootstrapMarker(ydoc, 'doc');
295+
296+
const result = await detectBootstrapRace(ydoc, 10);
297+
expect(result.raceSuspected).toBe(false);
298+
});
299+
300+
test('returns raceSuspected: true with competitor info when another finalized marker arrives', async () => {
301+
const ydoc = new YDoc();
302+
const otherClientId = ydoc.clientID + 1;
303+
writeBootstrapMarker(ydoc, 'doc');
304+
305+
const promise = detectBootstrapRace(ydoc, 20);
306+
307+
// Another client's finalized marker arrives during observation
308+
setTimeout(() => {
309+
ydoc.getMap('meta').set('bootstrap', {
310+
version: 1,
311+
clientId: otherClientId,
312+
seededAt: new Date().toISOString(),
313+
source: 'doc',
314+
});
315+
}, 5);
316+
317+
const result = await promise;
318+
expect(result.raceSuspected).toBe(true);
319+
if (result.raceSuspected) {
320+
expect(result.competitor.observedOtherClientId).toBe(otherClientId);
321+
expect(result.competitor.observedSource).toBe('doc');
322+
expect(typeof result.competitor.observedAt).toBe('string');
323+
}
324+
});
325+
326+
test('ignores changes to non-bootstrap meta keys', async () => {
327+
const ydoc = new YDoc();
328+
writeBootstrapMarker(ydoc, 'doc');
329+
330+
const promise = detectBootstrapRace(ydoc, 20);
331+
332+
// Unrelated meta key changes should not trigger false positive
333+
setTimeout(() => {
334+
ydoc.getMap('meta').set('docx', 'some-content');
335+
}, 5);
336+
337+
const result = await promise;
338+
expect(result.raceSuspected).toBe(false);
339+
});
340+
});
341+
342+
// ---------------------------------------------------------------------------
343+
// Constants
344+
// ---------------------------------------------------------------------------
345+
209346
describe('DEFAULT_BOOTSTRAP_SETTLING_MS', () => {
210347
test('is a positive number', () => {
211348
expect(DEFAULT_BOOTSTRAP_SETTLING_MS).toBeGreaterThan(0);
212349
});
213350
});
351+
352+
describe('DEFAULT_BOOTSTRAP_JITTER_MS', () => {
353+
test('is a positive number', () => {
354+
expect(DEFAULT_BOOTSTRAP_JITTER_MS).toBeGreaterThan(0);
355+
});
356+
});

0 commit comments

Comments
 (0)