Skip to content

Commit eda9c83

Browse files
committed
feat: configurable timeout
1 parent 6037c5f commit eda9c83

2 files changed

Lines changed: 42 additions & 36 deletions

File tree

vscode-workbench/src/textBinding.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,13 @@ export class YTextBinding implements vs.Disposable {
140140
readonly doc: vs.TextDocument,
141141
collabSock: HocuspocusProviderWebsocket,
142142
log_: Logger,
143+
/** The timeout period for {@link scheduleEnsureSync} in milliseconds,
144+
* disabling {@link scheduleEnsureSync} if zero.
145+
* Expected to be non-zero except in tests. */
146+
private readonly ensureSyncTimeoutMs: number = 3_000,
143147
/** Name of this document in {@link collabSock}.
144148
* Expected to be the file path except in tests. */
145149
docName: string = doc.uri.fsPath,
146-
/** Whether to enable the {@link scheduleEnsureSync} fallback.
147-
* Expected to be `true` except in tests. */
148-
readonly enableEnsureSync: boolean = true,
149150
) {
150151
// https://tiptap.dev/docs/hocuspocus/provider/examples#multiplexing
151152
this.hs = new HocuspocusProvider({
@@ -392,9 +393,9 @@ export class YTextBinding implements vs.Disposable {
392393
/** Ensure that {@link localYdoc} has the same contents as {@link doc},
393394
* and that {@link localYdoc} and {@link hs} have the same CRDT state.
394395
* Overwrite {@link localYdoc} and {@link doc} if this is not the case.
395-
* Debounced - runs 3s after the most recent invocation. */
396+
* Debounced - runs {@link ensureSyncTimeoutMs} after the most recent invocation. */
396397
private scheduleEnsureSync() {
397-
if (!this.initialSyncDone || !this.enableEnsureSync) return
398+
if (!this.initialSyncDone || this.ensureSyncTimeoutMs === 0) return
398399
if (this.ensureSyncTimeout) clearTimeout(this.ensureSyncTimeout)
399400
this.ensureSyncTimeout = setTimeout(() => {
400401
this.enqueueTransaction(async () => {
@@ -412,7 +413,7 @@ export class YTextBinding implements vs.Disposable {
412413
await this.initFromRemote()
413414
}
414415
})
415-
}, 3_000)
416+
}, this.ensureSyncTimeoutMs)
416417
}
417418

418419
dispose() {

vscode-workbench/test/collab.test.ts

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,16 @@ async function waitForInitialSync(bindings: YTextBinding[], timeoutMs: number):
8787
async function waitForQuiescence(bindings: YTextBinding[], timeoutMs: number): Promise<void> {
8888
const deadline = performance.now() + timeoutMs
8989
let prev = ''
90+
let stableIters = 0
9091
while (performance.now() < deadline) {
9192
const snap = bindings.map(b => b.remoteYtext.toString() + '|' + b.doc.getText()).join('|')
92-
if (snap === prev) return
93-
else prev = snap
94-
await delay(1000)
93+
if (snap === prev) {
94+
if (stableIters++ >= 5) return
95+
} else {
96+
prev = snap
97+
stableIters = 0
98+
}
99+
await delay(100)
95100
}
96101
assert.fail('timed out waiting for quiescent state')
97102
}
@@ -113,7 +118,7 @@ suite('Collaborative editing', () => {
113118
dispose: () => Promise<void>
114119
}
115120

116-
const mkHandles = async (enableEnsureSync: boolean = true): Promise<TestHandles> => {
121+
const mkHandles = async (ensureSyncTimeoutMs: number = 0): Promise<TestHandles> => {
117122
// In-memory Hocuspocus server on an ephemeral port; no persistence, no signal handlers.
118123
const server = new Server({ stopOnSignals: false, quiet: true })
119124
await new Promise<void>(resolve => server.httpServer.listen(0, '127.0.0.1', resolve))
@@ -125,7 +130,7 @@ suite('Collaborative editing', () => {
125130
vs.workspace.openTextDocument({ content: '', language: 'plaintext' }),
126131
vs.workspace.openTextDocument({ content: '', language: 'plaintext' }),
127132
])
128-
const bindings = docs.map((doc, i) => new YTextBinding(doc, clients[i], consoleLog, DOC_NAME, enableEnsureSync))
133+
const bindings = docs.map((doc, i) => new YTextBinding(doc, clients[i], consoleLog, ensureSyncTimeoutMs, DOC_NAME))
129134

130135
// Route document changes to the matching binding
131136
// (`YTextBindingManager.onDidChangeTextDocument` does this in production).
@@ -162,34 +167,22 @@ suite('Collaborative editing', () => {
162167
ed0.selection = new vs.Selection(0, 0, 0, 0)
163168
const text = 'PROBE\n'
164169
await vs.commands.executeCommand('default:type', { text })
165-
const deadline = performance.now() + 1_000
166-
while (handles.docs[1].getText() !== text) {
167-
if (performance.now() >= deadline) {
168-
assert.strictEqual(handles.docs[1].getText(), text)
169-
}
170-
await delay(50)
171-
}
172-
170+
await waitForQuiescence(handles.bindings, 1_000)
171+
assert.strictEqual(handles.docs[1].getText(), text)
173172
await handles.dispose()
174173
})
175174

176-
const testConcurrentEdits = async (handles: TestHandles) => {
177-
await waitForInitialSync(handles.bindings, 1_000)
178-
179-
// Ensure text editors are visible for both
180-
await vs.window.showTextDocument(handles.docs[0], { viewColumn: COLUMNS[0], preview: false })
181-
await vs.window.showTextDocument(handles.docs[1], { viewColumn: COLUMNS[1], preview: false })
182-
183-
// Drive a batch of edits, alternating between the two documents.
184-
// Not waiting between edits opens a local-vs-remote change race window.
175+
/** Drive a batch of edits, alternating between the two documents.
176+
* Not waiting between edits opens a local-vs-remote change race window. */
177+
const makeConcurrentEdits = async (handles: TestHandles) => {
185178
const rng = mulberry32(0xc0ffee)
186179
const NUM_EDITS = 100
187180
for (let i = 0; i < NUM_EDITS; i++) {
188181
await randomEditOn(handles.docs[i % 2], COLUMNS[i % 2], rng)
189182
}
183+
}
190184

191-
await waitForQuiescence(handles.bindings, 3_000)
192-
185+
const assertEqualStates = (handles: TestHandles) => {
193186
const [d0, d1] = handles.docs.map(d => d.getText())
194187
const [y0, y1] = handles.bindings.map(b => b.remoteYtext.toString())
195188

@@ -200,17 +193,29 @@ suite('Collaborative editing', () => {
200193
assert.strictEqual(d1, y1, diffMessage('doc1 vs its Y.Text', d1, y1))
201194
}
202195

203-
test('Concurrent edits settle on the equal states', async function () {
196+
test('Concurrent edits settle on equal states', async function () {
204197
this.timeout(20_000)
205-
const handles = await mkHandles()
206-
await testConcurrentEdits(handles)
198+
const ensureSyncTimeoutMs = 1_000
199+
const handles = await mkHandles(ensureSyncTimeoutMs)
200+
await waitForInitialSync(handles.bindings, 1_000)
201+
await makeConcurrentEdits(handles)
202+
// Wait for ensureSync
203+
await delay(ensureSyncTimeoutMs + 1_000)
204+
assertEqualStates(handles)
207205
await handles.dispose()
208206
})
209207

210-
test('Concurrent edits settle on the equal states (no resync)', async function () {
208+
test('Concurrent edits settle on equal states (no ensureSync)', async function () {
211209
this.timeout(20_000)
212-
const handles = await mkHandles(false)
213-
await testConcurrentEdits(handles)
210+
const handles = await mkHandles(0)
211+
await waitForInitialSync(handles.bindings, 1_000)
212+
// Ensure both documents are visible:
213+
// only `TextEditor.edit`s are expected to converge without ensureSync.
214+
await vs.window.showTextDocument(handles.docs[0], { viewColumn: COLUMNS[0], preview: false })
215+
await vs.window.showTextDocument(handles.docs[1], { viewColumn: COLUMNS[1], preview: false })
216+
await makeConcurrentEdits(handles)
217+
await waitForQuiescence(handles.bindings, 3_000)
218+
await assertEqualStates(handles)
214219
await handles.dispose()
215220
})
216221
})

0 commit comments

Comments
 (0)