From c8a98363a3ab343cb5dfa2bad53e2e6b50eeb9c8 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sat, 5 Sep 2026 16:14:16 -0700
Subject: [PATCH] fix: pipeline receipted mobile input with explicit no-replay
recovery
---
.../src/session/MobileSessionCommandDock.tsx | 11 +-
.../session/MobileTerminalInputRecovery.tsx | 41 +++
.../session/MobileTerminalLiveInputBar.tsx | 3 +-
...ent-send-keyboard-dismissal-wiring.test.ts | 2 +-
.../mobile-session-route-parity.test.ts | 16 +-
.../use-mobile-session-terminal-runtime.ts | 2 +
...se-mobile-session-terminal-send-actions.ts | 59 +++-
...se-mobile-session-terminal-subscription.ts | 2 +
.../use-terminal-input-recovery.test.ts | 173 ++++++++++
.../session/use-terminal-input-recovery.ts | 157 +++++++++
.../terminal-live-accessory-raw-send.test.ts | 24 ++
.../terminal-live-accessory-raw-send.ts | 30 +-
.../terminal/terminal-live-input-sender.ts | 6 +-
.../terminal-live-pending-flush-state.test.ts | 11 +-
.../terminal-live-pending-flush-state.ts | 168 +++++++---
.../terminal-live-pipelined-flush.test.ts | 173 ++++++++++
.../use-terminal-live-input-commit.test.ts | 4 +-
.../use-terminal-live-mirror-failure.test.ts | 213 ++++++++++++
.../use-terminal-live-pending-input-flush.ts | 123 +++++--
mobile/src/transport/direct-rpc-client.ts | 48 +--
mobile/src/transport/e2ee.ts | 2 +-
.../src/transport/logical-client-cutover.ts | 13 +
.../logical-terminal-stream-input.test.ts | 34 ++
.../logical-terminal-stream-input.ts | 85 +++++
.../mobile-relay-rpc-session.test.ts | 32 ++
.../src/transport/mobile-relay-rpc-session.ts | 9 +
.../src/transport/mobile-relay-rpc-streams.ts | 33 +-
.../transport/rpc-client-request-tracker.ts | 2 +
.../transport/rpc-client-socket-options.ts | 21 ++
.../src/transport/rpc-client-socket-send.ts | 38 +++
.../transport/rpc-client-socket-session.ts | 33 +-
.../transport/rpc-client-stream-options.ts | 9 +
.../transport/rpc-client-stream-registry.ts | 37 ++-
mobile/src/transport/rpc-client.ts | 8 +
.../stable-logical-rpc-client.test.ts | 37 +++
.../transport/stable-logical-rpc-client.ts | 42 ++-
.../terminal-input-request-fence.test.ts | 41 +++
.../transport/terminal-input-request-fence.ts | 17 +
...inal-ordered-input-pty.integration.test.ts | 254 +++++++++++++++
.../terminal-ordered-input-routing.test.ts | 120 +++++++
.../transport/terminal-ordered-input.test.ts | 196 +++++++++++
.../src/transport/terminal-ordered-input.ts | 307 ++++++++++++++++++
.../terminal-stream-input-failure.ts | 4 +
.../src/transport/terminal-stream-protocol.ts | 2 +
44 files changed, 2433 insertions(+), 209 deletions(-)
create mode 100644 mobile/src/session/MobileTerminalInputRecovery.tsx
create mode 100644 mobile/src/session/use-terminal-input-recovery.test.ts
create mode 100644 mobile/src/session/use-terminal-input-recovery.ts
create mode 100644 mobile/src/terminal/terminal-live-pipelined-flush.test.ts
create mode 100644 mobile/src/terminal/use-terminal-live-mirror-failure.test.ts
create mode 100644 mobile/src/transport/logical-client-cutover.ts
create mode 100644 mobile/src/transport/logical-terminal-stream-input.test.ts
create mode 100644 mobile/src/transport/logical-terminal-stream-input.ts
create mode 100644 mobile/src/transport/rpc-client-socket-options.ts
create mode 100644 mobile/src/transport/rpc-client-socket-send.ts
create mode 100644 mobile/src/transport/rpc-client-stream-options.ts
create mode 100644 mobile/src/transport/terminal-input-request-fence.test.ts
create mode 100644 mobile/src/transport/terminal-input-request-fence.ts
create mode 100644 mobile/src/transport/terminal-ordered-input-pty.integration.test.ts
create mode 100644 mobile/src/transport/terminal-ordered-input-routing.test.ts
create mode 100644 mobile/src/transport/terminal-ordered-input.test.ts
create mode 100644 mobile/src/transport/terminal-ordered-input.ts
create mode 100644 mobile/src/transport/terminal-stream-input-failure.ts
diff --git a/mobile/src/session/MobileSessionCommandDock.tsx b/mobile/src/session/MobileSessionCommandDock.tsx
index 7c7f772e7d6..6bc2e60221e 100644
--- a/mobile/src/session/MobileSessionCommandDock.tsx
+++ b/mobile/src/session/MobileSessionCommandDock.tsx
@@ -10,6 +10,7 @@ import {
} from 'lucide-react-native'
import { triggerMediumImpact } from '../platform/haptics'
import { MobileTerminalLiveInputBar } from './MobileTerminalLiveInputBar'
+import { MobileTerminalInputRecovery } from './MobileTerminalInputRecovery'
import { useHardwareKeyboardTextInputFocus } from '../hardware-keyboard/use-hardware-keyboard-text-input-focus'
import { createTerminalLiveAccessoryInput } from '../terminal/terminal-live-accessory-input'
import { getTerminalCommandKeyboardType } from '../terminal/terminal-keyboard-type'
@@ -36,7 +37,7 @@ export function MobileSessionCommandDock({ controller }: { controller: MobileSes
liveInputRef,
commandInputRef,
activeSessionTab,
- canSend,
+ canSend: connectionCanSend,
canCompose,
liveInputEnabled,
showNativeChat,
@@ -60,6 +61,7 @@ export function MobileSessionCommandDock({ controller }: { controller: MobileSes
activeBrowserTab,
keyboardLift
} = controller
+ const canSend = connectionCanSend && !controller.terminalInputFailure
const hardwareInputFocus = useHardwareKeyboardTextInputFocus({
enabled:
!activeMarkdownTab &&
@@ -84,6 +86,13 @@ export function MobileSessionCommandDock({ controller }: { controller: MobileSes
}
]}
>
+ {controller.terminalInputFailure && (
+
+ )}
{/* Accessory keys */}
{/* Why: fixed keyboard escape hatch; outside ScrollView + shortcut path so it can't scroll away or be hidden (#5106). */}
diff --git a/mobile/src/session/MobileTerminalInputRecovery.tsx b/mobile/src/session/MobileTerminalInputRecovery.tsx
new file mode 100644
index 00000000000..75a6bebd3d6
--- /dev/null
+++ b/mobile/src/session/MobileTerminalInputRecovery.tsx
@@ -0,0 +1,41 @@
+import { Pressable, StyleSheet, Text, View } from 'react-native'
+import { colors, typography } from '../theme/mobile-theme'
+import type { TerminalStreamInputFailure } from '../transport/terminal-stream-input-failure'
+
+export function MobileTerminalInputRecovery({
+ failure,
+ onRecover,
+ recoveryUnavailable
+}: {
+ failure: TerminalStreamInputFailure
+ onRecover: () => void
+ recoveryUnavailable: boolean
+}) {
+ return (
+
+ Terminal input paused
+
+ {failure.outcome === 'unknown'
+ ? 'Some input may have reached the terminal. Check its contents before continuing.'
+ : 'The host did not accept the input. Check the terminal before continuing.'}{' '}
+ Input will not be replayed.
+
+ {recoveryUnavailable && (
+
+ Recovery needs a new connection to a host that supports ordered input. Update the host if
+ needed.
+
+ )}
+
+ Reconnect input
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: { padding: 12, gap: 8, backgroundColor: colors.terminalBg },
+ title: { color: colors.textPrimary, fontSize: typography.metaSize, fontWeight: '600' },
+ detail: { color: colors.textSecondary, fontSize: typography.metaSize },
+ button: { alignSelf: 'flex-start', minHeight: 36, justifyContent: 'center' }
+})
diff --git a/mobile/src/session/MobileTerminalLiveInputBar.tsx b/mobile/src/session/MobileTerminalLiveInputBar.tsx
index cda36e7dec1..c4921ff9464 100644
--- a/mobile/src/session/MobileTerminalLiveInputBar.tsx
+++ b/mobile/src/session/MobileTerminalLiveInputBar.tsx
@@ -26,7 +26,7 @@ export function MobileTerminalLiveInputBar({
)
const {
handleLiveInputHardwareKey,
- canSend,
+ canSend: connectionCanSend,
focusLiveInput,
dictation,
isAttaching,
@@ -46,6 +46,7 @@ export function MobileTerminalLiveInputBar({
getLiveInteractionGeneration,
dismissKeyboardAfterAgentSend
} = controller
+ const canSend = connectionCanSend && !controller.terminalInputFailure
return (
{
const slice = sourceSlice(
sendActionsSource,
'async function handleAccessoryKey(',
- 'const sendLiveTerminalInput = useCallback('
+ 'const sendLiveTerminalInput = useCallback('
)
expect(slice).not.toContain('dismissKeyboardAfterAgentSend')
})
diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts
index cf0c450ba4b..3d69db9bae9 100644
--- a/mobile/src/session/mobile-session-route-parity.test.ts
+++ b/mobile/src/session/mobile-session-route-parity.test.ts
@@ -63,15 +63,15 @@ const HOST_COMPONENT_NAMES = new Set([
'View'
])
-const HEAD_MAIN_HOOK_SHA256 = 'ae0325005289a8df7a479aa50b4d65dc3f70234bc0bce7ef4dfd923031944178'
-const HEAD_HOOK_BINDING_SHA256 = 'a6c0e22ca841301a97e3cf8f3a9f7ba2a90f848bc7b430845d3df2ae79fea3bf'
+const HEAD_MAIN_HOOK_SHA256 = '8d30f0d8f02761c52690ad162d61a47befa35c2bbb61202e802a4cee4ab38a42'
+const HEAD_HOOK_BINDING_SHA256 = 'de164c375eb0317d8e3ac7b76ff636ea8bc12949297feed5c235c31e21349525'
const HEAD_CALLBACK_IDENTITY_SHA256 =
- '2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb'
-const HEAD_CALLBACK_BODY_SHA256 = '22103ba85a86e3a3fcb80a7509c7a455d79863010cde3af02db6565b55e3ebe9'
+ '4324a33cfe4081380515ca1afddf50b1858fd34f72833cba426829edead92d57'
+const HEAD_CALLBACK_BODY_SHA256 = '15a2e663ddd70ea79677ab8486cf3753b38c349ab83150dd35dae4ed2d32bf0f'
const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13'
const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581'
const HEAD_NESTED_FUNCTION_SHA256 =
- '6a13919ede2a8033436fb03e0ff7c426fbed97f470875a7b21b00aaada17fb73'
+ '0720d9ee9a848386db780b3ec198635fabd3ca33d376a8838dba0b9d3d748926'
const HEAD_NATIVE_REGISTRATION_SHA256 =
'cab85e4e4a3f43289ba93ddea9ccce57aea83e0bf14fd1620a965aad0c1cb49e'
const HEAD_NATIVE_REMOVAL_SHA256 =
@@ -82,7 +82,7 @@ const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f342345
const HEAD_RUNTIME_STRING_SHA256 =
'951ee0022e65cc1b15dfbaf11c78841840059e590c6df6368c56d70d30cf4015'
const HEAD_HOST_JSX_SHA256 = '6cc35b6fe4de4abf3948b4ef24924b1992301bb7c592e1e3de8c1323804ab163'
-const HEAD_LEAF_JSX_SHA256 = 'c94d687d5cb1aa02467863904b1d79c678ab7081521a6586f7b22a6c1f669ab6'
+const HEAD_LEAF_JSX_SHA256 = 'c3815cc0fcc5a7150d0c6e171684583e3287f2af1e83f83b1600c7e9810a7982'
const HEAD_STYLE_REFERENCE_SHA256 =
'7cbf94b352fe9cf5a6cf30e72a4fa9d8f1bb768684563ab14a44cb3a96cab4cb'
const HEAD_IDENTITY_FIELD_SHA256 =
@@ -473,7 +473,7 @@ describe('mobile session route extraction parity', () => {
const contentBindings = CONTENT_COMPONENT_NAMES.flatMap(
(name) => readHookFacts(name, definitions).bindings
)
- expect(main.hooks).toHaveLength(267)
+ expect(main.hooks).toHaveLength(269)
expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256)
expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256)
expect(main.callbacks).toHaveLength(77)
@@ -523,7 +523,7 @@ describe('mobile session route extraction parity', () => {
const jsx = readJsxFacts(readDefinitions())
expect(jsx.host).toHaveLength(123)
expect(hash(jsx.host)).toBe(HEAD_HOST_JSX_SHA256)
- expect(jsx.leaf).toHaveLength(62)
+ expect(jsx.leaf).toHaveLength(63)
expect(hash(jsx.leaf)).toBe(HEAD_LEAF_JSX_SHA256)
expect(jsx.styleReferences).toHaveLength(172)
expect(hash(jsx.styleReferences)).toBe(HEAD_STYLE_REFERENCE_SHA256)
diff --git a/mobile/src/session/use-mobile-session-terminal-runtime.ts b/mobile/src/session/use-mobile-session-terminal-runtime.ts
index 47886acd8c6..ad72c7ddaad 100644
--- a/mobile/src/session/use-mobile-session-terminal-runtime.ts
+++ b/mobile/src/session/use-mobile-session-terminal-runtime.ts
@@ -58,6 +58,7 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
const commandInputRef = useRef(null)
const liveInputFocusTimerRef = useRef | null>(null)
const sendLiveTerminalInputRef = useRef(async () => false)
+ const terminalInputSubscribedRef = useRef<(handle: string) => void>(() => {})
const sessionTabActionSheetKeyboardHideSubRef = useRef | null>(null)
@@ -172,6 +173,7 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
liveInputFocusTimerRef,
sendLiveTerminalInputRef,
sessionTabActionSheetKeyboardHideSubRef,
+ terminalInputSubscribedRef,
sessionTabActionSheetRequestSeqRef,
dictationRouteContextRef,
terminalUnsubsRef,
diff --git a/mobile/src/session/use-mobile-session-terminal-send-actions.ts b/mobile/src/session/use-mobile-session-terminal-send-actions.ts
index 6909f71ca63..9ff034f1c22 100644
--- a/mobile/src/session/use-mobile-session-terminal-send-actions.ts
+++ b/mobile/src/session/use-mobile-session-terminal-send-actions.ts
@@ -15,6 +15,8 @@ import {
} from '../terminal/terminal-send-request'
import { normalizeTerminalTextInput } from '../terminal/terminal-text-input-normalization'
import { useAgentSendKeyboardDismissal } from './use-agent-send-keyboard-dismissal'
+import { useTerminalInputRecovery } from './use-terminal-input-recovery'
+import type { TerminalLiveInputSender } from '../terminal/terminal-live-input-sender'
import type { MobileSessionTab } from './mobile-session-route-types'
import { useMobileSessionTabActionSheetOpener } from './use-mobile-session-tab-action-targets'
import type { MobileSessionTerminalWebviewModel } from './use-mobile-session-terminal-webview'
@@ -50,6 +52,7 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal
showToast
} = scope
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
+ const inputRecovery = useTerminalInputRecovery(scope)
const dismissSoftwareKeyboard = useCallback(() => {
dismissTerminalKeyboard({
@@ -121,7 +124,11 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal
if (accessoryCommit.kind !== 'allow-raw') {
return
}
- await sendTerminalLiveAccessoryRawBytes({
+ const rpc = clientRef.current
+ const reportFailure = rpc
+ ? inputRecovery.captureTerminalInputFailureReporter(targetHandle, rpc)
+ : null
+ const accepted = await sendTerminalLiveAccessoryRawBytes({
client: clientRef.current,
targetHandle,
activeHandle: activeHandleRef.current,
@@ -130,9 +137,12 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal
bytes: input.bytes,
deviceToken: deviceTokenRef.current
})
+ if (!accepted && rpc) {
+ reportFailure?.()
+ }
}
- const sendLiveTerminalInput = useCallback(
+ const sendLiveTerminalInput = useCallback(
async (handle: string, bytes: string): Promise => {
const text = normalizeTerminalTextInput(bytes)
if (text.length === 0) {
@@ -155,21 +165,39 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal
}
// 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',
- buildTerminalSendParams({
- terminal: handle,
- text,
- enter: false,
- deviceToken: deviceTokenRef.current
- }),
- TERMINAL_INPUT_SEND_OPTIONS
- )
- .then(isTerminalSendRpcAccepted, () => false)
+ const reportFailure = inputRecovery.captureTerminalInputFailureReporter(handle, rpc)
+ const streamSend = rpc.sendTerminalStreamInput?.(handle, text) ?? null
+ const accepted = await (
+ streamSend ??
+ rpc
+ .sendRequest(
+ 'terminal.send',
+ buildTerminalSendParams({
+ terminal: handle,
+ text,
+ enter: false,
+ deviceToken: deviceTokenRef.current
+ }),
+ TERMINAL_INPUT_SEND_OPTIONS
+ )
+ .then(isTerminalSendRpcAccepted, () => false)
+ ).catch(() => false)
+ if (!accepted) {
+ reportFailure()
+ }
+ return accepted
},
- [showToast]
+ [showToast, inputRecovery.captureTerminalInputFailureReporter]
)
+ sendLiveTerminalInput.cancelPending = (handle: string) => {
+ const rpc = clientRef.current
+ rpc?.cancelTerminalStreamInput?.(handle)
+ if (rpc) {
+ inputRecovery.reportTerminalInputFailure(handle, rpc)
+ }
+ }
+ sendLiveTerminalInput.supportsPipeline = (handle: string) =>
+ clientRef.current?.supportsTerminalStreamInput?.(handle) ?? false
sendLiveTerminalInputRef.current = sendLiveTerminalInput
const clearSessionTabActionSheetKeyboardListener = useCallback(() => {
@@ -228,6 +256,7 @@ export function useMobileSessionTerminalSendActions(scope: MobileSessionTerminal
)
return {
+ ...inputRecovery,
handleSend,
handleAccessoryKey,
sendLiveTerminalInput,
diff --git a/mobile/src/session/use-mobile-session-terminal-subscription.ts b/mobile/src/session/use-mobile-session-terminal-subscription.ts
index 333d472a30e..ec15d5bfe6e 100644
--- a/mobile/src/session/use-mobile-session-terminal-subscription.ts
+++ b/mobile/src/session/use-mobile-session-terminal-subscription.ts
@@ -34,6 +34,7 @@ export function useMobileSessionTerminalSubscription(
scheduleDelayedAction,
showToast,
markNativeChatInputLeaseReady,
+ terminalInputSubscribedRef,
showNativeChatRef,
getTerminalRef,
unsubscribeTerminal,
@@ -113,6 +114,7 @@ export function useMobileSessionTerminalSubscription(
}
if (data.type === 'subscribed') {
markNativeChatInputLeaseReady(handle)
+ terminalInputSubscribedRef.current(handle)
return
}
// Why: keep the subscription as the input-floor lease but don't mutate covered xterm state; return-to-terminal resubscribes.
diff --git a/mobile/src/session/use-terminal-input-recovery.test.ts b/mobile/src/session/use-terminal-input-recovery.test.ts
new file mode 100644
index 00000000000..ee75288a983
--- /dev/null
+++ b/mobile/src/session/use-terminal-input-recovery.test.ts
@@ -0,0 +1,173 @@
+import { createElement } from 'react'
+import { act, create } from 'react-test-renderer'
+import { describe, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import type { TerminalStreamInputFailure } from '../transport/terminal-stream-input-failure'
+import { useTerminalInputRecovery } from './use-terminal-input-recovery'
+
+function harness() {
+ let detail: TerminalStreamInputFailure | null = { outcome: 'unknown', reason: 'timeout' }
+ const recover = vi.fn(() => {
+ detail = null
+ return true
+ })
+ const client = {
+ getTerminalStreamInputFailure: () => detail,
+ cancelTerminalStreamInput: vi.fn(),
+ recoverTerminalStreamInput: recover
+ } as unknown as RpcClient
+ const options = {
+ client,
+ getSendCompletionGeneration: vi.fn(() => 1),
+ getLiveInteractionGeneration: vi.fn(() => 1),
+ activeHandle: 'a',
+ activeHandleRef: { current: 'a' },
+ clientRef: { current: client },
+ clearPendingLiveInputCommit: vi.fn(),
+ unsubscribeTerminal: vi.fn(),
+ subscribeToTerminal: vi.fn(),
+ terminalInputSubscribedRef: { current: (_handle: string) => {} }
+ }
+ let value!: ReturnType
+ function Probe() {
+ value = useTerminalInputRecovery(options)
+ return null
+ }
+ let renderer!: ReturnType
+ act(() => {
+ renderer = create(createElement(Probe))
+ })
+ return {
+ options,
+ client,
+ recover,
+ get: () => value,
+ render: () => act(() => renderer.update(createElement(Probe))),
+ close: () => act(() => renderer.unmount())
+ }
+}
+
+describe('terminal input explicit recovery', () => {
+ it('does not turn a late pre-recovery failure into a new legacy warning', () => {
+ const h = harness()
+ try {
+ const lateFailure = h.get().captureTerminalInputFailureReporter('a', h.client)
+ act(() => h.get().recoverTerminalInput())
+ act(() => h.options.terminalInputSubscribedRef.current('a'))
+ act(() => lateFailure())
+ expect(h.get().terminalInputFailure).toBeNull()
+ } finally {
+ h.close()
+ }
+ })
+ it('requires a user action and fresh subscription, and never replays text', () => {
+ const h = harness()
+ try {
+ act(() => h.options.terminalInputSubscribedRef.current('a'))
+ expect(h.recover).not.toHaveBeenCalled()
+ act(() => h.get().recoverTerminalInput())
+ expect(h.options.clearPendingLiveInputCommit).toHaveBeenCalledTimes(1)
+ expect(h.client.cancelTerminalStreamInput).toHaveBeenCalledWith('a')
+ expect(h.options.unsubscribeTerminal).toHaveBeenCalledWith('a')
+ expect(h.options.subscribeToTerminal).toHaveBeenCalledWith('a')
+ expect(h.get().terminalInputFailure?.outcome).toBe('unknown')
+ act(() => h.options.terminalInputSubscribedRef.current('a'))
+ expect(h.recover).toHaveBeenCalledWith('a')
+ expect(h.get().terminalInputFailure).toBeNull()
+ } finally {
+ h.close()
+ }
+ })
+
+ it('does not recover a new route from a late subscription event', () => {
+ const h = harness()
+ try {
+ act(() => h.get().recoverTerminalInput())
+ h.options.activeHandleRef.current = 'b'
+ act(() => h.options.terminalInputSubscribedRef.current('a'))
+ expect(h.recover).not.toHaveBeenCalled()
+ } finally {
+ h.close()
+ }
+ })
+
+ it('keeps the warning if the replacement subscription cannot recover', () => {
+ const h = harness()
+ try {
+ h.recover.mockReturnValue(false)
+ act(() => h.get().recoverTerminalInput())
+ act(() => h.options.terminalInputSubscribedRef.current('a'))
+ expect(h.get().terminalInputFailure?.outcome).toBe('unknown')
+ expect(h.get().terminalInputRecoveryUnavailable).toBe(true)
+ } finally {
+ h.close()
+ }
+ })
+
+ it('shows an old-host JSON failure and resumes only after explicit resubscription', () => {
+ const h = harness()
+ try {
+ h.client.getTerminalStreamInputFailure = () => null
+ h.recover.mockReturnValue(false)
+ act(() => h.get().reportTerminalInputFailure('a', h.client, true))
+ expect(h.get().terminalInputFailure?.reason).toBe('legacy_send_failed')
+ act(() => h.options.terminalInputSubscribedRef.current('a'))
+ expect(h.get().terminalInputFailure).not.toBeNull()
+ act(() => h.get().recoverTerminalInput())
+ act(() => h.options.terminalInputSubscribedRef.current('a'))
+ expect(h.get().terminalInputFailure).toBeNull()
+ } finally {
+ h.close()
+ }
+ })
+
+ it('a legacy warning cannot clear a later negotiated uncertain-prefix fence', () => {
+ const h = harness()
+ try {
+ h.client.getTerminalStreamInputFailure = () => null
+ h.recover.mockReturnValue(false)
+ act(() => h.get().reportTerminalInputFailure('a', h.client, true))
+ h.client.getTerminalStreamInputFailure = () => ({
+ outcome: 'unknown',
+ reason: 'receipt_timeout'
+ })
+ act(() => h.get().recoverTerminalInput())
+ act(() => h.options.terminalInputSubscribedRef.current('a'))
+ expect(h.get().terminalInputFailure?.reason).toBe('receipt_timeout')
+ expect(h.get().terminalInputRecoveryUnavailable).toBe(true)
+ } finally {
+ h.close()
+ }
+ })
+
+ it('invalidates recovery across A to B to A even when the late event arrives back on A', () => {
+ const h = harness()
+ try {
+ act(() => h.get().recoverTerminalInput())
+ h.options.activeHandle = h.options.activeHandleRef.current = 'b'
+ h.render()
+ h.options.activeHandle = h.options.activeHandleRef.current = 'a'
+ h.render()
+ act(() => h.options.terminalInputSubscribedRef.current('a'))
+ expect(h.recover).not.toHaveBeenCalled()
+ } finally {
+ h.close()
+ }
+ })
+
+ it.each(['getSendCompletionGeneration', 'getLiveInteractionGeneration'] as const)(
+ 'does not clear newer input after %s changes',
+ (field) => {
+ const h = harness()
+ try {
+ act(() => h.get().recoverTerminalInput())
+ h.options[field].mockReturnValue(2)
+ act(() => h.options.terminalInputSubscribedRef.current('a'))
+ expect(h.recover).not.toHaveBeenCalled()
+ expect(h.options.clearPendingLiveInputCommit).toHaveBeenCalledTimes(1)
+ } finally {
+ h.close()
+ }
+ }
+ )
+})
diff --git a/mobile/src/session/use-terminal-input-recovery.ts b/mobile/src/session/use-terminal-input-recovery.ts
new file mode 100644
index 00000000000..321a85444e3
--- /dev/null
+++ b/mobile/src/session/use-terminal-input-recovery.ts
@@ -0,0 +1,157 @@
+import { useCallback, useLayoutEffect, useRef, useReducer, useState, type RefObject } from 'react'
+import type { RpcClient } from '../transport/rpc-client'
+import type { TerminalStreamInputFailure } from '../transport/terminal-stream-input-failure'
+
+type RecoveryOptions = {
+ activeHandle: string | null
+ client: RpcClient | null
+ getSendCompletionGeneration: () => number
+ getLiveInteractionGeneration: () => number
+ activeHandleRef: RefObject
+ clientRef: RefObject
+ clearPendingLiveInputCommit: () => void
+ unsubscribeTerminal: (handle: string) => void
+ subscribeToTerminal: (handle: string) => void
+ terminalInputSubscribedRef: RefObject<(handle: string) => void>
+}
+
+export function useTerminalInputRecovery(options: RecoveryOptions) {
+ const [, refreshFailure] = useReducer((revision: number) => revision + 1, 0)
+ const recoveryEpochRef = useRef(0)
+ const [legacyFailure, setLegacyFailure] = useState<{ handle: string; client: RpcClient } | null>(
+ null
+ )
+ const [unavailable, setUnavailable] = useState<{ handle: string; client: RpcClient } | null>(null)
+ const recoveryRef = useRef<{
+ handle: string
+ client: RpcClient
+ generation: number
+ interaction: number
+ } | null>(null)
+ useLayoutEffect(() => {
+ return () => {
+ recoveryRef.current = null
+ }
+ }, [options.activeHandle, options.client])
+ const reportTerminalInputFailure = useCallback(
+ (handle: string, client: RpcClient, legacySendFailed = false) => {
+ if (options.clientRef.current !== client || options.activeHandleRef.current !== handle) {
+ return
+ }
+ const detail = client.getTerminalStreamInputFailure?.(handle)
+ if (detail) {
+ refreshFailure()
+ } else if (legacySendFailed) {
+ setLegacyFailure({ handle, client })
+ }
+ },
+ [options.activeHandleRef, options.clientRef]
+ )
+
+ const recoverTerminalInput = useCallback(() => {
+ const handle = options.activeHandleRef.current
+ const client = options.clientRef.current
+ if (!handle || !client) {
+ return
+ }
+ recoveryEpochRef.current += 1
+ setUnavailable(null)
+ // Recovery never replays the optimistic field or assumes what reached the shell.
+ options.clearPendingLiveInputCommit()
+ client.cancelTerminalStreamInput?.(handle)
+ options.unsubscribeTerminal(handle)
+ recoveryRef.current = {
+ handle,
+ client,
+ generation: options.getSendCompletionGeneration(),
+ interaction: options.getLiveInteractionGeneration()
+ }
+ options.subscribeToTerminal(handle)
+ }, [
+ options.activeHandleRef,
+ options.clientRef,
+ options.clearPendingLiveInputCommit,
+ options.unsubscribeTerminal,
+ options.subscribeToTerminal,
+ options.getSendCompletionGeneration,
+ options.getLiveInteractionGeneration
+ ])
+
+ const captureTerminalInputFailureReporter = useCallback(
+ (handle: string, client: RpcClient) => {
+ const epoch = recoveryEpochRef.current
+ const surface = options.getSendCompletionGeneration()
+ return () => {
+ if (
+ epoch === recoveryEpochRef.current &&
+ surface === options.getSendCompletionGeneration()
+ ) {
+ reportTerminalInputFailure(handle, client, true)
+ }
+ }
+ },
+ [options.getSendCompletionGeneration, reportTerminalInputFailure]
+ )
+
+ const handleSubscribed = useCallback(
+ (handle: string) => {
+ const recovery = recoveryRef.current
+ if (
+ !recovery ||
+ recovery.handle !== handle ||
+ options.activeHandleRef.current !== handle ||
+ options.clientRef.current !== recovery.client ||
+ options.getSendCompletionGeneration() !== recovery.generation ||
+ options.getLiveInteractionGeneration() !== recovery.interaction
+ ) {
+ recoveryRef.current = null
+ return
+ }
+ recoveryRef.current = null
+ const legacyOnly =
+ legacyFailure?.handle === handle &&
+ legacyFailure.client === recovery.client &&
+ !recovery.client.getTerminalStreamInputFailure?.(handle)
+ if (recovery.client.recoverTerminalStreamInput?.(handle) || legacyOnly) {
+ options.clearPendingLiveInputCommit()
+ setLegacyFailure(null)
+ refreshFailure()
+ } else {
+ setUnavailable(recovery)
+ reportTerminalInputFailure(handle, recovery.client)
+ }
+ },
+ [
+ options.activeHandleRef,
+ options.clientRef,
+ options.getSendCompletionGeneration,
+ options.getLiveInteractionGeneration,
+ options.clearPendingLiveInputCommit,
+ reportTerminalInputFailure,
+ legacyFailure
+ ]
+ )
+
+ useLayoutEffect(() => {
+ options.terminalInputSubscribedRef.current = handleSubscribed
+ return () => {
+ options.terminalInputSubscribedRef.current = () => {}
+ }
+ }, [handleSubscribed, options.terminalInputSubscribedRef])
+
+ const legacyDetail: TerminalStreamInputFailure | null =
+ legacyFailure?.handle === options.activeHandle && legacyFailure?.client === options.client
+ ? { outcome: 'unknown', reason: 'legacy_send_failed' }
+ : null
+ return {
+ terminalInputRecoveryUnavailable:
+ unavailable?.handle === options.activeHandle && unavailable?.client === options.client,
+ terminalInputFailure: options.activeHandle
+ ? (options.clientRef.current?.getTerminalStreamInputFailure?.(options.activeHandle) ??
+ legacyDetail)
+ : null,
+ reportTerminalInputFailure,
+ captureTerminalInputFailureReporter,
+ recoverTerminalInput
+ }
+}
diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts b/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts
index 4ecab7dd10c..7f992454b82 100644
--- a/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts
+++ b/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts
@@ -25,6 +25,30 @@ const BASE_ARGS = {
} as const
describe('terminal live accessory raw send', () => {
+ it.each([true, false])('uses negotiated receipts without RPC fallback: %s', async (accepted) => {
+ const { client, sendRequest } = captureClient()
+ const sendTerminalStreamInput = vi.fn(() => Promise.resolve(accepted))
+ await expect(
+ sendTerminalLiveAccessoryRawBytes({
+ ...BASE_ARGS,
+ client: { ...client, sendTerminalStreamInput }
+ })
+ ).resolves.toBe(accepted)
+ expect(sendTerminalStreamInput).toHaveBeenCalledWith('terminal-a', '\u001b')
+ expect(sendRequest).not.toHaveBeenCalled()
+ })
+
+ it('falls back only before stream admission when negotiation is absent', async () => {
+ const { client, sendRequest } = captureClient()
+ await expect(
+ sendTerminalLiveAccessoryRawBytes({
+ ...BASE_ARGS,
+ client: { ...client, sendTerminalStreamInput: () => null }
+ })
+ ).resolves.toBe(true)
+ expect(sendRequest).toHaveBeenCalledTimes(1)
+ })
+
it('sends raw bytes now-or-never with the device presence tag', async () => {
const { client, sendRequest } = captureClient()
diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send.ts b/mobile/src/terminal/terminal-live-accessory-raw-send.ts
index 3824d750792..cf904bbde1d 100644
--- a/mobile/src/terminal/terminal-live-accessory-raw-send.ts
+++ b/mobile/src/terminal/terminal-live-accessory-raw-send.ts
@@ -5,7 +5,7 @@ import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
type TerminalLiveAccessoryRawSendArgs = {
- readonly client: Pick | null
+ readonly client: Pick | null
readonly targetHandle: string
readonly activeHandle: string | null
readonly activeSessionTabType: string | null
@@ -26,16 +26,20 @@ export async function sendTerminalLiveAccessoryRawBytes(
if (!args.client || !rawSendTarget || args.connState !== 'connected') {
return false
}
- return args.client
- .sendRequest(
- 'terminal.send',
- buildTerminalSendParams({
- terminal: rawSendTarget,
- text: args.bytes,
- enter: false,
- deviceToken: args.deviceToken
- }),
- TERMINAL_INPUT_SEND_OPTIONS
- )
- .then(isTerminalSendRpcAccepted, () => false)
+ const streamSend = args.client.sendTerminalStreamInput?.(rawSendTarget, args.bytes) ?? null
+ return (
+ streamSend ??
+ args.client
+ .sendRequest(
+ 'terminal.send',
+ buildTerminalSendParams({
+ terminal: rawSendTarget,
+ text: args.bytes,
+ enter: false,
+ deviceToken: args.deviceToken
+ }),
+ TERMINAL_INPUT_SEND_OPTIONS
+ )
+ .then(isTerminalSendRpcAccepted, () => false)
+ ).catch(() => false)
}
diff --git a/mobile/src/terminal/terminal-live-input-sender.ts b/mobile/src/terminal/terminal-live-input-sender.ts
index c3538d1f723..77be3ca714c 100644
--- a/mobile/src/terminal/terminal-live-input-sender.ts
+++ b/mobile/src/terminal/terminal-live-input-sender.ts
@@ -1 +1,5 @@
-export type TerminalLiveInputSender = (handle: string, bytes: string) => Promise
+export type TerminalLiveInputSender = {
+ (handle: string, bytes: string): Promise
+ cancelPending?: (handle: string) => void
+ supportsPipeline?: (handle: string) => boolean
+}
diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts
index a77d4315c29..b7834e2302f 100644
--- a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts
+++ b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts
@@ -98,7 +98,7 @@ describe('terminal live mirror send queue', () => {
expect(payloads).toEqual(['a', 'bc'])
})
- it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => {
+ it('Given a failed prefix When a mirror send queues Then dependent input is not sent', async () => {
// Given
const state = createTerminalLivePendingFlushState()
const order: string[] = []
@@ -115,11 +115,12 @@ describe('terminal live mirror send queue', () => {
// Then
await expect(first).resolves.toBe(false)
- await expect(second).resolves.toBe(true)
- expect(order).toEqual(['first', 'second'])
+ await expect(second).resolves.toBe(false)
+ expect(order).toEqual(['first'])
+ await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(false)
})
- it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => {
+ it('Given a throwing send When a mirror send queues Then both resolve false', async () => {
// Given
const state = createTerminalLivePendingFlushState()
const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => {
@@ -131,7 +132,7 @@ describe('terminal live mirror send queue', () => {
// Then
await expect(first).resolves.toBe(false)
- await expect(second).resolves.toBe(true)
+ await expect(second).resolves.toBe(false)
})
it('Given a settled mirror send When it was the newest Then the state resets to null', async () => {
diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.ts b/mobile/src/terminal/terminal-live-pending-flush-state.ts
index fe0b4927f5a..8ee0e6af3ba 100644
--- a/mobile/src/terminal/terminal-live-pending-flush-state.ts
+++ b/mobile/src/terminal/terminal-live-pending-flush-state.ts
@@ -1,3 +1,5 @@
+import { TERMINAL_LIVE_INPUT_MAX_BYTES } from './terminal-live-input'
+
type TerminalLiveMirrorSender = (handle: string, payload: string) => Promise
type TerminalLivePendingRequest = {
@@ -7,102 +9,174 @@ type TerminalLivePendingRequest = {
type TerminalLivePendingBatch = {
readonly handle: string
payload: string
+ bytes: number
readonly requests: TerminalLivePendingRequest[]
readonly sender: TerminalLiveMirrorSender
+ readonly pipeline: boolean
}
+const MAX_PENDING_REQUESTS = 64
+const MAX_PENDING_BYTES = 1024 * 1024
+const encoder = new TextEncoder()
+
export type TerminalLivePendingFlushState = {
current: Promise | null
- activeRequests: TerminalLivePendingRequest[]
generation: number
pendingBatches: TerminalLivePendingBatch[]
+ activeBatches: Set
+ retainedBytes: number
+ requestCount: number
+ failed: boolean
+ finish: ((sent: boolean) => void) | null
}
export function createTerminalLivePendingFlushState(): TerminalLivePendingFlushState {
return {
current: null,
- activeRequests: [],
generation: 0,
- pendingBatches: []
+ pendingBatches: [],
+ activeBatches: new Set(),
+ retainedBytes: 0,
+ requestCount: 0,
+ failed: false,
+ finish: null
}
}
export function waitForTerminalLivePendingFlush(
state: TerminalLivePendingFlushState
): Promise {
- return state.current ?? Promise.resolve(true)
+ return state.failed ? Promise.resolve(false) : (state.current ?? Promise.resolve(true))
+}
+
+function releaseBatch(
+ state: TerminalLivePendingFlushState,
+ batch: TerminalLivePendingBatch,
+ sent: boolean
+): void {
+ state.retainedBytes -= batch.bytes
+ state.requestCount -= batch.requests.length
+ batch.payload = ''
+ batch.requests.splice(0).forEach(({ resolve }) => resolve(sent))
+}
+
+function rejectPending(state: TerminalLivePendingFlushState): void {
+ for (const batch of state.pendingBatches.splice(0)) {
+ releaseBatch(state, batch, false)
+ }
}
export function cancelTerminalLivePendingFlush(state: TerminalLivePendingFlushState): void {
state.generation += 1
- const requests = [
- ...state.activeRequests,
- ...state.pendingBatches.flatMap((batch) => batch.requests)
- ]
- state.activeRequests = []
- state.pendingBatches = []
- state.current = null
- requests.forEach(({ resolve }) => resolve(false))
-}
-
-async function drainTerminalLiveMirrorSends(
- state: TerminalLivePendingFlushState,
- generation: number
-): Promise {
- let allSent = true
- while (state.generation === generation) {
- const batch = state.pendingBatches.shift()
- if (!batch) {
- state.current = null
- return allSent
- }
-
- state.activeRequests = batch.requests
- const sent = await batch.sender(batch.handle, batch.payload).catch(() => false)
- if (state.generation !== generation) {
- return false
- }
-
- state.activeRequests = []
- batch.requests.forEach(({ resolve }) => resolve(sent))
- allSent &&= sent
+ rejectPending(state)
+ for (const batch of state.activeBatches) {
+ releaseBatch(state, batch, false)
}
- return false
+ state.activeBatches.clear()
+ state.finish?.(false)
+ state.finish = null
+ state.current = null
+ state.failed = false
}
-// Mirror deltas are ordered PTY bytes; batching pending bytes avoids one RTT per keystroke.
+function pumpMirrorSends(state: TerminalLivePendingFlushState): void {
+ if (state.failed) {
+ rejectPending(state)
+ }
+ while (state.pendingBatches.length > 0) {
+ const batch = state.pendingBatches[0]
+ if (
+ [...state.activeBatches].some(
+ (active) =>
+ !active.pipeline ||
+ !batch.pipeline ||
+ active.sender !== batch.sender ||
+ active.handle !== batch.handle
+ )
+ ) {
+ return
+ }
+ state.pendingBatches.shift()
+ state.activeBatches.add(batch)
+ const generation = state.generation
+ const settle = (sent: boolean): void => {
+ if (state.generation !== generation) {
+ return
+ }
+ state.activeBatches.delete(batch)
+ releaseBatch(state, batch, sent)
+ state.failed ||= !sent
+ pumpMirrorSends(state)
+ }
+ try {
+ void batch.sender(batch.handle, batch.payload).then(settle, () => settle(false))
+ } catch {
+ settle(false)
+ }
+ batch.payload = ''
+ }
+ if (state.activeBatches.size === 0) {
+ state.finish?.(!state.failed)
+ state.finish = null
+ state.current = null
+ }
+}
+
+// Pipelining changes dispatch timing, never the meaning of an accepted completion.
export function queueTerminalLiveMirrorSend(
state: TerminalLivePendingFlushState,
handle: string,
payload: string,
- sender: TerminalLiveMirrorSender
+ sender: TerminalLiveMirrorSender,
+ options: { pipeline?: boolean } = {}
): Promise {
+ if (state.failed) {
+ return Promise.resolve(false)
+ }
+ const bytes =
+ payload.length > TERMINAL_LIVE_INPUT_MAX_BYTES ? Infinity : encoder.encode(payload).byteLength
+ if (
+ bytes > TERMINAL_LIVE_INPUT_MAX_BYTES ||
+ state.retainedBytes + bytes > MAX_PENDING_BYTES ||
+ state.requestCount >= MAX_PENDING_REQUESTS
+ ) {
+ state.failed = true
+ rejectPending(state)
+ return Promise.resolve(false)
+ }
let resolveRequest: (sent: boolean) => void = () => {}
const request = new Promise((resolve) => {
resolveRequest = resolve
})
+ state.retainedBytes += bytes
+ state.requestCount += 1
+ const pipeline = options.pipeline === true
const pendingTail = state.pendingBatches.at(-1)
- if (pendingTail?.handle === handle && pendingTail.sender === sender) {
+ if (
+ pendingTail?.handle === handle &&
+ pendingTail.sender === sender &&
+ pendingTail.pipeline === pipeline &&
+ pendingTail.bytes + bytes <= TERMINAL_LIVE_INPUT_MAX_BYTES
+ ) {
pendingTail.payload += payload
+ pendingTail.bytes += bytes
pendingTail.requests.push({ resolve: resolveRequest })
} else {
state.pendingBatches.push({
handle,
payload,
+ bytes,
requests: [{ resolve: resolveRequest }],
- sender
+ sender,
+ pipeline
})
}
if (!state.current) {
- const generation = state.generation
- const drain = drainTerminalLiveMirrorSends(state, generation).catch(() => false)
- state.current = drain
- void drain.then(() => {
- if (state.current === drain) {
- state.current = null
- }
+ state.current = new Promise((resolve) => {
+ state.finish = resolve
})
}
+ pumpMirrorSends(state)
return request
}
diff --git a/mobile/src/terminal/terminal-live-pipelined-flush.test.ts b/mobile/src/terminal/terminal-live-pipelined-flush.test.ts
new file mode 100644
index 00000000000..9a97d3366cf
--- /dev/null
+++ b/mobile/src/terminal/terminal-live-pipelined-flush.test.ts
@@ -0,0 +1,173 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ cancelTerminalLivePendingFlush,
+ createTerminalLivePendingFlushState,
+ queueTerminalLiveMirrorSend,
+ waitForTerminalLivePendingFlush
+} from './terminal-live-pending-flush-state'
+import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
+
+function deferred() {
+ let resolve = (_sent: boolean): void => {}
+ const promise = new Promise((done) => {
+ resolve = done
+ })
+ return { promise, resolve }
+}
+
+afterEach(() => vi.useRealTimers())
+
+describe('negotiated live-input pipelining', () => {
+ it('dispatches later bytes before receipts, but holds Enter until every receipt', async () => {
+ const state = createTerminalLivePendingFlushState()
+ const receipts = [deferred(), deferred()]
+ const sent: string[] = []
+ const sender = (_handle: string, text: string) => {
+ sent.push(text)
+ return receipts[sent.length - 1].promise
+ }
+ const a = queueTerminalLiveMirrorSend(state, 'pty', 'a', sender, { pipeline: true })
+ const b = queueTerminalLiveMirrorSend(state, 'pty', 'b', sender, { pipeline: true })
+ expect(sent).toEqual(['a', 'b'])
+ const enter = sendTerminalLiveControlAfterPendingFlush(
+ () => waitForTerminalLivePendingFlush(state),
+ async () => {
+ sent.push('\r')
+ return true
+ }
+ )
+ receipts[1].resolve(true)
+ await b
+ expect(sent).toEqual(['a', 'b'])
+ receipts[0].resolve(true)
+ await a
+ await expect(enter).resolves.toBe(true)
+ expect(sent).toEqual(['a', 'b', '\r'])
+ expect(state.retainedBytes).toBe(0)
+ expect(state.requestCount).toBe(0)
+ })
+
+ it('latches an out-of-order failure after earlier receipts and refuses Enter', async () => {
+ const state = createTerminalLivePendingFlushState()
+ const receipts = [deferred(), deferred()]
+ let index = 0
+ const sender = () => receipts[index++].promise
+ const a = queueTerminalLiveMirrorSend(state, 'pty', 'a', sender, { pipeline: true })
+ const b = queueTerminalLiveMirrorSend(state, 'pty', 'b', sender, { pipeline: true })
+ receipts[1].resolve(false)
+ await expect(b).resolves.toBe(false)
+ receipts[0].resolve(true)
+ await a
+ expect(state.current).toBeNull()
+ const control = vi.fn(async () => true)
+ await expect(
+ sendTerminalLiveControlAfterPendingFlush(
+ () => waitForTerminalLivePendingFlush(state),
+ control
+ )
+ ).resolves.toBe(false)
+ expect(control).not.toHaveBeenCalled()
+ await expect(
+ queueTerminalLiveMirrorSend(state, 'pty', 'suffix', sender, { pipeline: true })
+ ).resolves.toBe(false)
+ expect(index).toBe(2)
+ })
+
+ it('does not pipeline past a legacy RPC still awaiting its reply', async () => {
+ const state = createTerminalLivePendingFlushState()
+ const first = deferred()
+ const sent: string[] = []
+ const sender = async (_handle: string, text: string) => {
+ sent.push(text)
+ return text === 'old' ? first.promise : true
+ }
+ const legacy = queueTerminalLiveMirrorSend(state, 'pty', 'old', sender)
+ const modern = queueTerminalLiveMirrorSend(state, 'pty', 'new', sender, { pipeline: true })
+ expect(sent).toEqual(['old'])
+ first.resolve(true)
+ await Promise.all([legacy, modern])
+ expect(sent).toEqual(['old', 'new'])
+ })
+
+ it('bounds outstanding request bookkeeping and resets only on explicit cancellation', async () => {
+ const state = createTerminalLivePendingFlushState()
+ const receipt = deferred()
+ const sender = vi.fn(() => receipt.promise)
+ const requests = Array.from({ length: 64 }, () =>
+ queueTerminalLiveMirrorSend(state, 'pty', 'x', sender, { pipeline: true })
+ )
+ await expect(
+ queueTerminalLiveMirrorSend(state, 'pty', 'overflow', sender, { pipeline: true })
+ ).resolves.toBe(false)
+ expect(sender).toHaveBeenCalledTimes(64)
+ expect(state.requestCount).toBe(64)
+ cancelTerminalLivePendingFlush(state)
+ await expect(Promise.all(requests)).resolves.toEqual(Array(64).fill(false))
+ expect(state.retainedBytes).toBe(0)
+ expect(state.requestCount).toBe(0)
+ await expect(
+ queueTerminalLiveMirrorSend(state, 'replacement', 'fresh', async () => true, {
+ pipeline: true
+ })
+ ).resolves.toBe(true)
+ receipt.resolve(false)
+ await Promise.resolve()
+ await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true)
+ })
+
+ it('bounds UTF-8 frame and total bytes, including acknowledged-later batches', async () => {
+ const state = createTerminalLivePendingFlushState()
+ const receipt = deferred()
+ const sender = vi.fn(() => receipt.promise)
+ const payload = 'é'.repeat(128 * 1024)
+ const requests = Array.from({ length: 4 }, () =>
+ queueTerminalLiveMirrorSend(state, 'pty', payload, sender, { pipeline: true })
+ )
+ expect(state.retainedBytes).toBe(1024 * 1024)
+ await expect(
+ queueTerminalLiveMirrorSend(state, 'pty', 'x', sender, { pipeline: true })
+ ).resolves.toBe(false)
+ expect(sender).toHaveBeenCalledTimes(4)
+ cancelTerminalLivePendingFlush(state)
+ await Promise.all(requests)
+ await expect(
+ queueTerminalLiveMirrorSend(state, 'pty', payload + 'x', sender, { pipeline: true })
+ ).resolves.toBe(false)
+ expect(sender).toHaveBeenCalledTimes(4)
+ receipt.resolve(false)
+ })
+
+ it.each([300, 400, 500])(
+ 'keeps 50ms typing cadence under %ims receipts without weakening completion',
+ async (rtt) => {
+ vi.useFakeTimers()
+ const state = createTerminalLivePendingFlushState()
+ const arrivals: { text: string; at: number }[] = []
+ const started = Date.now()
+ const sender = (_handle: string, text: string) => {
+ arrivals.push({ text, at: Date.now() - started })
+ return new Promise((resolve) => setTimeout(() => resolve(true), rtt))
+ }
+ const requests: Promise[] = []
+ for (const text of ['a', 'b', 'c', 'd']) {
+ requests.push(queueTerminalLiveMirrorSend(state, 'pty', text, sender, { pipeline: true }))
+ await vi.advanceTimersByTimeAsync(50)
+ }
+ expect(arrivals).toEqual([
+ { text: 'a', at: 0 },
+ { text: 'b', at: 50 },
+ { text: 'c', at: 100 },
+ { text: 'd', at: 150 }
+ ])
+ let completed = false
+ void waitForTerminalLivePendingFlush(state).then(() => {
+ completed = true
+ })
+ await vi.advanceTimersByTimeAsync(rtt - 51)
+ expect(completed).toBe(false)
+ await vi.advanceTimersByTimeAsync(1)
+ await expect(Promise.all(requests)).resolves.toEqual([true, true, true, true])
+ expect(completed).toBe(true)
+ }
+ )
+})
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 616a7ef0fa7..6ce7af7676a 100644
--- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts
+++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts
@@ -260,7 +260,7 @@ describe('terminal live input commit hook', () => {
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
- it('Given a failed mirrored Backspace When accessory input commits Then reports failure', async () => {
+ it('Given a failed mirrored prefix When accessory Backspace commits Then suppresses the dependent erase', async () => {
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
changeLiveInput(handlers, 'a', false)
await vi.waitFor(() => expect(sent).toEqual(['a']))
@@ -270,7 +270,7 @@ describe('terminal live input commit hook', () => {
localEdit: 'backspace'
})
- expect(sent).toEqual(['a', '\x7f'])
+ expect(sent).toEqual(['a'])
expect(result).toEqual({ kind: 'suppress-raw' })
})
diff --git a/mobile/src/terminal/use-terminal-live-mirror-failure.test.ts b/mobile/src/terminal/use-terminal-live-mirror-failure.test.ts
new file mode 100644
index 00000000000..00b057d34bb
--- /dev/null
+++ b/mobile/src/terminal/use-terminal-live-mirror-failure.test.ts
@@ -0,0 +1,213 @@
+import { createElement, type RefObject } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import type { TextInput } from 'react-native'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { useTerminalLivePendingInputFlush } from './use-terminal-live-pending-input-flush'
+import type { TerminalLiveInputSender } from './terminal-live-input-sender'
+
+function deferred() {
+ let resolve!: (sent: boolean) => void
+ const promise = new Promise((done) => {
+ resolve = done
+ })
+ return { promise, resolve }
+}
+
+const unmounts: (() => void)[] = []
+afterEach(() => {
+ unmounts.splice(0).forEach((unmount) => unmount())
+ vi.useRealTimers()
+})
+
+function harness(sender: TerminalLiveInputSender) {
+ const activeHandleRef = { current: 'terminal-a' as string | null }
+ const activeSessionTabTypeRef = { current: 'terminal' as string | null }
+ let capturedText = ''
+ let nativeText = ''
+ const setLiveInputCapture = vi.fn((text: string) => {
+ capturedText = text
+ })
+ const setNativeProps = vi.fn(({ text }: { text: string }) => {
+ nativeText = text
+ })
+ const liveInputRef = { current: { setNativeProps } as unknown as TextInput }
+ const sendLiveTerminalInputRef: RefObject = { current: sender }
+ let hook!: ReturnType>
+ let renderer!: ReactTestRenderer
+ function Harness() {
+ hook = useTerminalLivePendingInputFlush({
+ activeHandleRef,
+ activeSessionTabTypeRef,
+ liveInputRef,
+ liveInputTerminalHandlesRef: { current: new Set(['terminal-a', 'terminal-b']) },
+ sendLiveTerminalInputRef,
+ setLiveInputCapture
+ })
+ return null
+ }
+ act(() => {
+ renderer = create(createElement(Harness))
+ })
+ const unmount = () => {
+ act(() => renderer.unmount())
+ }
+ unmounts.push(unmount)
+ return {
+ hook,
+ activeHandleRef,
+ activeSessionTabTypeRef,
+ setNativeProps,
+ setLiveInputCapture,
+ text: () => ({ capturedText, nativeText }),
+ type: (text: string, composing?: boolean) => {
+ capturedText = text
+ nativeText = text
+ return hook.applyLiveInputMirror(activeHandleRef.current!, text, composing)
+ },
+ unmount
+ }
+}
+
+describe('failed terminal live mirrors', () => {
+ it.each([300, 400, 500])(
+ 'pipelines negotiated typing while retaining the %ims receipt barrier',
+ async (delay) => {
+ vi.useFakeTimers()
+ const writes: { text: string; at: number }[] = []
+ const start = Date.now()
+ const sender = Object.assign(
+ (_handle: string, text: string) => {
+ writes.push({ text, at: Date.now() - start })
+ return new Promise((resolve) => setTimeout(() => resolve(true), delay))
+ },
+ { supportsPipeline: () => true }
+ )
+ const h = harness(sender)
+ const sends = []
+ for (const text of ['a', 'ab', 'abc']) {
+ sends.push(h.type(text))
+ await vi.advanceTimersByTimeAsync(50)
+ }
+ expect(writes).toEqual([
+ { text: 'a', at: 0 },
+ { text: 'b', at: 50 },
+ { text: 'c', at: 100 }
+ ])
+ let finished = false
+ void h.hook.waitForPendingLiveInputFlush().then(() => {
+ finished = true
+ })
+ await vi.advanceTimersByTimeAsync(delay - 51)
+ expect(finished).toBe(false)
+ await vi.advanceTimersByTimeAsync(1)
+ await Promise.all(sends)
+ expect(finished).toBe(true)
+ }
+ )
+
+ it.each(['refused', 'throwing'] as const)(
+ 'clears native and JS text on a %s send and retains the failure latch',
+ async (kind) => {
+ const sender = vi.fn(async () => {
+ if (kind === 'throwing') {
+ throw new Error('connection lost')
+ }
+ return false
+ })
+ const h = harness(sender)
+ await expect(h.type('optimistic')).resolves.toBe(false)
+ expect(h.text()).toEqual({ capturedText: '', nativeText: '' })
+ expect(h.hook.sentLiveInputTextRef.current).toBe('')
+ expect(h.hook.heldLiveInputTextRef.current).toBe('')
+ expect(h.hook.pendingLiveInputHandleRef.current).toBe('terminal-a')
+ await expect(h.hook.flushPendingLiveInputText('terminal-a')).resolves.toBe(false)
+ await expect(h.hook.waitForPendingLiveInputFlush()).resolves.toBe(false)
+ await expect(h.type('dependent suffix')).resolves.toBe(false)
+ expect(sender).toHaveBeenCalledTimes(1)
+ }
+ )
+
+ it('clears a newer dependent composition when its mirrored prefix fails', async () => {
+ vi.useFakeTimers()
+ const receipt = deferred()
+ const sender = vi.fn(() => receipt.promise)
+ const h = harness(sender)
+ const first = h.type('x')
+ const preedit = h.type('x한', true)
+ receipt.resolve(false)
+ await Promise.all([first, preedit])
+ expect(h.text()).toEqual({ capturedText: '', nativeText: '' })
+ expect(h.hook.liveInputComposingRef.current).toBeUndefined()
+ await vi.advanceTimersByTimeAsync(1000)
+ expect(sender).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not clear another active terminal before its lifecycle reset runs', async () => {
+ const receipt = deferred()
+ const h = harness(() => receipt.promise)
+ const first = h.type('old')
+ h.activeHandleRef.current = 'terminal-b'
+ receipt.resolve(false)
+ await first
+ expect(h.setLiveInputCapture).not.toHaveBeenCalled()
+ expect(h.setNativeProps).not.toHaveBeenCalled()
+ })
+
+ it('a canceled failure and flush cannot clear a newer same-terminal interaction', async () => {
+ const old = deferred()
+ const fresh = deferred()
+ const h = harness((_handle, bytes) => (bytes === 'old' ? old.promise : fresh.promise))
+ const first = h.type('old')
+ const flush = h.hook.flushPendingLiveInputText('terminal-a')
+ h.hook.clearPendingLiveInputCommit()
+ const next = h.type('new')
+ await expect(first).resolves.toBe(false)
+ await expect(flush).resolves.toBe(false)
+ expect(h.text()).toEqual({ capturedText: 'new', nativeText: 'new' })
+ old.resolve(false)
+ fresh.resolve(true)
+ await next
+ expect(h.text()).toEqual({ capturedText: 'new', nativeText: 'new' })
+ })
+
+ it('an older successful flush cannot clear text typed while it waited', async () => {
+ const firstReceipt = deferred()
+ const h = harness(async (_handle, text) => (text === 'a' ? firstReceipt.promise : true))
+ const first = h.type('a')
+ const flush = h.hook.flushPendingLiveInputText('terminal-a')
+ const newer = h.type('ab')
+ firstReceipt.resolve(true)
+ await Promise.all([first, flush, newer])
+ expect(h.text()).toEqual({ capturedText: 'ab', nativeText: 'ab' })
+ })
+
+ it('unmount cancellation cannot update the native or JS field after settlement', async () => {
+ const receipt = deferred()
+ const h = harness(() => receipt.promise)
+ const pending = h.type('pending')
+ h.unmount()
+ unmounts.pop()
+ receipt.resolve(false)
+ await pending
+ expect(h.setLiveInputCapture).not.toHaveBeenCalled()
+ expect(h.setNativeProps).not.toHaveBeenCalled()
+ })
+
+ it('cancels transport input only for an unsettled mirror, before releasing its queue', async () => {
+ const receipt = deferred()
+ const cancelPending = vi.fn()
+ const sender = Object.assign(() => receipt.promise, { cancelPending })
+ const h = harness(sender)
+ h.hook.clearPendingLiveInputCommit()
+ expect(cancelPending).not.toHaveBeenCalled()
+ const first = h.type('pending')
+ h.hook.clearPendingLiveInputCommit()
+ expect(cancelPending).toHaveBeenCalledExactlyOnceWith('terminal-a')
+ await expect(first).resolves.toBe(false)
+ receipt.resolve(true)
+ const second = h.type('accepted')
+ await second
+ await h.hook.flushPendingLiveInputText('terminal-a')
+ expect(cancelPending).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts
index ff0c09c1c51..b9bf50b430f 100644
--- a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts
+++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts
@@ -58,6 +58,7 @@ export function useTerminalLivePendingInputFlush({
const liveInputComposingRef = useRef(undefined)
const sentLiveInputTextRef = useRef('')
const pendingLiveInputHandleRef = useRef(null)
+ const mirrorRevisionRef = useRef(0)
const runMirrorStepRef = useRef(async () => false)
const clearHeldCommitTimer = useCallback(() => {
@@ -67,20 +68,52 @@ export function useTerminalLivePendingInputFlush({
}
}, [])
- const resetMirrorState = useCallback(() => {
+ const clearMirrorState = useCallback(() => {
+ mirrorRevisionRef.current += 1
clearHeldCommitTimer()
- cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
heldLiveInputTextRef.current = ''
liveInputComposingRef.current = undefined
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
}, [clearHeldCommitTimer])
- const clearPendingLiveInputCommit = useCallback(() => {
- resetMirrorState()
+ const cancelPendingMirror = useCallback(() => {
+ const state = pendingLiveInputFlushRef.current
+ if (state.current && state.requestCount > 0) {
+ const handles = new Set(
+ [...state.activeBatches, ...state.pendingBatches].map((batch) => batch.handle)
+ )
+ for (const handle of handles) {
+ sendLiveTerminalInputRef.current.cancelPending?.(handle)
+ }
+ }
+ cancelTerminalLivePendingFlush(state)
+ }, [sendLiveTerminalInputRef])
+
+ const resetMirrorState = useCallback(() => {
+ cancelPendingMirror()
+ clearMirrorState()
+ }, [cancelPendingMirror, clearMirrorState])
+
+ const clearMirrorCapture = useCallback(() => {
+ clearMirrorState()
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
- }, [liveInputRef, resetMirrorState, setLiveInputCapture])
+ }, [clearMirrorState, liveInputRef, setLiveInputCapture])
+
+ const clearPendingLiveInputCommit = useCallback(() => {
+ cancelPendingMirror()
+ clearMirrorCapture()
+ }, [cancelPendingMirror, clearMirrorCapture])
+
+ const clearRejectedMirror = useCallback(
+ (handle: string) => {
+ clearMirrorCapture()
+ // Keep the failed lane owned so terminal switches can cancel it.
+ pendingLiveInputHandleRef.current = handle
+ },
+ [clearMirrorCapture]
+ )
const waitForPendingLiveInputFlush = useCallback(async (): Promise => {
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
@@ -111,6 +144,8 @@ export function useTerminalLivePendingInputFlush({
commitHeld,
composing
})
+ const revision = ++mirrorRevisionRef.current
+ const generation = pendingLiveInputFlushRef.current.generation
sentLiveInputTextRef.current = step.nextSentText
heldLiveInputTextRef.current = step.heldText
liveInputComposingRef.current = composing
@@ -130,23 +165,37 @@ export function useTerminalLivePendingInputFlush({
}
const payload = buildTerminalLiveMirrorPayload(step)
- if (payload.length === 0) {
- return waitForPendingLiveInputFlush()
+ const sent = await (payload.length === 0
+ ? waitForPendingLiveInputFlush()
+ : queueTerminalLiveMirrorSend(
+ pendingLiveInputFlushRef.current,
+ handle,
+ payload,
+ sendQueuedMirrorPayload,
+ { pipeline: sendLiveTerminalInputRef.current.supportsPipeline?.(handle) === true }
+ ))
+ if (
+ !sent &&
+ generation === pendingLiveInputFlushRef.current.generation &&
+ revision === mirrorRevisionRef.current &&
+ activeHandleRef.current === handle &&
+ (activeSessionTabTypeRef.current == null ||
+ activeSessionTabTypeRef.current === 'terminal') &&
+ liveInputTerminalHandlesRef.current.has(handle)
+ ) {
+ clearRejectedMirror(handle)
}
- return queueTerminalLiveMirrorSend(
- pendingLiveInputFlushRef.current,
- handle,
- payload,
- sendQueuedMirrorPayload
- )
+ return sent
},
[
activeHandleRef,
activeSessionTabTypeRef,
clearHeldCommitTimer,
+ clearRejectedMirror,
liveInputTerminalHandlesRef,
resetMirrorState,
sendQueuedMirrorPayload,
+ sendLiveTerminalInputRef,
waitForPendingLiveInputFlush
]
)
@@ -174,32 +223,46 @@ export function useTerminalLivePendingInputFlush({
}
const heldText = heldLiveInputTextRef.current
- const result =
+ const flush =
heldText.length > 0
- ? await runMirrorStep(handle, sentLiveInputTextRef.current + heldText, true)
- : await waitForPendingLiveInputFlush()
+ ? runMirrorStep(handle, sentLiveInputTextRef.current + heldText, true)
+ : waitForPendingLiveInputFlush()
+ const generation = pendingLiveInputFlushRef.current.generation
+ const revision = mirrorRevisionRef.current
+ const result = await flush
// Why: an explicit flush ends the field's editing session; the echoed PTY
// text stays, so local mirror state must restart from empty.
- clearPendingLiveInputCommit()
+ if (
+ generation === pendingLiveInputFlushRef.current.generation &&
+ revision === mirrorRevisionRef.current &&
+ activeHandleRef.current === handle &&
+ (activeSessionTabTypeRef.current == null ||
+ activeSessionTabTypeRef.current === 'terminal') &&
+ liveInputTerminalHandlesRef.current.has(handle)
+ ) {
+ if (result) {
+ clearPendingLiveInputCommit()
+ } else {
+ clearRejectedMirror(handle)
+ }
+ }
return result
},
- [clearPendingLiveInputCommit, runMirrorStep, waitForPendingLiveInputFlush]
+ [
+ activeHandleRef,
+ activeSessionTabTypeRef,
+ liveInputTerminalHandlesRef,
+ clearPendingLiveInputCommit,
+ clearRejectedMirror,
+ runMirrorStep,
+ waitForPendingLiveInputFlush
+ ]
)
useEffect(() => {
- return () => {
- if (heldCommitTimerRef.current) {
- clearTimeout(heldCommitTimerRef.current)
- heldCommitTimerRef.current = null
- }
- heldLiveInputTextRef.current = ''
- liveInputComposingRef.current = undefined
- sentLiveInputTextRef.current = ''
- pendingLiveInputHandleRef.current = null
- cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
- }
- }, [])
+ return resetMirrorState
+ }, [resetMirrorState])
return {
applyLiveInputMirror,
diff --git a/mobile/src/transport/direct-rpc-client.ts b/mobile/src/transport/direct-rpc-client.ts
index 16306f95cdd..9ecf9db2c06 100644
--- a/mobile/src/transport/direct-rpc-client.ts
+++ b/mobile/src/transport/direct-rpc-client.ts
@@ -1,5 +1,6 @@
import type { ConnectOptions, RpcClient, SendRequestOptions } from './rpc-client'
import { DirectConnectionLog } from './direct-connection-log'
+import { assertTerminalInputRequestAllowed } from './terminal-input-request-fence'
import { RpcClientAuthenticationRetry } from './rpc-client-authentication-retry'
import { RpcClientConnectionState } from './rpc-client-connection-state'
import {
@@ -19,6 +20,7 @@ import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
import { isStaleForegroundDial } from './rpc-stale-dial'
import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from './types'
import { negotiateMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation'
+import { sendSessionEncrypted } from './rpc-client-socket-send'
const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-'
@@ -61,9 +63,12 @@ export class DirectRpcClient implements RpcClient {
nextId: () => this.nextId(),
deviceToken,
getState: () => this.connectionState.get(),
- sendEncrypted: (request) => this.sendEncrypted(request)
+ sendEncrypted: (request) => this.sendEncrypted(request),
+ sendBinary: (bytes) => this.socketSession?.sendBinary(bytes) ?? false
})
this.requests = new RpcClientRequestTracker({
+ validateRequest: (method, params) =>
+ assertTerminalInputRequestAllowed(method, params, this.getTerminalStreamInputFailure),
nextId: () => this.nextId(),
deviceToken,
getState: () => this.connectionState.get(),
@@ -137,6 +142,17 @@ export class DirectRpcClient implements RpcClient {
return this.requests.sendRequest(method, params, options)
}
+ sendTerminalStreamInput = (terminal: string, text: string): Promise | null =>
+ this.streams.sendTerminalStreamInput(terminal, text)
+
+ supportsTerminalStreamInput = (terminal: string): boolean =>
+ this.streams.supportsTerminalStreamInput(terminal)
+ getTerminalStreamInputFailure = (terminal: string) =>
+ this.streams.getTerminalStreamInputFailure(terminal)
+ recoverTerminalStreamInput = (terminal: string) =>
+ this.streams.recoverTerminalStreamInput(terminal)
+ cancelTerminalStreamInput = (terminal: string) => this.streams.cancelTerminalStreamInput(terminal)
+
subscribe(
method: string,
params: unknown,
@@ -153,23 +169,14 @@ export class DirectRpcClient implements RpcClient {
this.streams.updateTerminalViewport(terminal, viewport)
}
- getState(): ConnectionState {
- return this.connectionState.get()
- }
-
- getReconnectAttempt(): number {
- return this.reconnect.getAttempt()
- }
-
- getLastConnectedAt(): number | null {
- return this.connectionState.getLastConnectedAt()
- }
+ getState = (): ConnectionState => this.connectionState.get()
+ getReconnectAttempt = (): number => this.reconnect.getAttempt()
+ getLastConnectedAt = (): number | null => this.connectionState.getLastConnectedAt()
getLastInboundAt = (): number | null => this.liveness.getLastInboundAt() || null
- onStateChange(listener: (state: ConnectionState) => void): () => void {
- return this.connectionState.addListener(listener)
- }
+ onStateChange = (listener: (state: ConnectionState) => void): (() => void) =>
+ this.connectionState.addListener(listener)
notifyForeground(_reason?: ForegroundNudgeReason): void {
if (this.intentionallyClosed) {
@@ -204,6 +211,7 @@ export class DirectRpcClient implements RpcClient {
close(): void {
this.intentionallyClosed = true
+ this.streams.markForReplay()
this.reconnect.cancel()
const session = this.socketSession
session?.clearTimers()
@@ -285,15 +293,7 @@ export class DirectRpcClient implements RpcClient {
}
private sendEncrypted(request: unknown): boolean {
- if (this.socketSession) {
- return this.socketSession.sendEncrypted(request)
- }
- console.log('[net] sendEncrypted FAILED — channel not ready', {
- hasWs: false,
- hasKey: false,
- state: this.getState()
- })
- return false
+ return sendSessionEncrypted(this.socketSession, request, this.getState())
}
private sendLivenessProbe(identity: object): boolean {
diff --git a/mobile/src/transport/e2ee.ts b/mobile/src/transport/e2ee.ts
index 2732b3d615f..a6dbf6f346f 100644
--- a/mobile/src/transport/e2ee.ts
+++ b/mobile/src/transport/e2ee.ts
@@ -73,7 +73,7 @@ export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null
return plaintext ? new TextDecoder().decode(plaintext) : null
}
-function encryptBytes(plaintext: Uint8Array, sharedKey: Uint8Array): Uint8Array {
+export function encryptBytes(plaintext: Uint8Array, sharedKey: Uint8Array): Uint8Array {
const nonce = u8(nacl.randomBytes(nacl.box.nonceLength))
const ciphertext = nacl.box.after(u8(plaintext), nonce, u8(sharedKey))
diff --git a/mobile/src/transport/logical-client-cutover.ts b/mobile/src/transport/logical-client-cutover.ts
new file mode 100644
index 00000000000..5b65652b831
--- /dev/null
+++ b/mobile/src/transport/logical-client-cutover.ts
@@ -0,0 +1,13 @@
+export class LogicalClientCutoverError extends Error {
+ constructor() {
+ super('RPC interrupted by connection migration')
+ }
+}
+export type PendingLogicalRequest = { reject: (error: Error) => void }
+
+export function isLogicalClientCutoverError(error: unknown): boolean {
+ return (
+ error instanceof LogicalClientCutoverError ||
+ (error instanceof Error && error.message === 'RPC interrupted by connection migration')
+ )
+}
diff --git a/mobile/src/transport/logical-terminal-stream-input.test.ts b/mobile/src/transport/logical-terminal-stream-input.test.ts
new file mode 100644
index 00000000000..8e01a2535a5
--- /dev/null
+++ b/mobile/src/transport/logical-terminal-stream-input.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it, vi } from 'vitest'
+import { LogicalTerminalStreamInput } from './logical-terminal-stream-input'
+import type { RpcClient } from './rpc-client'
+
+describe('logical terminal input cutover', () => {
+ it('keeps a pending old-stream prefix from falling back on a replacement old host', async () => {
+ let resolve = (_accepted: boolean) => {}
+ const original = new Promise((r) => {
+ resolve = r
+ })
+ let generation = 1
+ let session = {
+ supportsTerminalStreamInput: () => true,
+ sendTerminalStreamInput: vi.fn(() => original)
+ } as unknown as RpcClient
+ const input = new LogicalTerminalStreamInput(() => ({ generation, session, available: true }))
+ const prefix = input.send('t', 'prefix')!
+ generation = 2
+ session = {} as RpcClient
+ expect(input.supports('t')).toBe(true)
+ expect(await input.send('t', '\r')).toBe(false)
+ resolve(true)
+ expect(await prefix).toBe(false)
+ expect(await input.send('t', '\r')).toBe(false)
+ session = {
+ supportsTerminalStreamInput: () => true,
+ recoverTerminalStreamInput: () => true,
+ sendTerminalStreamInput: vi.fn(() => Promise.resolve(true))
+ } as unknown as RpcClient
+ expect(await input.send('t', 'blocked')).toBe(false)
+ expect(input.recover('t')).toBe(true)
+ expect(await input.send('t', 'fresh')).toBe(true)
+ })
+})
diff --git a/mobile/src/transport/logical-terminal-stream-input.ts b/mobile/src/transport/logical-terminal-stream-input.ts
new file mode 100644
index 00000000000..ed9a38e6ad7
--- /dev/null
+++ b/mobile/src/transport/logical-terminal-stream-input.ts
@@ -0,0 +1,85 @@
+import type { RpcClient } from './rpc-client'
+import type { TerminalStreamInputFailure } from './terminal-stream-input-failure'
+
+type InputAttempt = {
+ generation: number
+ pending: number
+ failure: TerminalStreamInputFailure | null
+}
+type InputContext = { generation: number; session: RpcClient; available: boolean }
+const interrupted: TerminalStreamInputFailure = {
+ outcome: 'unknown',
+ reason: 'connection_interrupted'
+}
+
+export class LogicalTerminalStreamInput {
+ private readonly attempts = new Map()
+ constructor(private readonly context: () => InputContext) {}
+
+ supports = (terminal: string): boolean => {
+ const { session } = this.context()
+ return this.attempts.has(terminal) || (session.supportsTerminalStreamInput?.(terminal) ?? false)
+ }
+
+ failure = (terminal: string): TerminalStreamInputFailure | null => {
+ const current = this.context()
+ const attempt = this.attempts.get(terminal)
+ if (attempt && (!current.available || attempt.generation !== current.generation)) {
+ attempt.failure ??= interrupted
+ }
+ return attempt?.failure ?? current.session.getTerminalStreamInputFailure?.(terminal) ?? null
+ }
+
+ recover = (terminal: string): boolean => {
+ const current = this.context()
+ if (!current.available || !current.session.recoverTerminalStreamInput?.(terminal)) {
+ return false
+ }
+ this.attempts.delete(terminal)
+ return true
+ }
+
+ cancel = (terminal: string): void => {
+ const attempt = this.attempts.get(terminal)
+ if (attempt?.pending) {
+ attempt.failure ??= { outcome: 'unknown', reason: 'cancelled' }
+ }
+ this.context().session.cancelTerminalStreamInput?.(terminal)
+ }
+
+ send = (terminal: string, text: string): Promise | null => {
+ const context = this.context()
+ if (!context.available || this.failure(terminal)) {
+ return Promise.resolve(false)
+ }
+ const previous = this.attempts.get(terminal)
+ const result = context.session.sendTerminalStreamInput?.(terminal, text) ?? null
+ if (!result) {
+ return previous ? Promise.resolve(false) : null
+ }
+ const attempt = previous ?? { generation: context.generation, pending: 0, failure: null }
+ attempt.pending += 1
+ this.attempts.set(terminal, attempt)
+ return result.then(
+ (accepted) => this.settle(terminal, attempt, accepted),
+ () => this.settle(terminal, attempt, false)
+ )
+ }
+
+ private settle(terminal: string, attempt: InputAttempt, accepted: boolean): boolean {
+ const current = this.context()
+ const valid =
+ accepted && current.available && current.generation === attempt.generation && !attempt.failure
+ attempt.pending -= 1
+ if (!valid) {
+ attempt.failure ??=
+ current.generation === attempt.generation
+ ? (current.session.getTerminalStreamInputFailure?.(terminal) ?? interrupted)
+ : interrupted
+ }
+ if (this.attempts.get(terminal) === attempt && attempt.pending === 0 && !attempt.failure) {
+ this.attempts.delete(terminal)
+ }
+ return valid
+ }
+}
diff --git a/mobile/src/transport/mobile-relay-rpc-session.test.ts b/mobile/src/transport/mobile-relay-rpc-session.test.ts
index 5887dffc73d..7ebcd09d3ef 100644
--- a/mobile/src/transport/mobile-relay-rpc-session.test.ts
+++ b/mobile/src/transport/mobile-relay-rpc-session.test.ts
@@ -5,6 +5,7 @@ import {
} from '../../../src/shared/browser-screencast-protocol'
import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
import { isRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
+import { TERMINAL_ORDERED_INPUT_CAPABILITY } from '../../../src/shared/terminal-ordered-input'
const fakes = vi.hoisted(() => ({
linkOptions: null as null | {
@@ -19,6 +20,7 @@ const fakes = vi.hoisted(() => ({
onError(error: Error): void
},
sendText: vi.fn(() => true),
+ sendBinary: vi.fn(() => true),
close: vi.fn()
}))
@@ -28,6 +30,7 @@ vi.mock('./mobile-relay-e2ee-link', () => ({
fakes.linkOptions = options
}
sendText = fakes.sendText
+ sendBinary = fakes.sendBinary
close = fakes.close
}
}))
@@ -133,9 +136,38 @@ describe('mobile relay RPC session', () => {
vi.clearAllMocks()
fakes.linkOptions = null
fakes.sendText.mockReturnValue(true)
+ fakes.sendBinary.mockReturnValue(true)
})
afterEach(() => vi.useRealTimers())
+ it('rechecks failed input after the connected await before sending JSON controls', async () => {
+ const { session } = await authenticateSession()
+ const dispose = session.subscribe('terminal.subscribe', { terminal: 't' }, vi.fn())
+ await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
+ const request = JSON.parse(fakes.sendText.mock.calls[0]![0] as string)
+ fakes.linkOptions!.onText(
+ JSON.stringify({
+ id: request.id,
+ ok: true,
+ streaming: true,
+ result: {
+ type: 'subscribed',
+ streamId: 7,
+ capabilities: { orderedInput: TERMINAL_ORDERED_INPUT_CAPABILITY }
+ },
+ _meta: { runtimeId: 'runtime-1' }
+ })
+ )
+ fakes.sendText.mockClear()
+ const enter = session.sendRequest('terminal.send', { terminal: 't', text: '\r' })
+ fakes.sendBinary.mockReturnValue(false)
+ expect(await session.sendTerminalStreamInput?.('t', 'prefix')).toBe(false)
+ await expect(enter).rejects.toThrow('Terminal input stopped')
+ expect(fakes.sendText).not.toHaveBeenCalled()
+ dispose()
+ session.close()
+ })
+
it('requires exact resume observations and confirms by request ID before becoming connected', async () => {
const { session, confirmationRequest, capabilityRequest } = await authenticateSession()
diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts
index 203a0329192..9ae0924dd50 100644
--- a/mobile/src/transport/mobile-relay-rpc-session.ts
+++ b/mobile/src/transport/mobile-relay-rpc-session.ts
@@ -1,3 +1,4 @@
+import { assertTerminalInputRequestAllowed } from './terminal-input-request-fence'
import {
PairingGetEndpointsResultSchema,
type DeviceResumeConfirmed,
@@ -61,6 +62,7 @@ export function connectMobileRelayRpcSession(args: {
const streams = new MobileRelayRpcStreams({
nextId: () => pending.nextId(),
sendFrame,
+ sendBinary: (bytes) => !closed && state === 'connected' && link.sendBinary(bytes),
waitForConnected: () => waitForConnected()
})
@@ -99,9 +101,16 @@ export function connectMobileRelayRpcSession(args: {
})
const client: MobileRelayRpcSession = {
+ supportsTerminalStreamInput: (terminal) => streams.supportsTerminalStreamInput(terminal),
+ getTerminalStreamInputFailure: (terminal) => streams.getTerminalStreamInputFailure(terminal),
+ recoverTerminalStreamInput: (terminal) => streams.recoverTerminalStreamInput(terminal),
+ cancelTerminalStreamInput: (terminal) => streams.cancelTerminalStreamInput(terminal),
+ sendTerminalStreamInput: (terminal, text) => streams.sendTerminalStreamInput(terminal, text),
async sendRequest(method, params, options) {
+ assertTerminalInputRequestAllowed(method, params, streams.getTerminalStreamInputFailure)
const budget = openRpcRequestBudget(options)
await waitForConnected(budget.timeoutMs)
+ assertTerminalInputRequestAllowed(method, params, streams.getTerminalStreamInputFailure)
return sendRpc(method, params, resolvePostConnectRequestTimeout(budget, requestTimeoutMs))
},
diff --git a/mobile/src/transport/mobile-relay-rpc-streams.ts b/mobile/src/transport/mobile-relay-rpc-streams.ts
index 2eacbd2168f..bd336bb835d 100644
--- a/mobile/src/transport/mobile-relay-rpc-streams.ts
+++ b/mobile/src/transport/mobile-relay-rpc-streams.ts
@@ -9,6 +9,7 @@ import {
} from './rpc-client-terminal-subscription'
import type { RpcClient } from './rpc-client'
import type { RpcResponse, RpcSuccess } from './types'
+import { TerminalOrderedInput, advertiseTerminalOrderedInput } from './terminal-ordered-input'
type StreamRecord = {
method: string
@@ -28,6 +29,7 @@ type StreamManagerOptions = {
nextId: () => string
sendFrame: (request: { id: string; method: string; params?: unknown }) => boolean
waitForConnected: () => Promise
+ sendBinary?: (bytes: Uint8Array) => boolean
}
export class MobileRelayRpcStreams {
@@ -35,8 +37,26 @@ export class MobileRelayRpcStreams {
private readonly terminalListeners = new Map void>()
private readonly terminalSnapshots = new Map()
private activeBrowserStream: StreamRecord | null = null
+ private readonly orderedInput: TerminalOrderedInput
- constructor(private readonly options: StreamManagerOptions) {}
+ constructor(private readonly options: StreamManagerOptions) {
+ this.orderedInput = new TerminalOrderedInput((bytes) => options.sendBinary?.(bytes) ?? false)
+ }
+
+ sendTerminalStreamInput(terminal: string, text: string): Promise | null {
+ return this.orderedInput.send(terminal, text)
+ }
+ getTerminalStreamInputFailure = (terminal: string) => this.orderedInput.failure(terminal)
+ recoverTerminalStreamInput = (terminal: string) => this.orderedInput.recover(terminal)
+ cancelTerminalStreamInput(terminal: string): void {
+ for (const id of this.orderedInput.cancel(terminal)) {
+ this.cancel(id)
+ }
+ }
+
+ supportsTerminalStreamInput(terminal: string): boolean {
+ return this.orderedInput.supports(terminal)
+ }
subscribe(
method: string,
@@ -47,7 +67,7 @@ export class MobileRelayRpcStreams {
const id = this.options.nextId()
const stream: StreamRecord = {
method,
- params,
+ params: advertiseTerminalOrderedInput(method, params),
listener,
onBinaryFrame: subscribeOptions?.onBinaryFrame,
streamIds: new Set(),
@@ -90,8 +110,13 @@ export class MobileRelayRpcStreams {
stream.subscriptionId = metadata.subscriptionId
}
if (typeof metadata.streamId === 'number') {
+ const streamId = metadata.streamId
+ this.orderedInput.register(response.id, stream.params, result)
stream.streamIds.add(metadata.streamId)
- this.terminalListeners.set(metadata.streamId, stream.listener)
+ this.terminalListeners.set(metadata.streamId, (event) => {
+ this.orderedInput.handle(streamId, event)
+ stream.listener(event)
+ })
}
if (stream.method === 'browser.screencast') {
this.activeBrowserStream = stream
@@ -121,6 +146,7 @@ export class MobileRelayRpcStreams {
}
clear(): void {
+ this.orderedInput.clear()
for (const stream of this.streams.values()) {
stream.cancelled = true
}
@@ -156,6 +182,7 @@ export class MobileRelayRpcStreams {
}
private remove(id: string): void {
+ this.orderedInput.reset(id)
const stream = this.streams.get(id)
if (!stream) {
return
diff --git a/mobile/src/transport/rpc-client-request-tracker.ts b/mobile/src/transport/rpc-client-request-tracker.ts
index d96d1e97609..b679d2bc2d6 100644
--- a/mobile/src/transport/rpc-client-request-tracker.ts
+++ b/mobile/src/transport/rpc-client-request-tracker.ts
@@ -16,6 +16,7 @@ type RequestTrackerOptions = {
waitForConnected: (timeoutMs?: number) => Promise
sendEncrypted: (request: unknown) => boolean
deviceToken: string
+ validateRequest?: (method: string, params: unknown) => void
}
export class RpcClientRequestTracker {
@@ -63,6 +64,7 @@ export class RpcClientRequestTracker {
timeoutMs: number
): Promise {
return new Promise((resolve, reject) => {
+ this.options.validateRequest?.(method, params)
const id = this.options.nextId()
const timeout = setTimeout(() => {
this.pending.delete(id)
diff --git a/mobile/src/transport/rpc-client-socket-options.ts b/mobile/src/transport/rpc-client-socket-options.ts
new file mode 100644
index 00000000000..abb7ad48997
--- /dev/null
+++ b/mobile/src/transport/rpc-client-socket-options.ts
@@ -0,0 +1,21 @@
+import type { RpcClientSocketSession } from './rpc-client-socket-session'
+import type { ConnectionLogEmitter, ConnectionState, RpcResponse } from './types'
+export type SocketSessionOptions = {
+ endpoint: string
+ deviceToken: string
+ serverPublicKey: Uint8Array
+ getCurrentSocket: () => WebSocket | null
+ getState: () => ConnectionState
+ getReconnectAttempt: () => number
+ isIntentionallyClosed: () => boolean
+ emitLog: ConnectionLogEmitter
+ onHandshakeStarted: () => void
+ onAuthenticated: (session: RpcClientSocketSession) => void
+ onAuthRejected: (reason: string) => void
+ onRpcResponse: (response: RpcResponse) => void
+ onBinary: (bytes: Uint8Array) => void
+ onAnyInbound: (receivedAt: number) => void
+ onAuthenticatedInbound: (session: RpcClientSocketSession) => void
+ onClosed: (session: RpcClientSocketSession, closeCode?: number) => void
+ onForcedClose: (session: RpcClientSocketSession) => void
+}
diff --git a/mobile/src/transport/rpc-client-socket-send.ts b/mobile/src/transport/rpc-client-socket-send.ts
new file mode 100644
index 00000000000..6cedf810440
--- /dev/null
+++ b/mobile/src/transport/rpc-client-socket-send.ts
@@ -0,0 +1,38 @@
+import { encryptBytes } from './e2ee'
+import type { RpcClientSocketSession } from './rpc-client-socket-session'
+import type { ConnectionState } from './types'
+
+export function sendSessionEncrypted(
+ session: RpcClientSocketSession | null,
+ request: unknown,
+ state: ConnectionState
+): boolean {
+ if (session) {
+ return session.sendEncrypted(request)
+ }
+ console.log('[net] sendEncrypted FAILED — channel not ready', {
+ hasWs: false,
+ hasKey: false,
+ state
+ })
+ return false
+}
+
+export function sendSocketEncryptedBinary(
+ socket: WebSocket,
+ key: Uint8Array | null,
+ ready: boolean,
+ bytes: Uint8Array,
+ onFailure: () => void
+): boolean {
+ if (!ready || !key || socket.readyState !== WebSocket.OPEN) {
+ return false
+ }
+ try {
+ socket.send(encryptBytes(bytes, key))
+ return true
+ } catch {
+ onFailure()
+ return false
+ }
+}
diff --git a/mobile/src/transport/rpc-client-socket-session.ts b/mobile/src/transport/rpc-client-socket-session.ts
index bc8f9d13e2c..5f6cc1661e2 100644
--- a/mobile/src/transport/rpc-client-socket-session.ts
+++ b/mobile/src/transport/rpc-client-socket-session.ts
@@ -9,33 +9,14 @@ import {
import { isRpcResponse } from './rpc-response-shape'
import { isStaleRpcSocketEvent, logRpcSocketClose } from './rpc-socket-close-evidence'
import { describeSocketEvent, redactSocketEndpoint } from './socket-event-debug'
-import type { ConnectionLogEmitter, ConnectionState, RpcResponse } from './types'
+import type { SocketSessionOptions } from './rpc-client-socket-options'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
+import { sendSocketEncryptedBinary } from './rpc-client-socket-send'
const CONNECT_TIMEOUT_MS = 12_000
const HANDSHAKE_TIMEOUT_MS = 5_000
const WEBSOCKET_CONNECTING_STATE = 0
-type SocketSessionOptions = {
- endpoint: string
- deviceToken: string
- serverPublicKey: Uint8Array
- getCurrentSocket: () => WebSocket | null
- getState: () => ConnectionState
- getReconnectAttempt: () => number
- isIntentionallyClosed: () => boolean
- emitLog: ConnectionLogEmitter
- onHandshakeStarted: () => void
- onAuthenticated: (session: RpcClientSocketSession) => void
- onAuthRejected: (reason: string) => void
- onRpcResponse: (response: RpcResponse) => void
- onBinary: (bytes: Uint8Array) => void
- onAnyInbound: (receivedAt: number) => void
- onAuthenticatedInbound: (session: RpcClientSocketSession) => void
- onClosed: (session: RpcClientSocketSession, closeCode?: number) => void
- onForcedClose: (session: RpcClientSocketSession) => void
-}
-
export class RpcClientSocketSession {
readonly socket: WebSocket
readonly constructedAt = Date.now()
@@ -86,6 +67,16 @@ export class RpcClientSocketSession {
this.socket.close()
}
+ sendBinary(bytes: Uint8Array): boolean {
+ return sendSocketEncryptedBinary(
+ this.socket,
+ this.sharedKey,
+ this.authenticated && this.options.getCurrentSocket() === this.socket,
+ bytes,
+ () => this.options.onForcedClose(this)
+ )
+ }
+
clearTimers(): void {
if (this.connectTimer) {
clearTimeout(this.connectTimer)
diff --git a/mobile/src/transport/rpc-client-stream-options.ts b/mobile/src/transport/rpc-client-stream-options.ts
new file mode 100644
index 00000000000..9436715b097
--- /dev/null
+++ b/mobile/src/transport/rpc-client-stream-options.ts
@@ -0,0 +1,9 @@
+import type { ConnectionState } from './types'
+
+export type StreamRegistryOptions = {
+ nextId: () => string
+ deviceToken: string
+ getState: () => ConnectionState
+ sendEncrypted: (request: unknown) => boolean
+ sendBinary?: (bytes: Uint8Array) => boolean
+}
diff --git a/mobile/src/transport/rpc-client-stream-registry.ts b/mobile/src/transport/rpc-client-stream-registry.ts
index ac20acdecc0..a70a64f2d14 100644
--- a/mobile/src/transport/rpc-client-stream-registry.ts
+++ b/mobile/src/transport/rpc-client-stream-registry.ts
@@ -13,7 +13,9 @@ import {
isTerminalSubscribedResult
} from './rpc-subscription-result-shapes'
import { RpcClientTerminalStreamRouter } from './rpc-client-terminal-stream-router'
-import type { ConnectionState, RpcResponse, RpcSuccess } from './types'
+import type { RpcResponse, RpcSuccess } from './types'
+import type { StreamRegistryOptions } from './rpc-client-stream-options'
+import { TerminalOrderedInput, advertiseTerminalOrderedInput } from './terminal-ordered-input'
export type RpcStreamingListener = (result: unknown) => void
@@ -31,20 +33,24 @@ type StreamRequest = {
sent?: boolean
}
-type StreamRegistryOptions = {
- nextId: () => string
- deviceToken: string
- getState: () => ConnectionState
- sendEncrypted: (request: unknown) => boolean
-}
-
export class RpcClientStreamRegistry {
private readonly streams = new Map()
private readonly terminalRouter = new RpcClientTerminalStreamRouter()
private activeBrowserRequestId: string | null = null
private pendingBrowserRequestId: string | null = null
-
+ private readonly orderedInput = new TerminalOrderedInput(
+ (bytes) => this.options.sendBinary?.(bytes) ?? false
+ )
constructor(private readonly options: StreamRegistryOptions) {}
+ readonly sendTerminalStreamInput = this.orderedInput.send.bind(this.orderedInput)
+ readonly supportsTerminalStreamInput = this.orderedInput.supports.bind(this.orderedInput)
+ readonly getTerminalStreamInputFailure = this.orderedInput.failure.bind(this.orderedInput)
+ readonly recoverTerminalStreamInput = this.orderedInput.recover.bind(this.orderedInput)
+ cancelTerminalStreamInput(terminal: string): void {
+ for (const id of this.orderedInput.cancel(terminal)) {
+ this.dispose(id)
+ }
+ }
subscribe(
method: string,
@@ -55,7 +61,7 @@ export class RpcClientStreamRegistry {
const id = this.options.nextId()
const stream: StreamRequest = {
method,
- params,
+ params: advertiseTerminalOrderedInput(method, params),
listener,
onBinaryFrame: subscribeOptions?.onBinaryFrame
}
@@ -156,9 +162,7 @@ export class RpcClientStreamRegistry {
updateTerminalSubscriptionViewport(this.streams.values(), terminal, viewport)
}
- size(): number {
- return this.streams.size
- }
+ readonly size = (): number => this.streams.size
private handleStreamingResponse(response: RpcSuccess): void {
const stream = this.streams.get(response.id)
@@ -187,7 +191,11 @@ export class RpcClientStreamRegistry {
}
}
if (isTerminalSubscribedResult(result)) {
- this.terminalRouter.register(response.id, result.streamId, stream.listener)
+ this.orderedInput.register(response.id, stream.params, result)
+ this.terminalRouter.register(response.id, result.streamId, (event) => {
+ this.orderedInput.handle(result.streamId, event)
+ stream.listener(event)
+ })
}
if (!stream.cancelled) {
stream.listener(result)
@@ -293,6 +301,7 @@ export class RpcClientStreamRegistry {
}
private resetTerminalRouting(id: string): void {
+ this.orderedInput.reset(id)
this.terminalRouter.reset(id)
}
diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts
index 10f586d2780..7a7c8a58481 100644
--- a/mobile/src/transport/rpc-client.ts
+++ b/mobile/src/transport/rpc-client.ts
@@ -22,6 +22,14 @@ type SubscribeOptions = {
type StreamingListener = (result: unknown) => void
export type RpcClient = {
+ supportsTerminalStreamInput?: (terminal: string) => boolean
+ getTerminalStreamInputFailure?: (
+ terminal: string
+ ) => import('./terminal-stream-input-failure').TerminalStreamInputFailure | null
+ recoverTerminalStreamInput?: (terminal: string) => boolean
+ cancelTerminalStreamInput?: (terminal: string) => void
+ /** null means no negotiated stream; a returned promise must never be retried over RPC. */
+ sendTerminalStreamInput?: (terminal: string, text: string) => Promise | null
sendRequest: (
method: string,
params?: unknown,
diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts
index faa236a88ca..7c23463b744 100644
--- a/mobile/src/transport/stable-logical-rpc-client.test.ts
+++ b/mobile/src/transport/stable-logical-rpc-client.test.ts
@@ -68,6 +68,43 @@ function deferred() {
}
describe('stable logical RPC client', () => {
+ it('fences JSON terminal.send after an ordered prefix crosses a logical cutover', async () => {
+ const old = new FakeSession('connected')
+ const pending = deferred()
+ Object.assign(old, { sendTerminalStreamInput: () => pending.promise })
+ const client = createStableLogicalRpcClient(old, 'lan')
+ const prefix = client.sendTerminalStreamInput?.('t', 'prefix')
+ const fresh = new FakeSession('connected')
+ Object.assign(fresh, { recoverTerminalStreamInput: () => true })
+ fresh.sendRequest.mockResolvedValue(success({}))
+ await client.migrateTo(fresh, 'relay')
+ await expect(
+ client.sendRequest('terminal.send', { terminal: 't', text: '\r' })
+ ).rejects.toThrow('Terminal input stopped')
+ expect(fresh.sendRequest).not.toHaveBeenCalled()
+ expect(client.getTerminalStreamInputFailure?.('t')?.outcome).toBe('unknown')
+ expect(client.recoverTerminalStreamInput?.('t')).toBe(true)
+ pending.resolve(true)
+ expect(await prefix).toBe(false)
+ expect(client.getTerminalStreamInputFailure?.('t')).toBe(null)
+ await client.sendRequest('terminal.send', { terminal: 't', text: 'new' })
+ expect(fresh.sendRequest).toHaveBeenCalledOnce()
+ client.close()
+ })
+ it('exposes negotiated stream support without probing or sending input', async () => {
+ const session = new FakeSession('connected')
+ const supports = vi.fn(() => true)
+ const send = vi.fn(() => Promise.resolve(false))
+ Object.assign(session, { supportsTerminalStreamInput: supports, sendTerminalStreamInput: send })
+ const client = createStableLogicalRpcClient(session, 'lan')
+ expect(client.supportsTerminalStreamInput?.('t')).toBe(true)
+ expect(send).not.toHaveBeenCalled()
+ expect(await client.sendTerminalStreamInput?.('t', 'x')).toBe(false)
+ expect(client.supportsTerminalStreamInput?.('t')).toBe(true)
+ client.close()
+ expect(client.supportsTerminalStreamInput?.('t')).toBe(true)
+ expect(await client.sendTerminalStreamInput?.('t', '\r')).toBe(false)
+ })
it('advertises source-default support on worktree catalog requests', async () => {
const session = new FakeSession('connected')
session.sendRequest.mockResolvedValue(success([]))
diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts
index fb514128382..d0a440970e1 100644
--- a/mobile/src/transport/stable-logical-rpc-client.ts
+++ b/mobile/src/transport/stable-logical-rpc-client.ts
@@ -1,4 +1,5 @@
import type { ConnectionState, RpcResponse } from './types'
+import { assertTerminalInputRequestAllowed } from './terminal-input-request-fence'
import type { RpcClient } from './rpc-client'
import {
forwardMigrationDialState,
@@ -7,23 +8,12 @@ import {
import { waitForAuthenticated } from './replacement-session-authentication'
import { projectMobileRpcRequestParams } from './mobile-rpc-request-projection'
import { LogicalClientConnectionPath } from './logical-client-connection-path'
+import { LogicalClientCutoverError, type PendingLogicalRequest } from './logical-client-cutover'
+import { LogicalTerminalStreamInput } from './logical-terminal-stream-input'
+export { LogicalClientCutoverError, isLogicalClientCutoverError } from './logical-client-cutover'
export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay'
-export class LogicalClientCutoverError extends Error {
- constructor() {
- super('RPC interrupted by connection migration')
- }
-}
-
-// Why: instanceof can miss across bundle copies, so also match by message.
-export function isLogicalClientCutoverError(error: unknown): boolean {
- return (
- error instanceof LogicalClientCutoverError ||
- (error instanceof Error && error.message === 'RPC interrupted by connection migration')
- )
-}
-
type SubscriptionRecord = {
method: string
params: unknown
@@ -33,10 +23,6 @@ type SubscriptionRecord = {
cancelled: boolean
}
-type PendingRequest = {
- reject: (error: Error) => void
-}
-
export type StableLogicalRpcClient = RpcClient & {
migrateTo(
session: RpcClient,
@@ -75,20 +61,32 @@ export function createStableLogicalRpcClient(
let nextSubscriptionId = 0
let activeStateUnsubscribe: (() => void) | null = null
const subscriptions = new Map()
- const pendingRequests = new Set()
+ const pendingRequests = new Set()
const stateListeners = new Set<(state: ConnectionState) => void>()
let state = initialSession.getState()
const connectionPath = new LogicalClientConnectionPath(() => state === 'connected')
bindActiveState(initialSession, generation)
+ const terminalInput = new LogicalTerminalStreamInput(() => ({
+ generation,
+ session: activeSession,
+ available: !closed && !suspended && state === 'connected'
+ }))
+
const logical: StableLogicalRpcClient = {
- sendRequest(method, params, options) {
+ supportsTerminalStreamInput: terminalInput.supports,
+ sendTerminalStreamInput: terminalInput.send,
+ getTerminalStreamInputFailure: terminalInput.failure,
+ recoverTerminalStreamInput: terminalInput.recover,
+ cancelTerminalStreamInput: terminalInput.cancel,
+ async sendRequest(method, params, options) {
+ assertTerminalInputRequestAllowed(method, params, terminalInput.failure)
if (closed) {
- return Promise.reject(new Error('Client closed'))
+ throw new Error('Client closed')
}
if (suspended) {
- return Promise.reject(new Error('Client suspended'))
+ throw new Error('Client suspended')
}
const requestGeneration = generation
const session = activeSession
diff --git a/mobile/src/transport/terminal-input-request-fence.test.ts b/mobile/src/transport/terminal-input-request-fence.test.ts
new file mode 100644
index 00000000000..7a0b85f29d2
--- /dev/null
+++ b/mobile/src/transport/terminal-input-request-fence.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it, vi } from 'vitest'
+import { assertTerminalInputRequestAllowed } from './terminal-input-request-fence'
+import { RpcClientRequestTracker } from './rpc-client-request-tracker'
+
+describe('failed ordered prefix RPC fence', () => {
+ it('fences only terminal.send for the failed terminal', () => {
+ const failure = (terminal: string) =>
+ terminal === 't' ? { outcome: 'unknown' as const, reason: 'lost' } : null
+ expect(() =>
+ assertTerminalInputRequestAllowed('terminal.send', { terminal: 't' }, failure)
+ ).toThrow('Terminal input stopped')
+ expect(() =>
+ assertTerminalInputRequestAllowed('terminal.send', { terminal: 'other' }, failure)
+ ).not.toThrow()
+ expect(() =>
+ assertTerminalInputRequestAllowed('terminal.subscribe', { terminal: 't' }, failure)
+ ).not.toThrow()
+ })
+ it('rechecks direct requests after a connection wait before writing JSON', async () => {
+ let failed = false
+ const send = vi.fn(() => true)
+ const tracker = new RpcClientRequestTracker({
+ nextId: () => '1',
+ deviceToken: 'd',
+ getState: () => 'connected',
+ waitForConnected: async () => {
+ failed = true
+ },
+ sendEncrypted: send,
+ validateRequest: (method, params) =>
+ assertTerminalInputRequestAllowed(method, params, () =>
+ failed ? { outcome: 'unknown', reason: 'lost' } : null
+ )
+ })
+ await expect(
+ tracker.sendRequest('terminal.send', { terminal: 't', text: '\r' })
+ ).rejects.toThrow('Terminal input stopped')
+ expect(send).not.toHaveBeenCalled()
+ expect(tracker.size()).toBe(0)
+ })
+})
diff --git a/mobile/src/transport/terminal-input-request-fence.ts b/mobile/src/transport/terminal-input-request-fence.ts
new file mode 100644
index 00000000000..afdf2c3dc69
--- /dev/null
+++ b/mobile/src/transport/terminal-input-request-fence.ts
@@ -0,0 +1,17 @@
+import type { TerminalStreamInputFailure } from './terminal-stream-input-failure'
+
+export function assertTerminalInputRequestAllowed(
+ method: string,
+ params: unknown,
+ failure: (terminal: string) => TerminalStreamInputFailure | null
+): void {
+ if (method !== 'terminal.send' || !params || typeof params !== 'object') {
+ return
+ }
+ const terminal = (params as { terminal?: unknown }).terminal
+ if (typeof terminal === 'string' && failure(terminal)) {
+ throw new Error(
+ 'Terminal input stopped; explicitly recover a fresh subscription before sending more input.'
+ )
+ }
+}
diff --git a/mobile/src/transport/terminal-ordered-input-pty.integration.test.ts b/mobile/src/transport/terminal-ordered-input-pty.integration.test.ts
new file mode 100644
index 00000000000..69b1ff4d5a4
--- /dev/null
+++ b/mobile/src/transport/terminal-ordered-input-pty.integration.test.ts
@@ -0,0 +1,254 @@
+import { randomBytes } from 'node:crypto'
+import { expect, it, vi } from 'vitest'
+import nacl from 'tweetnacl'
+import WebSocket, { WebSocketServer } from 'ws'
+import '../../../config/scripts/vitest-host-ports-setup'
+import { E2EEChannel } from '../../../src/main/runtime/rpc/e2ee-channel'
+import { RpcDispatcher } from '../../../src/main/runtime/rpc/dispatcher'
+import { TERMINAL_METHODS } from '../../../src/main/runtime/rpc/methods/terminal'
+import {
+ createOrderedInputPtyTestRig,
+ inputProofDeadline
+} from '../../../src/main/runtime/rpc/terminal-ordered-input-pty-test-rig'
+import {
+ decodeTerminalStreamFrame,
+ type TerminalStreamFrame
+} from '../../../src/shared/terminal-stream-protocol'
+import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
+import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel'
+import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams'
+import {
+ queueTerminalLiveMirrorSend,
+ createTerminalLivePendingFlushState,
+ waitForTerminalLivePendingFlush
+} from '../terminal/terminal-live-pending-flush-state'
+
+vi.mock('expo-crypto', () => ({
+ getRandomBytes: (length: number) => new Uint8Array(randomBytes(length))
+}))
+
+it.each(
+ [300, 400, 500].flatMap((receiptDelayMs) => [
+ { receiptDelayMs, pipeline: true },
+ { receiptDelayMs, pipeline: false }
+ ])
+)(
+ 'encrypted real-PTY cadence: receipt delay $receiptDelayMs ms, pipeline $pipeline',
+ async ({ receiptDelayMs, pipeline }) => {
+ const payloads = ['BOM:\ufeff', 'abcdefghijklmnop', 'é'.repeat(9000), '\t\x1b[A\x7f\u0000']
+ const suffix = '\r'
+ const expected = Buffer.from(payloads.join('') + suffix)
+ const rig = await createOrderedInputPtyTestRig(expected)
+ const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false })
+ const handlers = new Map void>()
+ const abort = new AbortController()
+ const errors: unknown[] = []
+ const sessions: E2EEChannel[] = []
+ const dispatches: Promise[] = []
+ const delayedSends = new Set>()
+ const keys = nacl.box.keyPair()
+ const dispatcher = new RpcDispatcher({ runtime: rig.runtime, methods: TERMINAL_METHODS })
+ let phone: WebSocket | undefined
+ let mobile: MobileE2EEV2PhysicalChannel | undefined
+ let disposeSubscription: (() => void) | undefined
+ let clearStreams: (() => void) | undefined
+ let delayReplies = false
+ let receipts = 0
+ let bytesBeforeFirstReceipt: Buffer | undefined
+ const pending = createTerminalLivePendingFlushState()
+ try {
+ await inputProofDeadline(
+ new Promise((resolve, reject) => {
+ server.once('listening', resolve)
+ server.once('error', reject)
+ }),
+ 'encrypted server listener'
+ )
+ server.on('connection', (socket) => {
+ const channel = new E2EEChannel(socket, {
+ serverSecretKey: keys.secretKey,
+ resolveAuthenticatedDevice: (token) =>
+ token === 'test-device'
+ ? { deviceId: 'phone', deviceToken: token, scope: 'mobile' }
+ : null,
+ transportContext: { transport: 'relay', relayHostId: 'AbCdEf0123_-xyZ9' },
+ requireV2: true,
+ onReady: () => {},
+ onError: (code, reason) => errors.push({ code, reason })
+ })
+ sessions.push(channel)
+ channel.onMessage((plaintext, reply, sendBinary) => {
+ const request = JSON.parse(plaintext)
+ const dispatched = dispatcher.dispatchStreaming(request, reply, {
+ connectionId: 'encrypted-input-proof',
+ signal: abort.signal,
+ clientKind: 'mobile',
+ sendBinary: (bytes) => {
+ if (!delayReplies) {
+ return sendBinary(bytes)
+ }
+ const schedule = () => {
+ const timer = setTimeout(() => {
+ delayedSends.delete(timer)
+ sendBinary(bytes)
+ }, receiptDelayMs)
+ delayedSends.add(timer)
+ }
+ schedule()
+ return true
+ },
+ registerBinaryStreamHandler: (id, handler) => {
+ handlers.set(id, handler)
+ return () => {
+ handlers.delete(id)
+ }
+ }
+ })
+ dispatches.push(dispatched)
+ void dispatched.catch((error) => errors.push(error))
+ })
+ channel.onBinaryMessage((bytes) => {
+ const frame = decodeTerminalStreamFrame(bytes)
+ if (frame) {
+ handlers.get(frame.streamId)?.(frame)
+ }
+ })
+ socket.on('message', (raw, binary) =>
+ channel.handleRawMessage(binary ? new Uint8Array(raw as Buffer) : raw.toString())
+ )
+ socket.on('error', (error) => errors.push(error))
+ })
+ const address = server.address()
+ if (!address || typeof address === 'string') {
+ throw new Error('Missing encrypted listener port')
+ }
+ phone = new WebSocket(`ws://127.0.0.1:${address.port}`, { perMessageDeflate: false })
+ await inputProofDeadline(
+ new Promise((resolve, reject) => {
+ phone!.once('open', resolve)
+ phone!.once('error', reject)
+ }),
+ 'encrypted client connection'
+ )
+ let authenticated!: () => void
+ const ready = new Promise((resolve) => {
+ authenticated = resolve
+ })
+ let id = 0
+ const streams = new MobileRelayRpcStreams({
+ nextId: () => String(++id),
+ waitForConnected: () => ready,
+ sendFrame: (request) => mobile!.sendText(JSON.stringify(request)),
+ sendBinary: (bytes) => mobile!.sendBinary(bytes)
+ })
+ clearStreams = () => streams.clear()
+ mobile = new MobileE2EEV2PhysicalChannel({
+ session: MobileE2EEV2ClientSession.create({
+ desktopPublicKeyB64: Buffer.from(keys.publicKey).toString('base64'),
+ transport: 'relay',
+ relayHostId: 'AbCdEf0123_-xyZ9'
+ }),
+ socket: phone,
+ deviceToken: 'test-device',
+ decodeBinary: async (raw) => (raw instanceof Uint8Array ? raw : null),
+ onAuthenticated: authenticated,
+ onText: (text) => {
+ streams.handleResponse(JSON.parse(text))
+ },
+ onBinary: (bytes) => streams.handleBinary(bytes),
+ onError: (error) => errors.push(error)
+ })
+ phone.on('message', (raw, binary) => {
+ void mobile!.handleMessage(binary ? new Uint8Array(raw as Buffer) : raw.toString())
+ })
+ mobile.start()
+ await inputProofDeadline(ready, 'real E2EE authentication')
+ let subscribed!: () => void
+ const subscriptionReady = new Promise((resolve) => {
+ subscribed = resolve
+ })
+ disposeSubscription = streams.subscribe(
+ 'terminal.subscribe',
+ {
+ terminal: 'terminal-1',
+ client: { id: 'phone', type: 'mobile' },
+ capabilities: { terminalBinaryStream: 1 }
+ },
+ (result) => {
+ if ((result as { type?: string }).type === 'subscribed') {
+ subscribed()
+ }
+ }
+ )
+ await inputProofDeadline(subscriptionReady, 'ordered-input negotiation')
+ expect(streams.supportsTerminalStreamInput('terminal-1')).toBe(true)
+ delayReplies = true
+ const started = performance.now()
+ const sender = (terminal: string, text: string) =>
+ streams.sendTerminalStreamInput(terminal, text)!
+ const sends: Promise[] = []
+ for (const text of payloads) {
+ sends.push(
+ queueTerminalLiveMirrorSend(pending, 'terminal-1', text, sender, { pipeline }).then(
+ (accepted) => {
+ bytesBeforeFirstReceipt ??= rig.bytes()
+ receipts++
+ return accepted
+ }
+ )
+ )
+ // Real-clock typing cadence, not a lifecycle readiness delay.
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ }
+ expect(receipts).toBe(0)
+ expect(
+ await inputProofDeadline(waitForTerminalLivePendingFlush(pending), 'prefix receipts')
+ ).toBe(true)
+ expect(await Promise.all(sends)).toEqual(payloads.map(() => true))
+ expect(bytesBeforeFirstReceipt).toEqual(
+ Buffer.from(pipeline ? payloads.join('') : payloads[0]!)
+ )
+ expect(await inputProofDeadline(sender('terminal-1', suffix), 'control receipt')).toBe(true)
+ await inputProofDeadline(rig.inputDelivered, 'real PTY bytes')
+ expect(rig.bytes()).toEqual(expected)
+ expect(errors).toEqual([])
+ console.log(
+ '[ordered-input-pty-proof]',
+ JSON.stringify({
+ payloads: payloads.length,
+ bytes: expected.length,
+ receiptDelayMs,
+ pipeline,
+ bytesBeforeFirstReceipt: bytesBeforeFirstReceipt!.length,
+ firstPtyInputMs: Math.round(rig.firstInputAt()! - started),
+ allReceiptsMs: Math.round(performance.now() - started)
+ })
+ )
+ disposeSubscription()
+ disposeSubscription = undefined
+ streams.clear()
+ } finally {
+ disposeSubscription?.()
+ clearStreams?.()
+ abort.abort()
+ for (const timer of delayedSends) {
+ clearTimeout(timer)
+ }
+ mobile?.dispose()
+ phone?.terminate()
+ for (const channel of sessions) {
+ channel.destroy()
+ }
+ for (const socket of server.clients) {
+ socket.terminate()
+ }
+ await inputProofDeadline(Promise.allSettled(dispatches), 'host subscription cleanup')
+ await inputProofDeadline(
+ new Promise((resolve) => server.close(() => resolve())),
+ 'encrypted server close'
+ )
+ await rig.close()
+ }
+ },
+ 20_000
+)
diff --git a/mobile/src/transport/terminal-ordered-input-routing.test.ts b/mobile/src/transport/terminal-ordered-input-routing.test.ts
new file mode 100644
index 00000000000..b7e9ece27b5
--- /dev/null
+++ b/mobile/src/transport/terminal-ordered-input-routing.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it, vi } from 'vitest'
+import { RpcClientStreamRegistry } from './rpc-client-stream-registry'
+import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams'
+import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
+import { TERMINAL_ORDERED_INPUT_CAPABILITY } from '../../../src/shared/terminal-ordered-input'
+import type { RpcResponse } from './types'
+
+describe.each(['direct', 'relay'] as const)('%s ordered input routing', (path) => {
+ function setup() {
+ const sent: { id: string; params?: unknown }[] = []
+ const send = (request: unknown) => {
+ sent.push(request as { id: string; params?: unknown })
+ return true
+ }
+ const binary = vi.fn((_bytes: Uint8Array) => true)
+ let next = 0
+ const options = { nextId: () => `${++next}`, sendBinary: binary }
+ const registry =
+ path === 'direct'
+ ? new RpcClientStreamRegistry({
+ ...options,
+ deviceToken: 'device',
+ getState: () => 'connected',
+ sendEncrypted: send
+ })
+ : new MobileRelayRpcStreams({
+ ...options,
+ sendFrame: send,
+ waitForConnected: async () => {}
+ })
+ return { registry, sent, binary }
+ }
+
+ it('advertises, routes receipts, and cancels pending input when unsubscribed', async () => {
+ const { registry, sent, binary } = setup()
+ const dispose = registry.subscribe('terminal.subscribe', { terminal: 't' }, vi.fn())
+ await vi.waitFor(() => expect(sent).toHaveLength(1))
+ expect(sent[0]?.params).toMatchObject({ capabilities: { orderedInput: 1 } })
+ const response: RpcResponse = {
+ id: sent[0]!.id,
+ ok: true,
+ streaming: true,
+ result: {
+ type: 'subscribed',
+ streamId: 7,
+ capabilities: { orderedInput: TERMINAL_ORDERED_INPUT_CAPABILITY }
+ },
+ _meta: { runtimeId: 'r' }
+ }
+ registry.handleResponse(response)
+ expect(registry.supportsTerminalStreamInput('t')).toBe(true)
+ const first = registry.sendTerminalStreamInput('t', 'a')!
+ expect(binary).toHaveBeenCalledOnce()
+ registry.handleBinary(
+ encodeTerminalStreamFrame({
+ opcode: TerminalStreamOpcode.Metadata,
+ streamId: 7,
+ seq: 999,
+ payload: new TextEncoder().encode(
+ JSON.stringify({ inputReceipt: { sequence: 1, outcome: 'accepted' } })
+ )
+ })
+ )
+ expect(await first).toBe(true)
+ const second = registry.sendTerminalStreamInput('t', 'b')!
+ registry.cancelTerminalStreamInput('t')
+ expect(sent).toContainEqual(expect.objectContaining({ method: 'terminal.unsubscribe' }))
+ dispose()
+ expect(await second).toBe(false)
+ expect(registry.getTerminalStreamInputFailure('t')).toEqual({
+ outcome: 'unknown',
+ reason: 'cancelled'
+ })
+ expect(registry.supportsTerminalStreamInput('t')).toBe(true)
+ expect(await registry.sendTerminalStreamInput('t', '\r')).toBe(false)
+ })
+
+ it('preserves old-host fallback without sending the new opcode', async () => {
+ const { registry, sent, binary } = setup()
+ const dispose = registry.subscribe('terminal.subscribe', { terminal: 't' }, vi.fn())
+ await vi.waitFor(() => expect(sent).toHaveLength(1))
+ registry.handleResponse({
+ id: sent[0]!.id,
+ ok: true,
+ streaming: true,
+ result: { type: 'subscribed', streamId: 7 },
+ _meta: { runtimeId: 'r' }
+ })
+ expect(registry.sendTerminalStreamInput('t', 'a')).toBe(null)
+ expect(registry.supportsTerminalStreamInput('t')).toBe(false)
+ expect(binary).not.toHaveBeenCalled()
+ dispose()
+ })
+
+ it('settles admitted input on physical connection loss', async () => {
+ const { registry, sent } = setup()
+ registry.subscribe('terminal.subscribe', { terminal: 't' }, vi.fn())
+ await vi.waitFor(() => expect(sent).toHaveLength(1))
+ registry.handleResponse({
+ id: sent[0]!.id,
+ ok: true,
+ streaming: true,
+ result: {
+ type: 'subscribed',
+ streamId: 7,
+ capabilities: { orderedInput: TERMINAL_ORDERED_INPUT_CAPABILITY }
+ },
+ _meta: { runtimeId: 'r' }
+ })
+ const input = registry.sendTerminalStreamInput('t', 'a')!
+ if (registry instanceof RpcClientStreamRegistry) {
+ registry.markForReplay()
+ } else {
+ registry.clear()
+ }
+ expect(await input).toBe(false)
+ expect(registry.supportsTerminalStreamInput('t')).toBe(true)
+ expect(await registry.sendTerminalStreamInput('t', '\r')).toBe(false)
+ })
+})
diff --git a/mobile/src/transport/terminal-ordered-input.test.ts b/mobile/src/transport/terminal-ordered-input.test.ts
new file mode 100644
index 00000000000..cb30115a7cc
--- /dev/null
+++ b/mobile/src/transport/terminal-ordered-input.test.ts
@@ -0,0 +1,196 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { TerminalOrderedInput, advertiseTerminalOrderedInput } from './terminal-ordered-input'
+import { decodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
+import { TERMINAL_ORDERED_INPUT_CAPABILITY as limits } from '../../../src/shared/terminal-ordered-input'
+
+function setup(overrides = {}) {
+ const binary = vi.fn((_bytes: Uint8Array) => true)
+ const input = new TerminalOrderedInput(binary)
+ input.register(
+ 'request',
+ advertiseTerminalOrderedInput('terminal.subscribe', { terminal: 't' }),
+ { type: 'subscribed', streamId: 8, capabilities: { orderedInput: { ...limits, ...overrides } } }
+ )
+ const receipt = (sequence: number, outcome: string) =>
+ input.handle(8, { type: 'metadata', inputReceipt: { sequence, outcome } })
+ return { input, binary, receipt }
+}
+afterEach(() => vi.useRealTimers())
+
+describe('negotiated terminal input receipts', () => {
+ it.each([
+ [1, 'rejected'],
+ [2, 'unknown']
+ ] as const)('classifies refusal of pending sequence %s as %s', async (sequence, outcome) => {
+ const { input, receipt } = setup()
+ const first = input.send('t', 'a')!
+ const second = input.send('t', 'b')!
+ receipt(sequence, 'rejected')
+ expect(await first).toBe(false)
+ expect(await second).toBe(false)
+ expect(input.failure('t')?.outcome).toBe(outcome)
+ expect(input.recover('t')).toBe(false)
+ })
+ it('does not recover using a subscription registered before the failed admission', async () => {
+ const { input, receipt } = setup()
+ const params = advertiseTerminalOrderedInput('terminal.subscribe', { terminal: 't' })
+ input.register('candidate', params, {
+ type: 'subscribed',
+ streamId: 9,
+ capabilities: { orderedInput: limits }
+ })
+ const pending = input.send('t', 'x')!
+ input.handle(9, { type: 'metadata', inputReceipt: { sequence: 1, outcome: 'unknown' } })
+ expect(await pending).toBe(false)
+ expect(input.recover('t')).toBe(false)
+ receipt(1, 'accepted')
+ expect(input.failure('t')?.outcome).toBe('unknown')
+ })
+ it('releases clean terminal records across 2048 subscribe/unsubscribe cycles', async () => {
+ const input = new TerminalOrderedInput(() => true)
+ for (let i = 1; i <= 2048; i += 1) {
+ const terminal = `t-${i}`
+ const request = `request-${i}`
+ input.register(request, advertiseTerminalOrderedInput('terminal.subscribe', { terminal }), {
+ type: 'subscribed',
+ streamId: i,
+ capabilities: { orderedInput: limits }
+ })
+ if (i % 2 === 0) {
+ const accepted = input.send(terminal, 'a')!
+ input.handle(i, { type: 'metadata', inputReceipt: { sequence: 1, outcome: 'accepted' } })
+ expect(await accepted).toBe(true)
+ }
+ if (i % 3 === 0) {
+ input.clear()
+ } else {
+ input.reset(request)
+ }
+ expect(input.supports(terminal)).toBe(false)
+ }
+ const retained = input as unknown as {
+ streams: Map
+ byTerminal: Map
+ }
+ expect(retained.streams.size).toBe(0)
+ expect(retained.byTerminal.size).toBe(0)
+ })
+ it('waits for accepted receipts and sequences input independently of output metadata', async () => {
+ const { input, binary, receipt } = setup()
+ let settled = false
+ const first = input.send('t', '한')!
+ void first.then(() => {
+ settled = true
+ })
+ const second = input.send('t', '\r')!
+ await Promise.resolve()
+ expect(settled).toBe(false)
+ expect(binary.mock.calls.map(([bytes]) => decodeTerminalStreamFrame(bytes))).toMatchObject([
+ { opcode: TerminalStreamOpcode.Input, streamId: 8, seq: 1 },
+ { opcode: TerminalStreamOpcode.Input, streamId: 8, seq: 2 }
+ ])
+ input.handle(8, { type: 'metadata', seq: 200 })
+ expect(settled).toBe(false)
+ receipt(1, 'accepted')
+ receipt(2, 'accepted')
+ expect(await first).toBe(true)
+ expect(await second).toBe(true)
+ })
+
+ it.each(['rejected', 'unknown'])('latches %s without replay or later sends', async (outcome) => {
+ const { input, binary, receipt } = setup()
+ const first = input.send('t', 'text')!
+ const second = input.send('t', '\r')!
+ receipt(1, outcome)
+ expect(await first).toBe(false)
+ expect(await second).toBe(false)
+ expect(await input.send('t', 'later')).toBe(false)
+ expect(input.supports('t')).toBe(true)
+ expect(binary).toHaveBeenCalledTimes(2)
+ })
+
+ it.each([{ maxPendingFrames: 1 }, { maxPendingBytes: 1 }])(
+ 'bounds admitted outstanding input %j',
+ async (limit) => {
+ const { input, binary } = setup(limit)
+ const first = input.send('t', 'a')!
+ expect(await input.send('t', 'b')).toBe(false)
+ expect(await first).toBe(false)
+ expect(binary).toHaveBeenCalledTimes(1)
+ }
+ )
+
+ it('checks UTF-8 frame bytes and latches local refusal', async () => {
+ const { input, binary } = setup({ maxFrameBytes: 2 })
+ expect(await input.send('t', '한')).toBe(false)
+ expect(await input.send('t', 'a')).toBe(false)
+ expect(binary).not.toHaveBeenCalled()
+ })
+
+ it('settles and releases receipts on unsubscribe and ignores old receipts', async () => {
+ const { input, receipt } = setup()
+ const first = input.send('t', 'a')!
+ input.reset('request')
+ expect(await first).toBe(false)
+ receipt(1, 'accepted')
+ expect(await input.send('t', 'b')).toBe(false)
+ expect(input.supports('t')).toBe(true)
+ })
+
+ it('times out missing receipts without permitting further input', async () => {
+ vi.useFakeTimers()
+ const { input, binary } = setup()
+ const first = input.send('t', 'a')!
+ await vi.advanceTimersByTimeAsync(30_000)
+ expect(await first).toBe(false)
+ expect(await input.send('t', 'b')).toBe(false)
+ expect(binary).toHaveBeenCalledTimes(1)
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it('returns fallback only when no compatible host echo exists', () => {
+ const input = new TerminalOrderedInput(vi.fn())
+ const params = advertiseTerminalOrderedInput('terminal.subscribe', { terminal: 't' })
+ input.register('old', params, { type: 'subscribed', streamId: 1 })
+ expect(input.send('t', 'a')).toBe(null)
+ expect(input.supports('t')).toBe(false)
+ input.register('future', params, {
+ type: 'subscribed',
+ streamId: 2,
+ capabilities: { orderedInput: { ...limits, version: 2 } }
+ })
+ expect(input.send('t', 'a')).toBe(null)
+ })
+
+ it('keeps uncertainty latched across an old-host reconnect and recovers only after fresh negotiation', async () => {
+ const { input, receipt } = setup()
+ const first = input.send('t', 'prefix')!
+ input.clear()
+ expect(await first).toBe(false)
+ const params = advertiseTerminalOrderedInput('terminal.subscribe', { terminal: 't' })
+ input.register('old', params, { type: 'subscribed', streamId: 9 })
+ expect(input.recover('t')).toBe(false)
+ expect(input.supports('t')).toBe(true)
+ expect(await input.send('t', '\r')).toBe(false)
+ input.register('fresh', params, {
+ type: 'subscribed',
+ streamId: 10,
+ capabilities: { orderedInput: limits }
+ })
+ expect(await input.send('t', 'blocked')).toBe(false)
+ expect(input.recover('t')).toBe(true)
+ const fresh = input.send('t', 'fresh')!
+ receipt(1, 'accepted')
+ input.handle(10, { type: 'metadata', inputReceipt: { sequence: 1, outcome: 'accepted' } })
+ expect(await fresh).toBe(true)
+ })
+
+ it('latches synchronous socket failure and disposes receipt timers', async () => {
+ vi.useFakeTimers()
+ const { input, binary } = setup()
+ binary.mockReturnValue(false)
+ expect(await input.send('t', 'a')).toBe(false)
+ expect(await input.send('t', 'b')).toBe(false)
+ expect(vi.getTimerCount()).toBe(0)
+ })
+})
diff --git a/mobile/src/transport/terminal-ordered-input.ts b/mobile/src/transport/terminal-ordered-input.ts
new file mode 100644
index 00000000000..f097ffcfa3c
--- /dev/null
+++ b/mobile/src/transport/terminal-ordered-input.ts
@@ -0,0 +1,307 @@
+import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
+import { TERMINAL_ORDERED_INPUT_CAPABILITY } from '../../../src/shared/terminal-ordered-input'
+import type { TerminalStreamInputFailure } from './terminal-stream-input-failure'
+
+type Limits = {
+ version: 1
+ maxFrameBytes: number
+ maxPendingBytes: number
+ maxPendingFrames: number
+}
+type Pending = {
+ bytes: number
+ resolve: (accepted: boolean) => void
+ timer: ReturnType
+}
+type InputStream = {
+ terminal: string
+ streamId: number
+ limits: Limits
+ sequence: number
+ failed: boolean
+ failure: TerminalStreamInputFailure | null
+ registration: number
+ failureRegistration: number
+ pendingBytes: number
+ pending: Map
+}
+
+export function advertiseTerminalOrderedInput(method: string, params: unknown): unknown {
+ if (method !== 'terminal.subscribe' || !params || typeof params !== 'object') {
+ return params
+ }
+ const existing = (params as { capabilities?: unknown }).capabilities
+ return {
+ ...params,
+ capabilities: { ...(existing && typeof existing === 'object' ? existing : {}), orderedInput: 1 }
+ }
+}
+
+export class TerminalOrderedInput {
+ private readonly streams = new Map()
+ private readonly byTerminal = new Map()
+ private registration = 0
+ constructor(private readonly sendBinary: (bytes: Uint8Array) => boolean) {}
+
+ supports(terminal: string): boolean {
+ return this.byTerminal.has(terminal)
+ }
+
+ failure(terminal: string): TerminalStreamInputFailure | null {
+ return this.byTerminal.get(terminal)?.failure ?? null
+ }
+
+ recover(terminal: string): boolean {
+ const failed = this.byTerminal.get(terminal)
+ if (!failed?.failed) {
+ return !!failed && failed.sequence === 0
+ }
+ const fresh = [...this.streams.values()].findLast(
+ (stream) =>
+ stream.terminal === terminal &&
+ !stream.failed &&
+ stream.sequence === 0 &&
+ stream.registration > failed.failureRegistration
+ )
+ if (!fresh) {
+ return false
+ }
+ this.byTerminal.set(terminal, fresh)
+ return true
+ }
+
+ cancel(terminal: string): string[] {
+ const requests: string[] = []
+ for (const [request, stream] of this.streams) {
+ if (stream.terminal !== terminal) {
+ continue
+ }
+ if (stream.pending.size > 0) {
+ this.fail(stream, { outcome: 'unknown', reason: 'cancelled' })
+ }
+ requests.push(request)
+ }
+ return requests
+ }
+
+ register(requestId: string, params: unknown, result: unknown): void {
+ if (!params || typeof params !== 'object' || !result || typeof result !== 'object') {
+ return
+ }
+ const { terminal, capabilities: offered } = params as {
+ terminal?: unknown
+ capabilities?: { orderedInput?: unknown }
+ }
+ if (offered?.orderedInput !== 1) {
+ return
+ }
+ const { type, streamId, capabilities } = result as {
+ type?: unknown
+ streamId?: unknown
+ capabilities?: { orderedInput?: unknown }
+ }
+ if (
+ type !== 'subscribed' ||
+ typeof terminal !== 'string' ||
+ !Number.isInteger(streamId) ||
+ Number(streamId) <= 0 ||
+ Number(streamId) > 0xffffffff
+ ) {
+ return
+ }
+ const limits = parseLimits(capabilities?.orderedInput)
+ if (!limits || this.streams.has(requestId)) {
+ return
+ }
+ const previous = this.byTerminal.get(terminal)
+ if (previous && previous.pending.size > 0) {
+ this.fail(previous, { outcome: 'unknown', reason: 'subscription_replaced' })
+ }
+ const stream: InputStream = {
+ terminal,
+ streamId: Number(streamId),
+ limits,
+ sequence: 0,
+ failed: false,
+ failure: null,
+ registration: ++this.registration,
+ failureRegistration: 0,
+ pendingBytes: 0,
+ pending: new Map()
+ }
+ this.streams.set(requestId, stream)
+ if (!this.byTerminal.get(terminal)?.failed) {
+ this.byTerminal.set(terminal, stream)
+ }
+ }
+
+ send(terminal: string, text: string): Promise | null {
+ const stream = this.byTerminal.get(terminal)
+ if (!stream) {
+ return null
+ }
+ if (stream.failed) {
+ return Promise.resolve(false)
+ }
+ // Check code units before allocating a potentially oversized encoded copy.
+ if (text.length === 0 || text.length > stream.limits.maxFrameBytes) {
+ this.fail(stream, {
+ outcome: stream.pending.size > 0 ? 'unknown' : 'rejected',
+ reason: 'too_large'
+ })
+ return Promise.resolve(false)
+ }
+ const payload = new TextEncoder().encode(text)
+ if (
+ payload.length > stream.limits.maxFrameBytes ||
+ stream.pendingBytes + payload.length > stream.limits.maxPendingBytes ||
+ stream.pending.size >= stream.limits.maxPendingFrames ||
+ stream.sequence >= Number.MAX_SAFE_INTEGER
+ ) {
+ this.fail(stream, {
+ outcome: stream.pending.size > 0 ? 'unknown' : 'rejected',
+ reason: 'queue_full'
+ })
+ return Promise.resolve(false)
+ }
+ const sequence = ++stream.sequence
+ return new Promise((resolve) => {
+ const timer = setTimeout(
+ () => this.fail(stream, { outcome: 'unknown', reason: 'receipt_timeout' }),
+ 30_000
+ )
+ stream.pending.set(sequence, { bytes: payload.length, resolve, timer })
+ stream.pendingBytes += payload.length
+ try {
+ if (
+ !this.sendBinary(
+ encodeTerminalStreamFrame({
+ opcode: TerminalStreamOpcode.Input,
+ streamId: stream.streamId,
+ seq: sequence,
+ payload
+ })
+ )
+ ) {
+ this.fail(stream, { outcome: 'unknown', reason: 'connection_interrupted' })
+ }
+ } catch {
+ this.fail(stream, { outcome: 'unknown', reason: 'connection_interrupted' })
+ }
+ })
+ }
+
+ handle(streamId: number, result: unknown): void {
+ if (!result || typeof result !== 'object') {
+ return
+ }
+ const metadata = result as { type?: unknown; inputReceipt?: unknown }
+ const streams = [...this.streams.values()].filter((stream) => stream.streamId === streamId)
+ for (const stream of streams) {
+ if (metadata.type === 'error') {
+ this.fail(stream)
+ continue
+ }
+ if (metadata.type !== 'metadata' || metadata.inputReceipt === undefined) {
+ continue
+ }
+ const receipt = metadata.inputReceipt as {
+ sequence?: unknown
+ outcome?: unknown
+ reason?: unknown
+ } | null
+ if (!receipt || !Number.isSafeInteger(receipt.sequence) || Number(receipt.sequence) <= 0) {
+ this.fail(stream)
+ continue
+ }
+ const sequence = Number(receipt.sequence)
+ if (sequence > stream.sequence) {
+ this.fail(stream)
+ continue
+ }
+ const pending = stream.pending.get(sequence)
+ if (!pending) {
+ continue
+ }
+ if (receipt.outcome !== 'accepted') {
+ this.fail(stream, {
+ outcome:
+ receipt.outcome === 'rejected' && stream.pending.keys().next().value === sequence
+ ? 'rejected'
+ : 'unknown',
+ reason: typeof receipt.reason === 'string' ? receipt.reason.slice(0, 128) : 'write_failed'
+ })
+ continue
+ }
+ clearTimeout(pending.timer)
+ stream.pending.delete(sequence)
+ stream.pendingBytes -= pending.bytes
+ pending.resolve(true)
+ }
+ }
+
+ reset(requestId: string): void {
+ const stream = this.streams.get(requestId)
+ if (stream) {
+ const retainFailure = stream.pending.size > 0 || stream.failed
+ this.fail(stream)
+ if (!retainFailure && this.byTerminal.get(stream.terminal) === stream) {
+ this.byTerminal.delete(stream.terminal)
+ }
+ }
+ this.streams.delete(requestId)
+ }
+
+ clear(): void {
+ for (const requestId of this.streams.keys()) {
+ this.reset(requestId)
+ }
+ }
+
+ private fail(
+ stream: InputStream,
+ failure: TerminalStreamInputFailure = {
+ outcome: stream.pending.size > 0 ? 'unknown' : 'rejected',
+ reason: 'stream_closed'
+ }
+ ): void {
+ if (!stream.failed) {
+ stream.failure = failure
+ stream.failureRegistration = this.registration
+ }
+ stream.failed = true
+ for (const pending of stream.pending.values()) {
+ clearTimeout(pending.timer)
+ pending.resolve(false)
+ }
+ stream.pending.clear()
+ stream.pendingBytes = 0
+ }
+}
+
+function parseLimits(value: unknown): Limits | null {
+ if (!value || typeof value !== 'object') {
+ return null
+ }
+ const limits = value as Limits
+ if (limits.version !== 1) {
+ return null
+ }
+ for (const key of ['maxFrameBytes', 'maxPendingBytes', 'maxPendingFrames'] as const) {
+ if (!Number.isSafeInteger(limits[key]) || limits[key] <= 0) {
+ return null
+ }
+ }
+ return {
+ version: 1,
+ maxFrameBytes: Math.min(limits.maxFrameBytes, TERMINAL_ORDERED_INPUT_CAPABILITY.maxFrameBytes),
+ maxPendingBytes: Math.min(
+ limits.maxPendingBytes,
+ TERMINAL_ORDERED_INPUT_CAPABILITY.maxPendingBytes
+ ),
+ maxPendingFrames: Math.min(
+ limits.maxPendingFrames,
+ TERMINAL_ORDERED_INPUT_CAPABILITY.maxPendingFrames
+ )
+ }
+}
diff --git a/mobile/src/transport/terminal-stream-input-failure.ts b/mobile/src/transport/terminal-stream-input-failure.ts
new file mode 100644
index 00000000000..c7772361985
--- /dev/null
+++ b/mobile/src/transport/terminal-stream-input-failure.ts
@@ -0,0 +1,4 @@
+export type TerminalStreamInputFailure = {
+ outcome: 'rejected' | 'unknown'
+ reason: string
+}
diff --git a/mobile/src/transport/terminal-stream-protocol.ts b/mobile/src/transport/terminal-stream-protocol.ts
index 384d66996f1..ef4ea94f053 100644
--- a/mobile/src/transport/terminal-stream-protocol.ts
+++ b/mobile/src/transport/terminal-stream-protocol.ts
@@ -9,6 +9,7 @@ export enum TerminalStreamOpcode {
SnapshotEnd = 4,
Resized = 5,
Error = 6,
+ Input = 7,
Metadata = 12
}
@@ -76,6 +77,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
value === TerminalStreamOpcode.SnapshotEnd ||
value === TerminalStreamOpcode.Resized ||
value === TerminalStreamOpcode.Error ||
+ value === TerminalStreamOpcode.Input ||
value === TerminalStreamOpcode.Metadata
)
}