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:
Brennan Benson 2026-07-22 11:45:59 -07:00 committed by GitHub
parent 1a9e819c40
commit dfbc2e8ba7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 107 additions and 2 deletions

View File

@ -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'))

View File

@ -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 ||