fix(mobile): keep terminal input composable while the connection is cut (#11463)

* 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
This commit is contained in:
Brennan Benson 2026-07-30 17:23:01 -07:00 committed by GitHub
parent 7ad7f6a647
commit 5cc502cc55
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 566 additions and 68 deletions

View File

@ -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<boolean | null>(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()}
/>
<MobileTerminalInputActions

View File

@ -0,0 +1,136 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { resolveMobileTerminalInputGate } from './terminal-input-connection-gate'
import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request'
const sessionRouteSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
)
function routeSlice(anchorStart: string, anchorEnd: string): string {
const start = sessionRouteSource.indexOf(anchorStart)
expect(start).toBeGreaterThanOrEqual(0)
// Why: a duplicated start anchor would silently slice the wrong region.
expect(sessionRouteSource.indexOf(anchorStart, start + 1)).toBe(-1)
const end = sessionRouteSource.indexOf(anchorEnd, start)
expect(end).toBeGreaterThan(start)
return sessionRouteSource.slice(start, end + anchorEnd.length)
}
describe('terminal input connection gate', () => {
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 })
})
})

View File

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

View File

@ -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<unknown> = Promise.resolve({ ok: true })) {
const sendRequest = vi.fn(() => result)
return { client: { sendRequest } as unknown as Pick<RpcClient, 'sendRequest'>, 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()
})
})

View File

@ -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<RpcClient, 'sendRequest'> | 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<void> {
// 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
)
}

View File

@ -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 } } : {})
}
}

View File

@ -11,6 +11,8 @@ type TerminalLiveInputCommitHarness = {
readonly handlers: ReturnType<typeof useTerminalLiveInputCommit<string>>
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<TerminalLiveInputSender> = {
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<typeof useTerminalLiveInputCommit<string>> | 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([])
})
})

View File

@ -22,6 +22,7 @@ type TerminalLiveInputCommitOptions<TTabType extends string> = {
readonly activeHandleRef: RefObject<string | null>
readonly activeSessionTabType: TTabType | null | undefined
readonly activeSessionTabTypeRef: RefObject<TTabType | null>
readonly connected: boolean
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly liveInputTerminalHandlesRef: RefObject<Set<string>>
@ -45,6 +46,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
activeHandleRef,
activeSessionTabType,
activeSessionTabTypeRef,
connected,
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
@ -68,6 +70,13 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
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) {

View File

@ -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<unknown>): { 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)
}
})
})

View File

@ -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', {