Skip to content

Commit d387d26

Browse files
fix(sync-client): bound repo.find() + load file docs concurrently
Opening a project hung for ~60s whenever a single file doc was slow to serve. Two compounding causes in the sync client's connect(): 1. findDoc()'s repo.find() had no deadline, so it waited out automerge-repo's own ~60s unavailable-doc timeout before rejecting. 2. loadFileDocuments() loaded every file doc SERIALLY, so that 60s stall (and any others) summed end-to-end across the whole project. On a large project (the smoke-all extension fixtures share one ~49-file project) or a CPU-starved sync server, one slow doc stalled the entire project open past any caller's budget. In the hub-client this manifested as the Editor/preview never mounting — the dominant cause of the smoke-all E2E flakiness (traced: a file doc's repo.find hung exactly 60.2s while the test's 75s render-wait expired). It is also a real user-facing hang on loaded machines. Fixes: - Bound each repo.find() attempt with AbortSignal.timeout (5s). A timed-out attempt is treated like the existing cold-start "unavailable" race: retried while a peer is connected, then surfaced as `unavailable` so the file degrades gracefully via the existing markFileUnavailable path instead of hanging the open. (A doc that loses the race re-loads when its index entry next changes; eager retry-on-peer-arrival remains the separate "plan D2" gap noted in syncWithFiles.) - Load file docs CONCURRENTLY (Promise.all) so the wait is bounded by the single slowest doc, not the sum. Per-file unavailable handling and the rethrow of genuine (non-unavailable) errors are preserved. Verified: quarto-sync-client suite 102 pass; smoke-all 78/78 with no contention (and ~30% faster); under induced heavy CPU contention the extension-fixture failures drop from ~all to ~2/15 (the rest gated by the shared 49-file project + retries on CI).
1 parent 0940c94 commit d387d26

2 files changed

Lines changed: 76 additions & 12 deletions

File tree

hub-client/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ be in reverse chronological order (latest first).
1515
1616
-->
1717

18+
### 2026-06-19
19+
20+
- [`6fc040e8`](https://github.com/quarto-dev/q2/commits/6fc040e8): Opening a project no longer hangs for ~60s when a single file fails to sync promptly — file documents now load concurrently with a bounded per-document timeout, so one slow file degrades gracefully instead of stalling the whole project.
21+
1822
### 2026-06-17
1923

2024
- [`744c6ed1`](https://github.com/quarto-dev/q2/commits/744c6ed1): Editor `.qmd` syntax highlighting now appears immediately on file open, instead of after a brief debounce.

ts-packages/quarto-sync-client/src/client.ts

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,38 @@ const DEFAULT_FIND_DOC_RETRY: Required<FindDocRetryOptions> = {
206206
baseDelayMs: 250,
207207
};
208208

209+
/**
210+
* Per-attempt deadline for `repo.find()` inside {@link findDoc}.
211+
*
212+
* Without it, `repo.find()` waits out automerge-repo's *own* unavailable-doc
213+
* timeout (~60s in v2.5.6) before rejecting. Because `loadFileDocuments` loads
214+
* every project file doc serially, a single slow-to-serve doc — common when
215+
* the sync server is CPU-starved, or in large projects — stalls the whole
216+
* project open (and any caller awaiting it) for a full minute. That 60s serial
217+
* stall was the dominant cause of the smoke-all E2E flakiness (the project
218+
* never finished loading, so the Editor/preview never mounted within the test
219+
* budget) and is a real user-facing hang on loaded machines.
220+
*
221+
* Bounding each attempt lets a genuinely-slow doc abort fast into the existing
222+
* unavailable-retry path: with `attempts` retries it still gets several
223+
* chances (≈17s total worst case at the default), and if it never arrives it
224+
* degrades to the graceful `unavailable` marker instead of hanging the open.
225+
* A truly-needed doc that loses the race re-loads when its index entry next
226+
* changes (`syncWithFiles`); fully eager retry-on-peer-arrival is tracked
227+
* separately (the "plan D2" gap noted in `syncWithFiles`).
228+
*/
229+
const FIND_DOC_ATTEMPT_TIMEOUT_MS = 5000;
230+
231+
/** True for an AbortSignal.timeout / abort rejection from `repo.find()`. */
232+
function isAbortOrTimeoutError(err: unknown): boolean {
233+
if (!(err instanceof Error)) return false;
234+
return (
235+
err.name === 'TimeoutError' ||
236+
err.name === 'AbortError' ||
237+
/\baborted?\b|timed?\s*out/i.test(err.message)
238+
);
239+
}
240+
209241
/**
210242
* Default file filter: only parse .qmd files.
211243
*/
@@ -429,14 +461,33 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS
429461
const repo = state.repo!;
430462
for (let attempt = 0; ; attempt++) {
431463
try {
432-
const handle = await repo.find<T>(docId);
464+
// Bound each attempt so a slow-to-serve doc aborts fast instead of
465+
// hanging on automerge-repo's ~60s internal unavailable timeout (see
466+
// FIND_DOC_ATTEMPT_TIMEOUT_MS). A timed-out attempt is treated like the
467+
// cold-start "unavailable" race: retried while a peer is connected,
468+
// then surfaced as unavailable.
469+
const handle = await repo.find<T>(docId, {
470+
signal: AbortSignal.timeout(FIND_DOC_ATTEMPT_TIMEOUT_MS),
471+
});
433472
await handle.whenReady();
434473
applyActorId(handle, state.actorId);
435474
return handle;
436475
} catch (err) {
437476
const message = err instanceof Error ? err.message : String(err);
438-
if (!/unavailable/i.test(message)) throw err;
439-
if (attempt >= attempts) throw err;
477+
const retriable = /unavailable/i.test(message) || isAbortOrTimeoutError(err);
478+
if (!retriable) throw err;
479+
if (attempt >= attempts) {
480+
// Out of attempts. Normalize a timeout into the "unavailable" shape
481+
// so callers (loadFileDocuments / syncWithFiles) degrade gracefully
482+
// via markFileUnavailable instead of failing the whole connect.
483+
if (isAbortOrTimeoutError(err)) {
484+
throw new Error(
485+
`document ${String(docId)} is unavailable: not served within ` +
486+
`${FIND_DOC_ATTEMPT_TIMEOUT_MS}ms over ${attempts + 1} attempts`,
487+
);
488+
}
489+
throw err;
490+
}
440491
if (state.connectedPeers.size === 0) throw err;
441492
await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** attempt));
442493
// The connection may have been torn down while we slept.
@@ -553,15 +604,24 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS
553604
async function loadFileDocuments(files: FileEntry[]): Promise<void> {
554605
if (!state.repo) return;
555606

556-
for (const file of files) {
557-
try {
558-
const handle = await findDoc<FileDocument>(normalizeDocId(file.docId) as DocumentId);
559-
await subscribeToFile(file.path, handle);
560-
} catch (err) {
561-
if (!isUnavailableError(err)) throw err;
562-
markFileUnavailable(file.path, String(file.docId));
563-
}
564-
}
607+
// Load file docs CONCURRENTLY. Serial loading made the whole connect
608+
// wait out each slow doc end-to-end — with the per-attempt findDoc cap
609+
// that is still attempts×timeout *per slow doc, summed*, which on a
610+
// contended sync server (or a large project) blows past any caller's
611+
// budget. Concurrent loading bounds the wait to the single slowest doc.
612+
// Each file's own unavailable handling is preserved; a non-unavailable
613+
// error still rejects (propagating out of connect) exactly as before.
614+
await Promise.all(
615+
files.map(async (file) => {
616+
try {
617+
const handle = await findDoc<FileDocument>(normalizeDocId(file.docId) as DocumentId);
618+
await subscribeToFile(file.path, handle);
619+
} catch (err) {
620+
if (!isUnavailableError(err)) throw err;
621+
markFileUnavailable(file.path, String(file.docId));
622+
}
623+
}),
624+
);
565625
}
566626

567627
// Helper: sync files with index changes

0 commit comments

Comments
 (0)