fix(mobile): dispose superseded capability retry loops

This commit is contained in:
Neil
2026-10-21 15:38:46 -07:00
parent c803e22f18
commit db11220736
2 changed files with 83 additions and 2 deletions
@@ -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)
}
)
})
+10 -2
View File
@@ -139,12 +139,20 @@ export function useNewWorktreeRuntimeCapabilities(
return
}
let stale = false
let refreshVersion = 0
let retry: ReturnType<typeof setTimeout> | 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)
}