From 5cc502cc553f508be3afe5e52d8da0a049503f2a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:23:01 -0700 Subject: [PATCH] fix(mobile): keep terminal input composable while the connection is cut (#11463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): keep terminal input composable while the connection is cut Fixes #6713. While the socket was down every input control on the mobile session screen was hard-disabled by the single canSend gate — the keyboard would not even open, and everything typed during the outage was silently discarded. Split the gate: canCompose (local composing, survives an outage) vs canSend (needs the live socket). The buffered command box stays editable offline and holds the text; the send button, accessory keys, and live-input capture stay connection-gated; the live/buffered mode toggle stays tappable so live-mode users can reach the compose box. The return-key submit path holds composed text instead of firing a doomed RPC. Also reset the live-input mirror when the connection drops: bytes sent into a stalled link are lost but were recorded as delivered, so the first post-reconnect send replayed stale fragments or emitted phantom erases (observed as `YZZYecho CLEANLINE` corrupting the next command on device). * fix(mobile): stop stalled terminal input replaying into the PTY after reconnect Device verification of the first commit surfaced the real replay vector for the second defect: sendRequest parks in waitForConnected while disconnected, so live-mirror deltas queued behind a dying send drain into the connect wait and fire on the next socket — bytes typed during an outage executed tens of seconds later (observed on device as the prompt reading `nOPQ` after reconnect with no post-recovery typing). Add SendRequestOptions.failWhenDisconnected — reject now instead of parking — and opt in every keystroke-grade terminal send: live mirror, accessory keys, buffered command send, and gesture arrows. Deliberate command sends (initialPrompt on terminal create) keep the connect wait. terminal.send param construction moves to terminal-send-request.ts and the accessory raw-send tail to terminal-live-accessory-raw-send.ts. Re-verified on simulator through a blackhole cut-proxy: text typed during the stall no longer replays, and the first post-recovery command executes verbatim. * test(mobile): assert route-slice anchors are unique so pins cannot slice the wrong region * docs(mobile): trim replay-fix comments to one-line rationale --- .../app/h/[hostId]/session/[worktreeId].tsx | 134 ++++++++-------- .../terminal-input-connection-gate.test.ts | 136 +++++++++++++++++ .../terminal-input-connection-gate.ts | 26 ++++ .../terminal-live-accessory-raw-send.test.ts | 56 +++++++ .../terminal-live-accessory-raw-send.ts | 43 ++++++ mobile/src/terminal/terminal-send-request.ts | 26 ++++ .../use-terminal-live-input-commit.test.ts | 55 ++++++- .../use-terminal-live-input-commit.ts | 9 ++ .../rpc-client-connect-wait-replay.test.ts | 143 ++++++++++++++++++ mobile/src/transport/rpc-client.ts | 6 + 10 files changed, 566 insertions(+), 68 deletions(-) create mode 100644 mobile/src/terminal/terminal-input-connection-gate.test.ts create mode 100644 mobile/src/terminal/terminal-input-connection-gate.ts create mode 100644 mobile/src/terminal/terminal-live-accessory-raw-send.test.ts create mode 100644 mobile/src/terminal/terminal-live-accessory-raw-send.ts create mode 100644 mobile/src/terminal/terminal-send-request.ts create mode 100644 mobile/src/transport/rpc-client-connect-wait-replay.test.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 9deac34cd..6a7bfc3a5 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -114,7 +114,7 @@ import { loadTerminalAccessoryLayout } from '../../../../src/terminal/terminal-accessory-layout' import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input' -import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target' +import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send' import { clearTerminalLiveInputFocusTimer, focusTerminalLiveInputTarget, @@ -127,6 +127,11 @@ import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-sen import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply' import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version' import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit' +import { resolveMobileTerminalInputGate } from '../../../../src/terminal/terminal-input-connection-gate' +import { + buildTerminalSendParams, + TERMINAL_INPUT_SEND_OPTIONS +} from '../../../../src/terminal/terminal-send-request' import { getTerminalCommandKeyboardType, getTerminalLiveInputKeyboardType @@ -1064,18 +1069,18 @@ export default function SessionScreen() { activeHandleRef, activeSessionTabType: activeSessionTab?.type, activeSessionTabTypeRef, + connected: connState === 'connected', liveInputRef, liveInputTerminalHandles, liveInputTerminalHandlesRef, sendLiveTerminalInputRef, setLiveInputCapture }) - const canSend = - connState === 'connected' && - activeHandle != null && - activeSessionTab?.type !== 'markdown' && - activeSessionTab?.type !== 'file' && - activeSessionTab?.type !== 'browser' + const { canCompose, canSend } = resolveMobileTerminalInputGate({ + connState, + activeHandle, + activeSessionTabType: activeSessionTab?.type + }) const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false const [browserScreencastSupported, setBrowserScreencastSupported] = useState(null) // Why: hosts without aiVault.v1 reject listSessions, so hide the header entry instead of a dead-end "update this host" panel. @@ -2998,7 +3003,8 @@ export default function SessionScreen() { }, [activeSessionTab, fileDocs, readFileTab]) async function handleSend() { - if (!client || !activeHandle || sendingRef.current) { + // Why: the return key still submits while offline; hold the composed text instead of firing a doomed RPC (#6713). + if (!client || !activeHandle || sendingRef.current || !canSend) { return } sendingRef.current = true @@ -3007,15 +3013,17 @@ export default function SessionScreen() { setInput('') try { - await client.sendRequest('terminal.send', { - terminal: activeHandle, - text, - enter: true, - // Why: presence-lock take-floor; marks this phone active so multi-mobile contention resolves to the last actor. - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) + // Why: fail now and restore the text — a send parked across a reconnect would execute long after the tap. + await client.sendRequest( + 'terminal.send', + buildTerminalSendParams({ + terminal: activeHandle, + text, + enter: true, + deviceToken: deviceTokenRef.current + }), + TERMINAL_INPUT_SEND_OPTIONS + ) } catch { setInput(text) } finally { @@ -3032,29 +3040,15 @@ export default function SessionScreen() { if (accessoryCommit.kind !== 'allow-raw') { return } - const currentClient = clientRef.current - // Why: async IME flushing can outlive the original terminal selection. - const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({ + await sendTerminalLiveAccessoryRawBytes({ + client: clientRef.current, targetHandle, activeHandle: activeHandleRef.current, - activeSessionTabType: activeSessionTabTypeRef.current + activeSessionTabType: activeSessionTabTypeRef.current, + connState: connStateRef.current, + bytes: input.bytes, + deviceToken: deviceTokenRef.current }) - if (!currentClient || !rawSendTarget || connStateRef.current !== 'connected') { - return - } - await currentClient - .sendRequest('terminal.send', { - terminal: rawSendTarget, - text: input.bytes, - enter: false, - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) - .then( - () => undefined, - () => undefined - ) } const sendLiveTerminalInput = useCallback( @@ -3078,15 +3072,19 @@ export default function SessionScreen() { ) { return false } + // Why: live-mirror deltas queued behind a dying send drain into the connect + // wait and replay stale bytes after reconnect (#6713's `YZZYecho …` corruption). return rpc - .sendRequest('terminal.send', { - terminal: handle, - text, - enter: false, - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) + .sendRequest( + 'terminal.send', + buildTerminalSendParams({ + terminal: handle, + text, + enter: false, + deviceToken: deviceTokenRef.current + }), + TERMINAL_INPUT_SEND_OPTIONS + ) .then(isTerminalSendRpcAccepted, () => false) }, [showToast] @@ -3348,14 +3346,17 @@ export default function SessionScreen() { terminalGestureInputInFlightRef.current.add(handle) try { - await rpc.sendRequest('terminal.send', { - terminal: handle, - text: queued.bytes, - enter: false, - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) + // Why: gesture arrows parked across a reconnect would move a TUI long after the swipe. + await rpc.sendRequest( + 'terminal.send', + buildTerminalSendParams({ + terminal: handle, + text: queued.bytes, + enter: false, + deviceToken: deviceTokenRef.current + }), + TERMINAL_INPUT_SEND_OPTIONS + ) } catch { // Transient failure } finally { @@ -3837,14 +3838,15 @@ export default function SessionScreen() { subscribeToTerminal(createdHandle) if (options?.initialPrompt?.trim()) { void client - .sendRequest('terminal.send', { - terminal: createdHandle, - text: options.initialPrompt, - enter: options.enter !== false, - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) + .sendRequest( + 'terminal.send', + buildTerminalSendParams({ + terminal: createdHandle, + text: options.initialPrompt, + enter: options.enter !== false, + deviceToken: deviceTokenRef.current + }) + ) .then((sendResponse) => { if (!sendResponse.ok) { throw new Error( @@ -4847,9 +4849,10 @@ export default function SessionScreen() { styles.accessoryKey, liveInputEnabled && styles.accessoryKeyActive, pressed && styles.accessoryKeyPressed, - !canSend && styles.accessoryKeyDisabled + !canCompose && styles.accessoryKeyDisabled ]} - disabled={!canSend} + // Why: offline, live mode is dead but the buffered box still composes — keep the escape hatch tappable (#6713). + disabled={!canCompose} onPress={toggleLiveInput} accessibilityLabel={ liveInputEnabled @@ -4862,7 +4865,7 @@ export default function SessionScreen() { color={ liveInputEnabled ? colors.bgBase - : canSend + : canCompose ? colors.textSecondary : colors.textMuted } @@ -5057,7 +5060,8 @@ export default function SessionScreen() { autocompleteEnabled )} returnKeyType="send" - editable={canSend} + // Why: composing is local — an outage must not lock the field or discard typed text (#6713). + editable={canCompose} onSubmitEditing={() => void handleSend()} /> { + it('Given a live connection on a terminal tab Then composing and sending are both allowed', () => { + expect( + resolveMobileTerminalInputGate({ + connState: 'connected', + activeHandle: 'terminal-a', + activeSessionTabType: 'terminal' + }) + ).toEqual({ canCompose: true, canSend: true }) + }) + + it('Given a cut connection Then composing stays available while sending is blocked', () => { + for (const connState of [ + 'connecting', + 'handshaking', + 'disconnected', + 'reconnecting', + 'auth-failed' + ] as const) { + expect( + resolveMobileTerminalInputGate({ + connState, + activeHandle: 'terminal-a', + activeSessionTabType: 'terminal' + }) + ).toEqual({ canCompose: true, canSend: false }) + } + }) + + it('Given a non-terminal tab or no handle Then neither composing nor sending is allowed', () => { + for (const activeSessionTabType of ['markdown', 'file', 'browser']) { + expect( + resolveMobileTerminalInputGate({ + connState: 'connected', + activeHandle: 'terminal-a', + activeSessionTabType + }) + ).toEqual({ canCompose: false, canSend: false }) + } + expect( + resolveMobileTerminalInputGate({ + connState: 'connected', + activeHandle: null, + activeSessionTabType: 'terminal' + }) + ).toEqual({ canCompose: false, canSend: false }) + }) + + it('Given a lagging tab list yielding no tab Then the gate treats the type as unknown, not non-terminal', () => { + expect( + resolveMobileTerminalInputGate({ + connState: 'disconnected', + activeHandle: 'terminal-a', + activeSessionTabType: undefined + }) + ).toEqual({ canCompose: true, canSend: false }) + }) +}) + +describe('session route offline-compose wiring', () => { + it('derives both gates from the shared resolver', () => { + expect(sessionRouteSource).toContain('resolveMobileTerminalInputGate({') + }) + + it('keeps the buffered command box editable offline while the live capture stays send-gated', () => { + const bufferedInput = routeSlice( + 'ref={commandInputRef}', + 'onSubmitEditing={() => void handleSend()}' + ) + expect(bufferedInput).toContain('editable={canCompose}') + + const liveCapture = routeSlice('ref={liveInputRef}', 'importantForAutofill="no"') + expect(liveCapture).toContain('editable={canSend}') + }) + + it('keeps the send button connection-gated so held text cannot fire into a dead link', () => { + const sendButton = routeSlice('styles.sendButton,', 'accessibilityLabel="Send command"') + expect(sendButton).toContain('disabled={!canSend}') + }) + + it('holds composed text when the return key submits offline', () => { + const handleSend = routeSlice('async function handleSend()', 'sendingRef.current = true') + expect(handleSend).toContain('!canSend') + }) + + it('keeps the live/buffered mode toggle reachable offline', () => { + const modeToggle = routeSlice( + 'liveInputEnabled && styles.accessoryKeyActive', + 'onPress={toggleLiveInput}' + ) + expect(modeToggle).toContain('disabled={!canCompose}') + }) + + it('tells the live-input commit hook about connection loss so stale mirror state resets', () => { + const hookCall = routeSlice('useTerminalLiveInputCommit({', 'setLiveInputCapture') + expect(hookCall).toContain("connected: connState === 'connected'") + }) + + it('keeps every keystroke-grade terminal send now-or-never so nothing replays after reconnect', () => { + // Live mirror, buffered send, and gesture arrows must all opt out of the + // connect wait — a parked send replays stale bytes into the PTY. Accessory + // keys get the same option inside terminal-live-accessory-raw-send.ts. + const optOuts = sessionRouteSource.match(/TERMINAL_INPUT_SEND_OPTIONS/g)?.length ?? 0 + expect(optOuts).toBe(4) + expect(TERMINAL_INPUT_SEND_OPTIONS).toEqual({ failWhenDisconnected: true }) + }) + + it('tags terminal sends with the device presence lock only when a token exists', () => { + expect( + buildTerminalSendParams({ terminal: 't1', text: 'ls', enter: true, deviceToken: 'tok' }) + ).toEqual({ terminal: 't1', text: 'ls', enter: true, client: { id: 'tok', type: 'mobile' } }) + expect( + buildTerminalSendParams({ terminal: 't1', text: 'ls', enter: false, deviceToken: null }) + ).toEqual({ terminal: 't1', text: 'ls', enter: false }) + }) +}) diff --git a/mobile/src/terminal/terminal-input-connection-gate.ts b/mobile/src/terminal/terminal-input-connection-gate.ts new file mode 100644 index 000000000..c8d4da301 --- /dev/null +++ b/mobile/src/terminal/terminal-input-connection-gate.ts @@ -0,0 +1,26 @@ +import type { ConnectionState } from '../transport/types' + +type MobileTerminalInputGateOptions = { + readonly connState: ConnectionState + readonly activeHandle: string | null + readonly activeSessionTabType: string | null | undefined +} + +type MobileTerminalInputGate = { + // Why: composing is local — it must survive an outage so typed text is held, not discarded (#6713). + readonly canCompose: boolean + readonly canSend: boolean +} + +export function resolveMobileTerminalInputGate({ + connState, + activeHandle, + activeSessionTabType +}: MobileTerminalInputGateOptions): MobileTerminalInputGate { + const canCompose = + activeHandle != null && + activeSessionTabType !== 'markdown' && + activeSessionTabType !== 'file' && + activeSessionTabType !== 'browser' + return { canCompose, canSend: canCompose && connState === 'connected' } +} diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts b/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts new file mode 100644 index 000000000..7a1110b40 --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { sendTerminalLiveAccessoryRawBytes } from './terminal-live-accessory-raw-send' +import type { RpcClient } from '../transport/rpc-client' + +function captureClient(result: Promise = Promise.resolve({ ok: true })) { + const sendRequest = vi.fn(() => result) + return { client: { sendRequest } as unknown as Pick, sendRequest } +} + +const BASE_ARGS = { + targetHandle: 'terminal-a', + activeHandle: 'terminal-a', + activeSessionTabType: 'terminal', + connState: 'connected', + bytes: '', + deviceToken: 'tok' +} as const + +describe('terminal live accessory raw send', () => { + it('sends raw bytes now-or-never with the device presence tag', async () => { + const { client, sendRequest } = captureClient() + + await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client }) + + expect(sendRequest).toHaveBeenCalledWith( + 'terminal.send', + { terminal: 'terminal-a', text: '', enter: false, client: { id: 'tok', type: 'mobile' } }, + // Why: an accessory key parked in the connect wait would fire into the PTY long after the tap. + { failWhenDisconnected: true } + ) + }) + + it('drops the bytes instead of sending while disconnected', async () => { + const { client, sendRequest } = captureClient() + + await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' }) + + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('drops the bytes when the terminal selection went stale mid-flush', async () => { + const { client, sendRequest } = captureClient() + + await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' }) + + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('swallows a rejected send so accessory taps never surface transport errors', async () => { + const { client } = captureClient(Promise.reject(new Error('Not connected: terminal.send'))) + + await expect( + sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client }) + ).resolves.toBeUndefined() + }) +}) diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send.ts b/mobile/src/terminal/terminal-live-accessory-raw-send.ts new file mode 100644 index 000000000..792491f6c --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-raw-send.ts @@ -0,0 +1,43 @@ +import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target' +import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' + +type TerminalLiveAccessoryRawSendArgs = { + readonly client: Pick | null + readonly targetHandle: string + readonly activeHandle: string | null + readonly activeSessionTabType: string | null + readonly connState: ConnectionState + readonly bytes: string + readonly deviceToken: string | null +} + +export async function sendTerminalLiveAccessoryRawBytes( + args: TerminalLiveAccessoryRawSendArgs +): Promise { + // Why: async IME flushing can outlive the original terminal selection. + const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({ + targetHandle: args.targetHandle, + activeHandle: args.activeHandle, + activeSessionTabType: args.activeSessionTabType + }) + if (!args.client || !rawSendTarget || args.connState !== 'connected') { + return + } + await args.client + .sendRequest( + 'terminal.send', + buildTerminalSendParams({ + terminal: rawSendTarget, + text: args.bytes, + enter: false, + deviceToken: args.deviceToken + }), + TERMINAL_INPUT_SEND_OPTIONS + ) + .then( + () => undefined, + () => undefined + ) +} diff --git a/mobile/src/terminal/terminal-send-request.ts b/mobile/src/terminal/terminal-send-request.ts new file mode 100644 index 000000000..e99c6f03c --- /dev/null +++ b/mobile/src/terminal/terminal-send-request.ts @@ -0,0 +1,26 @@ +import type { SendRequestOptions } from '../transport/rpc-client' + +type TerminalSendParams = { + readonly terminal: string + readonly text: string + readonly enter: boolean + readonly client?: { readonly id: string; readonly type: 'mobile' } +} + +// Why: keystroke sends must never park in the connect wait — parked sends replay into the PTY after reconnect (#6713). +export const TERMINAL_INPUT_SEND_OPTIONS: SendRequestOptions = { failWhenDisconnected: true } + +export function buildTerminalSendParams(args: { + terminal: string + text: string + enter: boolean + // Why: presence-lock take-floor; marks this phone active so multi-mobile contention resolves to the last actor. + deviceToken: string | null +}): TerminalSendParams { + return { + terminal: args.terminal, + text: args.text, + enter: args.enter, + ...(args.deviceToken ? { client: { id: args.deviceToken, type: 'mobile' as const } } : {}) + } +} diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index b759e30d6..c211ff447 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -11,6 +11,8 @@ type TerminalLiveInputCommitHarness = { readonly handlers: ReturnType> readonly sent: readonly string[] readonly setActiveSessionTabType: (next: string | undefined) => void + readonly setConnected: (next: boolean) => void + readonly setSendResult: (next: boolean) => void readonly unmount: () => void } @@ -46,15 +48,16 @@ function createTerminalLiveInputCommitHarness({ current: new Set([activeHandle]) } const sent: string[] = [] + let currentSendResult = sendResult const sendLiveTerminalInputRef: RefObject = { current: async (_handle, bytes) => { sent.push(bytes) - return sendResult + return currentSendResult } } - // The hook keeps live-input state in refs, so a change handler alone never - // re-renders; only a prop change (this variable) re-runs the pending-clear effect. + // Refs never re-render; only these variables re-run the hook's clear effects. let currentActiveSessionTabType: string | undefined = 'terminal' + let currentConnected = true let handlers: ReturnType> | null = null let renderer: ReactTestRenderer | null = null @@ -64,6 +67,7 @@ function createTerminalLiveInputCommitHarness({ activeHandleRef, activeSessionTabType: currentActiveSessionTabType, activeSessionTabTypeRef, + connected: currentConnected, liveInputRef, liveInputTerminalHandles, liveInputTerminalHandlesRef, @@ -98,6 +102,15 @@ function createTerminalLiveInputCommitHarness({ renderer?.update(createElement(Harness)) }) }, + setConnected: (next: boolean): void => { + currentConnected = next + act(() => { + renderer?.update(createElement(Harness)) + }) + }, + setSendResult: (next: boolean): void => { + currentSendResult = next + }, unmount: () => { act(() => renderer?.unmount()) } @@ -340,4 +353,40 @@ describe('terminal live input commit hook', () => { // Then: pending was dropped, so submit sends only the carriage return await vi.waitFor(() => expect(sent).toEqual(['\r'])) }) + + it('Given bytes lost in a silent stall When the disconnect is detected Then the first post-recovery send carries no stale fragment or phantom erases', async () => { + // Given: a stalled link — the mirror sends but the PTY never accepts (#6713 second defect) + const { captures, handlers, sent, setConnected, setSendResult } = + createTerminalLiveInputCommitHarness({ sendResult: false }) + handlers.handleLiveInputChange('XYZZY') + await vi.waitFor(() => expect(sent).toEqual(['XYZZY'])) + + // When: the outage is finally detected, then the link recovers + setConnected(false) + setSendResult(true) + setConnected(true) + + // Then: the capture was wiped, and fresh typing sends verbatim bytes — not + // 'XYZZY…' replayed and not DELs erasing PTY chars that never arrived + expect(captures.at(-1)).toBe('') + const sentBeforeRecovery = sent.length + handlers.handleLiveInputChange('echo CLEANLINE') + await vi.waitFor(() => expect(sent.slice(sentBeforeRecovery)).toEqual(['echo CLEANLINE'])) + }) + + it('Given a held syllable during an outage When the disconnect is detected Then the settle timer cannot commit it later', async () => { + // Given + vi.useFakeTimers() + const { handlers, sent, setConnected } = createTerminalLiveInputCommitHarness({ + sendResult: false + }) + handlers.handleLiveInputChange('한') + + // When + setConnected(false) + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS) + + // Then: the outage cleared the held text before the timer could send it + expect(sent).toEqual([]) + }) }) diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index 9c707dfbb..d3b3e3fec 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -22,6 +22,7 @@ type TerminalLiveInputCommitOptions = { readonly activeHandleRef: RefObject readonly activeSessionTabType: TTabType | null | undefined readonly activeSessionTabTypeRef: RefObject + readonly connected: boolean readonly liveInputRef: RefObject readonly liveInputTerminalHandles: ReadonlySet readonly liveInputTerminalHandlesRef: RefObject> @@ -45,6 +46,7 @@ export function useTerminalLiveInputCommit({ activeHandleRef, activeSessionTabType, activeSessionTabTypeRef, + connected, liveInputRef, liveInputTerminalHandles, liveInputTerminalHandlesRef, @@ -68,6 +70,13 @@ export function useTerminalLiveInputCommit({ setLiveInputCapture }) + useEffect(() => { + // Why: what reached the PTY is unknowable across an outage — stale mirror state corrupts the first post-reconnect send. + if (!connected) { + clearPendingLiveInputCommit() + } + }, [connected, clearPendingLiveInputCommit]) + useEffect(() => { const pendingHandle = pendingLiveInputHandleRef.current if (!pendingHandle) { diff --git a/mobile/src/transport/rpc-client-connect-wait-replay.test.ts b/mobile/src/transport/rpc-client-connect-wait-replay.test.ts new file mode 100644 index 000000000..24015ce82 --- /dev/null +++ b/mobile/src/transport/rpc-client-connect-wait-replay.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect } from './rpc-client' + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => `encrypted:${plaintext}`, + decrypt: (raw: string) => raw.replace(/^encrypted:/, ''), + decryptBytes: (bytes: Uint8Array) => bytes +})) + +class MockWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSED = 3 + + readonly CONNECTING = MockWebSocket.CONNECTING + readonly OPEN = MockWebSocket.OPEN + readonly CLOSED = MockWebSocket.CLOSED + + readyState = MockWebSocket.CONNECTING + onopen: (() => void) | null = null + onclose: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + sent: string[] = [] + close = vi.fn(() => { + if (this.readyState === MockWebSocket.CLOSED) { + return + } + this.readyState = MockWebSocket.CLOSED + this.onclose?.() + }) + + constructor(readonly endpoint: string) { + mockSockets.push(this) + } + + send(payload: string): void { + this.sent.push(payload) + } + + open(): void { + this.readyState = MockWebSocket.OPEN + this.onopen?.() + this.receive(JSON.stringify({ type: 'e2ee_ready' })) + this.receive('encrypted:{"type":"e2ee_authenticated"}') + } + + receive(payload: unknown): void { + this.onmessage?.({ data: payload }) + } +} + +const mockSockets: MockWebSocket[] = [] +const originalWebSocket = globalThis.WebSocket + +function track(request: Promise): { read: () => string } { + let outcome = 'pending' + request.then( + () => { + outcome = 'resolved' + }, + (error: Error) => { + outcome = error.message + } + ) + return { read: () => outcome } +} + +function terminalSendFrames(socket: MockWebSocket): string[] { + return socket.sent.filter((frame) => frame.includes('terminal.send')) +} + +describe('mobile rpc-client connect-wait replay', () => { + beforeEach(() => { + vi.useFakeTimers() + mockSockets.length = 0 + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket + }) + + afterEach(() => { + vi.useRealTimers() + globalThis.WebSocket = originalWebSocket + }) + + it('Given a cut connection When a send opts into failWhenDisconnected Then it rejects now and nothing replays after reconnect', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + socket.open() + socket.close() + + const request = client.sendRequest( + 'terminal.send', + { terminal: 't1', text: 'YZZY' }, + { failWhenDisconnected: true } + ) + const outcome = track(request) + + try { + await vi.advanceTimersByTimeAsync(0) + expect(outcome.read()).toBe('Not connected: terminal.send') + + // Reconnect; the rejected keystroke must not ride the new socket. + await vi.advanceTimersByTimeAsync(500) + mockSockets[1]!.open() + await vi.advanceTimersByTimeAsync(0) + expect(terminalSendFrames(mockSockets[1]!)).toEqual([]) + } finally { + client.close() + await request.catch(() => undefined) + } + }) + + it('Given a cut connection When a caller does not opt in Then the send parks and is delivered on the next socket', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + socket.open() + socket.close() + + // Pins the default behavior that motivates the opt-out: parked requests + // replay after reconnect, which is what corrupted post-recovery input. + const request = client.sendRequest('terminal.send', { terminal: 't1', text: 'YZZY' }) + const outcome = track(request) + + try { + await vi.advanceTimersByTimeAsync(0) + expect(outcome.read()).toBe('pending') + + await vi.advanceTimersByTimeAsync(500) + mockSockets[1]!.open() + await vi.advanceTimersByTimeAsync(0) + expect(terminalSendFrames(mockSockets[1]!)).toHaveLength(1) + } finally { + client.close() + await request.catch(() => undefined) + } + }) +}) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 30f6d7a42..9aebcc28d 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -54,6 +54,9 @@ export type SendRequestOptions = { * against the post-connect clock, and squeezing them to the floor after a slow * reconnect would fail sends that used to land. */ budgetSpansConnect?: boolean + /** Reject immediately when not connected — a send parked in the connect wait + * replays stale terminal bytes into the PTY after reconnect. */ + failWhenDisconnected?: boolean } type SubscribeOptions = { @@ -1013,6 +1016,9 @@ export function connect( const budget = openRpcRequestBudget(options) const waitStart = budget.startedAt const wasConnected = state === 'connected' + if (options?.failWhenDisconnected && !wasConnected) { + throw new Error(`Not connected: ${method}`) + } await waitForConnected(options?.timeoutMs) if (!wasConnected) { console.log('[net] sendRequest waited for connect', {