diff --git a/mobile/src/tasks/worktree-create-capability-recovery.test.ts b/mobile/src/tasks/worktree-create-capability-recovery.test.ts index 1e0f949e6d3..8f6575bec5f 100644 --- a/mobile/src/tasks/worktree-create-capability-recovery.test.ts +++ b/mobile/src/tasks/worktree-create-capability-recovery.test.ts @@ -231,4 +231,77 @@ describe('capability probe recovery', () => { expect(current?.tasksSupported).toBe(false) } ) + it('bounds failed probes over ten minutes and stops all retries on close', async () => { + vi.useFakeTimers() + const host = connection() + host.sendRequest.mockRejectedValue(new Error('offline')) + await render(host.client) + await act(async () => vi.advanceTimersByTimeAsync(600_000)) + expect(host.sendRequest).toHaveBeenCalledTimes(24) + expect(vi.getTimerCount()).toBe(1) + await render(host.client, false) + expect(vi.getTimerCount()).toBe(0) + await act(async () => vi.advanceTimersByTimeAsync(600_000)) + expect(host.sendRequest).toHaveBeenCalledTimes(24) + }) + + it('does not poll a confirmed unsupported host across renders or sheet reopens', async () => { + vi.useFakeTimers() + const host = connection() + host.sendRequest.mockResolvedValue({ + id: 'old', + ok: true, + result: { capabilities: [] }, + _meta: { runtimeId: 'old' } + }) + await render(host.client) + for (let reopen = 0; reopen < 20; reopen += 1) { + await render(host.client, false) + await render(host.client) + } + await act(async () => vi.advanceTimersByTimeAsync(600_000)) + expect(host.sendRequest).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it.each(['old-first', 'new-first'])( + 'leaves no retry or RPC after overlapping reconnect failures settle %s and unmount', + async (order) => { + vi.useFakeTimers() + const host = connection() + let failOld: ((reason: Error) => void) | undefined + let failNew: ((reason: Error) => void) | undefined + host.sendRequest.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failOld = reject + }) + ) + host.sendRequest.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failNew = reject + }) + ) + await render(host.client) + await act(async () => host.reconnect()) + await act(async () => { + if (order === 'old-first') { + failOld?.(new Error('old offline')) + } + failNew?.(new Error('new offline')) + }) + if (order === 'new-first') { + await act(async () => failOld?.(new Error('old offline'))) + } + expect(vi.getTimerCount()).toBe(1) + act(() => renderer?.unmount()) + renderer = undefined + expect(vi.getTimerCount()).toBe(0) + await act(async () => vi.advanceTimersByTimeAsync(60_000)) + expect(host.sendRequest).toHaveBeenCalledTimes(2) + host.reconnect() + expect(host.sendRequest).toHaveBeenCalledTimes(2) + } + ) }) diff --git a/mobile/src/tasks/worktree-create-capability.ts b/mobile/src/tasks/worktree-create-capability.ts index 1de2e62a248..f4e36eca008 100644 --- a/mobile/src/tasks/worktree-create-capability.ts +++ b/mobile/src/tasks/worktree-create-capability.ts @@ -139,12 +139,20 @@ export function useNewWorktreeRuntimeCapabilities( return } let stale = false + let refreshVersion = 0 let retry: ReturnType | undefined let retryDelay = 1_000 const refresh = async () => { + if (stale) { + return + } + const version = ++refreshVersion clearTimeout(retry) const capabilities = await getCapabilities() - if (!stale && capabilities) { + if (stale || version !== refreshVersion) { + return + } + if (capabilities) { setCapabilityState({ client, generation: client.getGeneration?.(), @@ -153,7 +161,7 @@ export function useNewWorktreeRuntimeCapabilities( }) retryDelay = 1_000 } - if (!stale && !capabilityProbeRef.current && client.getState() === 'connected') { + if (!capabilityProbeRef.current && client.getState() === 'connected') { retry = setTimeout(() => void refresh(), retryDelay) retryDelay = Math.min(retryDelay * 2, 30_000) }