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:
parent
7ad7f6a647
commit
5cc502cc55
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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' }
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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 } } : {})
|
||||
}
|
||||
}
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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', {
|
||||
|
|
|
|||
Loading…
Reference in New Issue