diff --git a/src/main/runtime/relay/relay-concurrency-broker-wait.test.ts b/src/main/runtime/relay/relay-concurrency-broker-wait.test.ts index 278e329a5f3..ba782a03862 100644 --- a/src/main/runtime/relay/relay-concurrency-broker-wait.test.ts +++ b/src/main/runtime/relay/relay-concurrency-broker-wait.test.ts @@ -229,4 +229,28 @@ describe('relay live-broker wait under interleaving', () => { expect(openBroker).toHaveBeenCalledTimes(1) coordinator.stop() }) + + it('keeps the budget over a chain of superseding reconciles, not just over armed retries', async () => { + // Every transient-demand acquire and release reconciles, so a busy host can + // supersede a waiter's reconcile faster than opens settle. The budget has to + // survive that or a caller rides the chain indefinitely holding its demand ref. + vi.useFakeTimers() + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker: () => + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error('slow open')), 400) + ), + onStatus: vi.fn(), + random: () => 0.5 + }) + coordinator.reconcile() + const seen = observe(coordinator.waitForLiveBrokerResult(1_000)) + const churn = setInterval(() => coordinator.reconcile(), 200) + + await vi.advanceTimersByTimeAsync(2_000) + clearInterval(churn) + expect(seen()).not.toBe('unsettled') + coordinator.stop() + }) }) diff --git a/src/main/runtime/relay/relay-live-broker-wait.ts b/src/main/runtime/relay/relay-live-broker-wait.ts index 6baad9939c5..83c58920f2b 100644 --- a/src/main/runtime/relay/relay-live-broker-wait.ts +++ b/src/main/runtime/relay/relay-live-broker-wait.ts @@ -30,13 +30,21 @@ export async function runLiveBrokerWait( budgetMs: number ): Promise { const deadline = Date.now() + budgetMs + let joinedReconcile = false while (!source.stopped()) { const broker = source.liveBroker() if (broker) { return { broker } } + // Why the budget applies only once a reconcile has been joined: the one open + // the waiter arrived on is never cut short, but a chain of superseding opens + // must not outlive the budget the caller asked for. + if (joinedReconcile && Date.now() >= deadline) { + return settledResult(source) + } const pending = source.reconcile() const superseded = source.authorityChange() + joinedReconcile = true // Why unbounded on `pending`: a reconcile always settles (opens carry HTTP // deadlines), and cutting a slow-but-succeeding open short would fail a // pairing that was about to work.