diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index b1180f4c81d..474149fd6bc 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -2999,12 +2999,16 @@ export default function SessionScreen() { async function handleAccessoryKey( input: ReturnType, - targetHandle = activeHandleRef.current + targetHandle = activeHandleRef.current, + isDeliveryTargetCurrent: () => boolean = () => targetHandle === activeHandleRef.current ) { - if (!client || !targetHandle || targetHandle !== activeHandleRef.current || !canSend) { + if (!client || !targetHandle || !isDeliveryTargetCurrent() || !canSend) { return false } const accessoryCommit = await handleLiveInputAccessoryBytes(input) + if (!isDeliveryTargetCurrent()) { + return false + } if (accessoryCommit.kind !== 'allow-raw') { return accessoryCommit.kind === 'handled' } @@ -3419,17 +3423,19 @@ export default function SessionScreen() { // Why: the controller queue sits outside RPC, so fence pre-outage taps from a later connection. const targetClient = clientRef.current const targetConnectedAt = targetClient?.getLastConnectedAt() ?? null + const isDeliveryTargetCurrent = (targetHandle: string) => + activeHandleRef.current === targetHandle && + targetClient !== null && + clientRef.current === targetClient && + connStateRef.current === 'connected' && + targetClient.getLastConnectedAt() === targetConnectedAt accessoryRepeatRef.current.start( input, createTerminalAccessoryRepeatSender( activeHandleRef.current, - (targetHandle) => - activeHandleRef.current === targetHandle && - targetClient !== null && - clientRef.current === targetClient && - connStateRef.current === 'connected' && - targetClient.getLastConnectedAt() === targetConnectedAt, - (nextInput, targetHandle) => handleAccessoryKeyRef.current(nextInput, targetHandle) + isDeliveryTargetCurrent, + (nextInput, targetHandle, isTargetCurrent) => + handleAccessoryKeyRef.current(nextInput, targetHandle, isTargetCurrent) ) ) }, diff --git a/mobile/src/terminal/terminal-accessory-repeat.test.ts b/mobile/src/terminal/terminal-accessory-repeat.test.ts index efc3364c035..79e7d487ccb 100644 --- a/mobile/src/terminal/terminal-accessory-repeat.test.ts +++ b/mobile/src/terminal/terminal-accessory-repeat.test.ts @@ -164,7 +164,26 @@ describe('terminal accessory repeat', () => { await expect(send('down')).resolves.toBe(false) expect(sendToTerminal).toHaveBeenCalledTimes(1) - expect(sendToTerminal).toHaveBeenCalledWith('down', 'terminal-a') + expect(sendToTerminal).toHaveBeenCalledWith('down', 'terminal-a', expect.any(Function)) + }) + + it('provides a live target guard for async send preflight', async () => { + let targetCurrent = true + const preflight = deferred() + const send = createTerminalAccessoryRepeatSender( + 'terminal-a', + () => targetCurrent, + async (_input, _targetHandle, isDeliveryTargetCurrent) => { + await preflight.promise + return isDeliveryTargetCurrent() + } + ) + + const result = send('down') + targetCurrent = false + preflight.resolve() + + await expect(result).resolves.toBe(false) }) it('drops a queued send when its press-time connection is no longer current', async () => { diff --git a/mobile/src/terminal/terminal-accessory-repeat.ts b/mobile/src/terminal/terminal-accessory-repeat.ts index 9ed10af12d2..fbdbabd52e9 100644 --- a/mobile/src/terminal/terminal-accessory-repeat.ts +++ b/mobile/src/terminal/terminal-accessory-repeat.ts @@ -2,17 +2,22 @@ export const TERMINAL_ACCESSORY_REPEAT_DELAY_MS = 400 export const TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS = 45 type TerminalAccessoryRepeatSender = (input: TInput) => Promise +type TerminalAccessoryRepeatSendToTerminal = ( + input: TInput, + targetHandle: string, + isDeliveryTargetCurrent: () => boolean +) => Promise export function createTerminalAccessoryRepeatSender( targetHandle: string | null, isDeliveryTargetCurrent: (targetHandle: string) => boolean, - sendToTerminal: (input: TInput, targetHandle: string) => Promise + sendToTerminal: TerminalAccessoryRepeatSendToTerminal ): TerminalAccessoryRepeatSender { return (input) => { if (!targetHandle || !isDeliveryTargetCurrent(targetHandle)) { return Promise.resolve(false) } - return sendToTerminal(input, targetHandle) + return sendToTerminal(input, targetHandle, () => isDeliveryTargetCurrent(targetHandle)) } }