export interface PollUntilOptions { timeoutMs: number intervalMs?: number label: string signal?: AbortSignal } type PollReadOutcome = { kind: 'value'; value: T } | { kind: 'error'; error: unknown } export async function pollUntil( read: () => Promise, ready: (value: T) => boolean, options: PollUntilOptions, ): Promise { if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) { throw new Error('poll timeout must be a positive finite number') } const intervalMs = options.intervalMs ?? 100 if (!Number.isFinite(intervalMs) || intervalMs < 0) { throw new Error('poll interval must be a non-negative finite number') } const deadline = Date.now() + options.timeoutMs let lastError: unknown let lastRead: { value: T } | null = null // a poll that expires because its predicate kept returning false is the // ordinary case, and the error used to name nothing but the label: every one // of those sent the reader back to re-run the whole flow just to learn what // the state had been. reporting the last value the read produced makes the // failure say it. bounded, because a polled value is often a whole evidence // snapshot and a failure report is not a data dump. const describeLastRead = (): string => { if (lastRead === null) { if (lastError === undefined) return 'no read settled' const message = lastError instanceof Error ? lastError.message : String(lastError) return `last read failed: ${message}` } let serialized: string try { const seen = new WeakSet() serialized = JSON.stringify(lastRead.value, (_key, entry: unknown) => { if (typeof entry !== 'object' || entry === null) return entry if (seen.has(entry)) return '[circular]' seen.add(entry) return entry }) ?? String(lastRead.value) } catch { return 'last value (unserializable)' } if (serialized.length <= 300) return `last value ${serialized}` return `last value ${serialized.slice(0, 300)}... (${serialized.length} chars)` } const timeoutError = () => new Error( `${options.label} timed out after ${options.timeoutMs}ms; ${describeLastRead()}`, { cause: lastError }, ) options.signal?.throwIfAborted() let rejectBoundary: (reason: unknown) => void = () => {} const boundary = new Promise((_, reject) => { rejectBoundary = reject }) const onAbort = () => rejectBoundary( options.signal?.reason ?? new DOMException('The operation was aborted', 'AbortError'), ) options.signal?.addEventListener('abort', onAbort, { once: true }) const deadlineTimer = setTimeout( () => rejectBoundary(timeoutError()), options.timeoutMs, ) try { while (true) { if (Date.now() >= deadline) throw timeoutError() const outcome = await Promise.race([ Promise.resolve() .then(read) .then( (value): PollReadOutcome => ({ kind: 'value', value }), (error: unknown): PollReadOutcome => ({ kind: 'error', error }), ), boundary, ]) options.signal?.throwIfAborted() if (outcome.kind === 'error') { lastError = outcome.error } else { lastRead = { value: outcome.value } if (Date.now() >= deadline) throw timeoutError() try { if (ready(outcome.value)) return outcome.value } catch (error) { lastError = error } } const remaining = deadline - Date.now() if (remaining <= 0) throw timeoutError() if (intervalMs > 0) { let intervalTimer: ReturnType | undefined try { await Promise.race([ new Promise((resolve) => { intervalTimer = setTimeout(resolve, Math.min(intervalMs, remaining)) }), boundary, ]) } finally { clearTimeout(intervalTimer) } } } } finally { clearTimeout(deadlineTimer) options.signal?.removeEventListener('abort', onAbort) } }