fix(mobile-quick-commands): replay sheet load killed by connection migration (#9798)
Opening the Quick Commands sheet right after connecting over relay races the relay->direct cutover, which rejects the in-flight one-shot settings.getTerminalQuickCommands with LogicalClientCutoverError while connState stays 'connected'. The sheet then strands on "RPC interrupted by connection migration" with an empty list until closed and reopened. The read is side-effect-free, so replay it on cutover (capped at 5, cancelled if the sheet closes or the client is replaced). Same failure class and pattern as #9794 (capability probe) and #9796 (terminal create).
This commit is contained in:
parent
1a9e819c40
commit
dfbc2e8ba7
|
|
@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
|||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import type { TerminalQuickCommand } from '../../../src/shared/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import { useQuickCommands } from './use-quick-commands'
|
||||
|
||||
|
|
@ -88,6 +89,79 @@ describe('useQuickCommands', () => {
|
|||
expect(state?.ready).toBe(false)
|
||||
})
|
||||
|
||||
it('replays the load after a connection-migration cutover', async () => {
|
||||
const client = {
|
||||
sendRequest: vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new LogicalClientCutoverError())
|
||||
.mockResolvedValueOnce(success([FIRST]))
|
||||
} as unknown as RpcClient
|
||||
|
||||
await mount(client)
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(state?.commands).toEqual([FIRST])
|
||||
expect(state?.ready).toBe(true)
|
||||
expect(state?.error).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces the cutover error once replays are exhausted', async () => {
|
||||
const client = {
|
||||
sendRequest: vi.fn(() => Promise.reject(new LogicalClientCutoverError()))
|
||||
} as unknown as RpcClient
|
||||
|
||||
await mount(client)
|
||||
|
||||
// Initial attempt + 5 replays, then give up rather than loop forever.
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(6)
|
||||
expect(state?.ready).toBe(false)
|
||||
expect(state?.error).toBe('RPC interrupted by connection migration')
|
||||
})
|
||||
|
||||
it('does not replay non-cutover load failures', async () => {
|
||||
const client = {
|
||||
sendRequest: vi.fn(() => Promise.reject(new Error('boom')))
|
||||
} as unknown as RpcClient
|
||||
|
||||
await mount(client)
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(state?.ready).toBe(false)
|
||||
expect(state?.error).toBe('boom')
|
||||
})
|
||||
|
||||
it('stops replaying a cutover-interrupted load after the sheet closes', async () => {
|
||||
let rejectLoad: (error: Error) => void = () => {}
|
||||
const client = {
|
||||
sendRequest: vi.fn(
|
||||
() =>
|
||||
new Promise<RpcResponse>((_resolve, reject) => {
|
||||
rejectLoad = reject
|
||||
})
|
||||
)
|
||||
} as unknown as RpcClient
|
||||
|
||||
function Harness({ enabled }: { enabled: boolean }): null {
|
||||
state = useQuickCommands({ client, enabled })
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { enabled: true }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
renderer!.update(createElement(Harness, { enabled: false }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
rejectLoad(new LogicalClientCutoverError())
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps mutations disabled when the remote list could not be loaded', async () => {
|
||||
const client = {
|
||||
sendRequest: vi.fn().mockResolvedValue(failure('load failed'))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcFailure, RpcSuccess } from '../transport/types'
|
||||
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import type { TerminalQuickCommand } from '../../../src/shared/types'
|
||||
import {
|
||||
applyTerminalQuickCommandMutation,
|
||||
|
|
@ -43,6 +44,30 @@ function readQuickCommands(result: unknown): TerminalQuickCommand[] | null {
|
|||
return parseNormalizedTerminalQuickCommands(list)
|
||||
}
|
||||
|
||||
const LOAD_CUTOVER_MAX_RETRIES = 5
|
||||
|
||||
// Why: opening the sheet right after connecting over relay races the relay→direct
|
||||
// cutover, which rejects in-flight one-shots while connState stays 'connected';
|
||||
// the read is side-effect-free, so replay it instead of stranding an empty sheet.
|
||||
async function loadQuickCommandsWithCutoverRetry(
|
||||
client: RpcClient,
|
||||
cancelled: () => boolean
|
||||
): Promise<RpcResponse> {
|
||||
for (let migrationRetry = 0; ; migrationRetry += 1) {
|
||||
try {
|
||||
return await client.sendRequest('settings.getTerminalQuickCommands')
|
||||
} catch (error) {
|
||||
if (
|
||||
cancelled() ||
|
||||
!isLogicalClientCutoverError(error) ||
|
||||
migrationRetry >= LOAD_CUTOVER_MAX_RETRIES
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useQuickCommands({ client, enabled }: Args): QuickCommandsState {
|
||||
const [commands, setCommands] = useState<TerminalQuickCommand[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
|
@ -90,7 +115,13 @@ export function useQuickCommands({ client, enabled }: Args): QuickCommandsState
|
|||
) {
|
||||
return
|
||||
}
|
||||
const response = await client.sendRequest('settings.getTerminalQuickCommands')
|
||||
const response = await loadQuickCommandsWithCutoverRetry(
|
||||
client,
|
||||
() =>
|
||||
stale ||
|
||||
operationId !== operationIdRef.current ||
|
||||
mutationContextRef.current !== mutationContext
|
||||
)
|
||||
if (
|
||||
stale ||
|
||||
operationId !== operationIdRef.current ||
|
||||
|
|
|
|||
Loading…
Reference in New Issue