fix(remote): recover terminal attach and renderer HMR

This commit is contained in:
Brennan Benson
2026-07-30 16:46:45 -07:00
parent 5fe3aaf2b7
commit 2d608461dd
8 changed files with 314 additions and 29 deletions
@@ -5510,6 +5510,7 @@ describe('connectPanePty', () => {
// Once replay completes (guard cleared), real keystrokes flow through.
replayingPanesRef.current.delete(1)
setFitOverride('pty-live', 'remote-desktop-fit', 80, 24)
transport.claimViewport.mockClear()
;(onDataHandler as (data: string) => void)('a')
expect(transport.sendInput).toHaveBeenCalledWith('a')
expect(transport.claimViewport).toHaveBeenCalledTimes(1)
@@ -23564,6 +23565,53 @@ describe('connectPanePty', () => {
expect(transport.resize).not.toHaveBeenCalled()
})
it('claims a visible remote mirror once when its passive fit hold arrives', async () => {
const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides')
const { connectPanePty } = await import('./pty-connection')
const ptyId = 'remote:env-1@@terminal-visible'
const transport = createMockTransport(ptyId)
transportFactoryQueue.push(transport)
const deps = createDeps({
restoredLeafId: LEAF_2,
restoredPtyIdByLeafId: { [LEAF_2]: ptyId },
paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
})
const pane = createPane(2)
pane.fitAddon = {
...pane.fitAddon,
proposeDimensions: vi.fn(() => ({ cols: 132, rows: 42 }))
} as never
const binding = connectPanePty(pane as never, createManager(2) as never, deps as never)
await flushAsyncTicks()
transport.claimViewport.mockClear()
setFitOverride(ptyId, 'remote-desktop-fit', 10, 4)
expect(transport.claimViewport).toHaveBeenCalledWith(132, 42)
setFitOverride(ptyId, 'desktop-fit', 132, 42)
transport.claimViewport.mockClear()
setFitOverride(ptyId, 'remote-desktop-fit', 80, 24)
expect(transport.claimViewport).not.toHaveBeenCalled()
deps.isVisibleRef.current = false
binding.syncProcessTracking()
deps.isVisibleRef.current = true
binding.noteVisibilityResume()
expect(transport.claimViewport).toHaveBeenCalledWith(132, 42)
setFitOverride(ptyId, 'desktop-fit', 132, 42)
deps.isVisibleRef.current = false
binding.syncProcessTracking()
deps.isVisibleRef.current = true
binding.noteVisibilityResume()
transport.claimViewport.mockClear()
setFitOverride(ptyId, 'remote-desktop-fit', 80, 24)
expect(transport.claimViewport).not.toHaveBeenCalled()
setFitOverride(ptyId, 'desktop-fit', 132, 42)
binding.dispose()
})
it('does NOT re-assert while a mobile-fit override parks the PTY at phone dims', async () => {
const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides')
vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 80, rows: 24 })
@@ -69,7 +69,11 @@ import {
type SafeFitContinuationHandle
} from '@/lib/pane-manager/pane-tree-ops'
import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observer'
import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides'
import {
bindPanePtyId,
getFitOverrideForPty,
onOverrideChange
} from '@/lib/pane-manager/mobile-fit-overrides'
import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state'
import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty-size-reconcile'
import { shouldClaimRemoteDesktopViewport } from './remote-desktop-viewport-claim'
@@ -2249,14 +2253,26 @@ export function connectPanePty(
terminalKeyTarget.addEventListener('keydown', onTerminalKeyDown, { capture: true })
}
let visibleRemoteViewportClaimPtyId: string | null = null
let pendingVisibleRemoteViewportClaim = false
const setPanePtyFitBinding = (ptyId: string): void => {
bindPanePtyId(pane.id, ptyId, deps.tabId)
pane.container.dataset.ptyId = ptyId
if (
deps.isVisibleRef.current &&
isRemoteRuntimePtyId(ptyId) &&
visibleRemoteViewportClaimPtyId !== ptyId
) {
// Why: the initial fit event consumes this activation arm before later peer ownership changes.
visibleRemoteViewportClaimPtyId = ptyId
pendingVisibleRemoteViewportClaim = true
}
// Why: override hydration can arrive before this pane knows its PTY. Once
// data-pty-id is bound, safeFit can park xterm at the held phone grid.
// data-pty-id is bound, safeFit can park xterm at the authoritative grid.
if (getFitOverrideForPty(ptyId)) {
safeFit(pane)
}
claimPendingVisibleRemoteViewport()
}
let activePanePtyBinding: string | null = null
// Why: bind time lets async liveness reconcile ignore a request started
@@ -2310,6 +2326,8 @@ export function connectPanePty(
// Why: fit bindings live in a module-level map, so pane teardown must
// clear them explicitly instead of relying on DOM removal.
bindPanePtyId(pane.id, null, deps.tabId)
visibleRemoteViewportClaimPtyId = null
pendingVisibleRemoteViewportClaim = false
activePanePtyBinding = null
activePanePtyBindingBoundAt = null
delete pane.container.dataset.ptyId
@@ -3847,6 +3865,45 @@ export function connectPanePty(
transport.claimViewport?.(cols, rows)
}
}
const claimPendingVisibleRemoteViewport = (): void => {
if (!pendingVisibleRemoteViewportClaim || !deps.isVisibleRef.current) {
return
}
claimViewportForUserActivity()
}
const armVisibleRemoteViewportClaim = (): void => {
const ptyId = transport.getPtyId()
if (!ptyId || !isRemoteRuntimePtyId(ptyId)) {
visibleRemoteViewportClaimPtyId = null
pendingVisibleRemoteViewportClaim = false
return
}
if (
visibleRemoteViewportClaimPtyId !== ptyId ||
pendingVisibleRemoteViewportClaim ||
getFitOverrideForPty(ptyId)?.mode === 'remote-desktop-fit'
) {
visibleRemoteViewportClaimPtyId = ptyId
pendingVisibleRemoteViewportClaim = true
}
}
const unsubscribeRemoteDesktopActivationClaim = onOverrideChange((event) => {
if (event.ptyId !== transport.getPtyId() || !isRemoteRuntimePtyId(event.ptyId)) {
return
}
if (event.mode === 'desktop-fit') {
visibleRemoteViewportClaimPtyId = event.ptyId
pendingVisibleRemoteViewportClaim = false
return
}
if (event.mode === 'remote-desktop-fit') {
if (deps.isVisibleRef.current && visibleRemoteViewportClaimPtyId !== event.ptyId) {
visibleRemoteViewportClaimPtyId = event.ptyId
pendingVisibleRemoteViewportClaim = true
}
claimPendingVisibleRemoteViewport()
}
})
// Why: an unbound transport (detached during a remount/move and never
// rebound) silently rejects every keystroke while the PTY stays alive and
@@ -8726,15 +8783,22 @@ export function connectPanePty(
agentCompletionCoordinator.startProcessTracking()
// Why: the hidden-delivery gate must follow every pane visibility flip.
syncHiddenRendererPtyDelivery()
if (!deps.isVisibleRef.current) {
pendingVisibleRemoteViewportClaim = false
}
},
// Why: visible-resume size readback repairs dropped hidden resizes without refitting against xterm's transient hidden DOM fallback.
noteVisibilityResume() {
armVisibleRemoteViewportClaim()
claimPendingVisibleRemoteViewport()
ptySizeReassertion.request({ fit: false })
consumeHibernatedAgentWake()
requestKnownDroidReconfirmation()
sampleVisiblePaneForegroundAgent()
},
reassertPtySizeAfterWindowWake() {
armVisibleRemoteViewportClaim()
claimPendingVisibleRemoteViewport()
ptySizeReassertion.request({ fit: false })
},
// Why: mobile wake reaches this pane while it's hidden on the desktop, so consume only the armed hibernation wake — no size/foreground reads.
@@ -8813,6 +8877,7 @@ export function connectPanePty(
// Why: park/reconnect/remount doesn't advance the recovery epoch, so invalidate this xterm or its delayed retry could hit the next instance.
terminalRecoveryInstance.unregister()
unregisterUndeliverableWriteHandler()
unsubscribeRemoteDesktopActivationClaim()
cancelHiddenOutputSnapshotScrollRestore()
structuralReplayCoordinator.dispose()
cancelFreshSpawnFollowReset()
@@ -701,6 +701,46 @@ describe('createRemoteRuntimePtyTransport', () => {
)
})
it('retries initial web mirror inventory after a transient runtime close', async () => {
const healthyRuntimeCall = runtimeCall.getMockImplementation()
let activateAttempts = 0
runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => {
if (request.method === 'session.tabs.activate' && activateAttempts++ === 0) {
throw Object.assign(new Error('Remote Orca runtime closed the connection.'), {
code: 'remote_runtime_unavailable'
})
}
return healthyRuntimeCall?.(request)
})
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onError = vi.fn()
const recoveryPhases: string[] = []
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'web-terminal-host-tab-1',
leafId: 'pane:1'
})
transport.attach({
existingPtyId: 'remote:env-1@@stale-client-handle',
cols: 100,
rows: 30,
callbacks: {
onError,
onRecoveryStateChange: (state) => recoveryPhases.push(state.phase)
}
})
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(1))
expect(activateAttempts).toBe(2)
expect(onError).not.toHaveBeenCalled()
expect(recoveryPhases).toContain('backoff')
expect(latestSubscribePayload().terminal).toBe('terminal-1')
expect(runtimeCall.mock.calls.some(([request]) => request.method === 'terminal.create')).toBe(
false
)
})
it('resolves a HUB-native SSH PTY wake hint to its runtime terminal handle', async () => {
const leafId = '11111111-1111-4111-8111-111111111111'
runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => {
@@ -164,6 +164,7 @@ export function createRemoteRuntimePtyTransport(
let resubscribeRequestedRequiresReplacement = false
let recoveryRequiresReplacement = false
let stopWaitingForPublishedHandle: (() => void) | null = null
let settleHostSessionAttachRetry: ((retry: boolean) => void) | null = null
let attachGeneration = 0
let subscriptionGeneration = 0
@@ -213,6 +214,13 @@ export function createRemoteRuntimePtyTransport(
subscriptionGeneration += 1
closeMultiplexedStream()
}
if (
recovery.currentPhase === 'disconnected' ||
recovery.currentPhase === 'disposed' ||
recovery.currentPhase === 'idle'
) {
settleHostSessionAttachRetry?.(false)
}
emitRecoveryState()
})
let lastRecoveryStateKey = ''
@@ -228,6 +236,7 @@ export function createRemoteRuntimePtyTransport(
let agentSessionRequiresHostAuthorityReplay = false
let terminalCreateUnknownOutcomeError: unknown = null
let lastConnectOptions: Parameters<PtyTransport['connect']>[0] | null = null
let lastAttachOptions: Parameters<PtyTransport['attach']>[0] | null = null
let resolvePaneUnavailable = false
let recoveringPaneHandle: string | null = null
const adoptExecutionMetadata = (terminal: {
@@ -397,7 +406,8 @@ export function createRemoteRuntimePtyTransport(
}
async function waitForHostSessionHandle(
hostTabId: string
hostTabId: string,
isCurrent: () => boolean
): Promise<string | null | undefined | false> {
if (!worktreeId) {
return undefined
@@ -425,7 +435,7 @@ export function createRemoteRuntimePtyTransport(
}
const startedAt = Date.now()
while (!destroyed) {
while (isCurrent()) {
const remainingMs = HOST_SESSION_ATTACH_TIMEOUT_MS - (Date.now() - startedAt)
if (remainingMs <= 0) {
return undefined
@@ -457,6 +467,61 @@ export function createRemoteRuntimePtyTransport(
return undefined
}
function waitForHostSessionAttachRetry(recoveryEpoch: number): Promise<boolean> {
return new Promise((resolve) => {
let settled = false
const settle = (retry: boolean): void => {
if (settled) {
return
}
settled = true
if (settleHostSessionAttachRetry === settle) {
settleHostSessionAttachRetry = null
}
resolve(retry)
}
settleHostSessionAttachRetry?.(false)
settleHostSessionAttachRetry = settle
if (!recovery.schedule(recoveryEpoch, () => settle(true))) {
settle(false)
}
})
}
async function waitForHostSessionHandleWithRecovery(
hostTabId: string,
isCurrent: () => boolean
): Promise<string | null | undefined | false> {
let recoveryEpoch = recovery.isActive ? recovery.currentEpoch : undefined
while (isCurrent()) {
try {
const hostHandle = await waitForHostSessionHandle(hostTabId, isCurrent)
if (!isCurrent()) {
return undefined
}
if (recoveryEpoch !== undefined) {
if (!recovery.isCurrent(recoveryEpoch)) {
return undefined
}
recovery.markHealthy()
}
return hostHandle
} catch (error) {
if (
!isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error)) ||
!isCurrent()
) {
throw error
}
recoveryEpoch = recovery.isActive ? recovery.currentEpoch : recovery.begin()
if (!(await waitForHostSessionAttachRetry(recoveryEpoch)) || !isCurrent()) {
return undefined
}
}
}
return undefined
}
async function waitForResubscribeHostSessionHandle(
hostTabId: string,
previousHandle: string,
@@ -523,29 +588,27 @@ export function createRemoteRuntimePtyTransport(
async function attachHostSessionMirror(
options: { cols?: number; rows?: number },
notifySpawn = true,
expectedAttachGeneration?: number
expectedAttachGeneration?: number,
expectedLifecycleEpoch?: number
): Promise<PtyConnectResult | undefined> {
if (!tabId || !isWebTerminalSurfaceTabId(tabId)) {
return undefined
}
const isCurrent = (): boolean =>
!destroyed &&
(expectedAttachGeneration === undefined || expectedAttachGeneration === attachGeneration) &&
(expectedLifecycleEpoch === undefined || expectedLifecycleEpoch === lifecycleEpoch)
const hostTabId = toHostSessionTabId(tabId)
const hostHandle = await waitForHostSessionHandle(hostTabId)
if (hostHandle === undefined || destroyed) {
const hostHandle = await waitForHostSessionHandleWithRecovery(hostTabId, isCurrent)
if (hostHandle === undefined || !isCurrent()) {
return undefined
}
if (hostHandle === null) {
storedCallbacks.onError?.('Remote terminal was closed.')
return undefined
}
if (
!hostHandle ||
destroyed ||
(expectedAttachGeneration !== undefined && expectedAttachGeneration !== attachGeneration)
) {
if (
!destroyed &&
(expectedAttachGeneration === undefined || expectedAttachGeneration === attachGeneration)
) {
if (!hostHandle || !isCurrent()) {
if (isCurrent()) {
storedCallbacks.onError?.('Remote terminal was closed.')
}
return undefined
@@ -573,6 +636,9 @@ export function createRemoteRuntimePtyTransport(
}
}
if (!isCurrent()) {
return undefined
}
handle = hostHandle
remotePtyId = toRemoteRuntimePtyId(hostHandle, currentRuntimeEnvironmentId)
registerShutdownHandlers(remotePtyId)
@@ -592,12 +658,7 @@ export function createRemoteRuntimePtyTransport(
throw error
}
}
if (
destroyed ||
!connected ||
!remotePtyId ||
(expectedAttachGeneration !== undefined && expectedAttachGeneration !== attachGeneration)
) {
if (!connected || !remotePtyId || !isCurrent()) {
return undefined
}
@@ -1548,6 +1609,7 @@ export function createRemoteRuntimePtyTransport(
const connectLifecycleEpoch = ++lifecycleEpoch
const createEnvironmentId = currentRuntimeEnvironmentId
lastConnectOptions = options
lastAttachOptions = null
storedCallbacks = options.callbacks
recoveryRequiresReplacement = false
terminalEnded = false
@@ -1559,7 +1621,7 @@ export function createRemoteRuntimePtyTransport(
try {
if (isWebTerminalSurfaceTabId(tabId ?? '')) {
return await attachHostSessionMirror(options)
return await attachHostSessionMirror(options, true, undefined, connectLifecycleEpoch)
}
if (options.sessionId && !getRemoteRuntimeTerminalHandle(options.sessionId)) {
@@ -1762,12 +1824,13 @@ export function createRemoteRuntimePtyTransport(
},
attach(options) {
lifecycleEpoch += 1
const attachLifecycleEpoch = ++lifecycleEpoch
const generation = ++attachGeneration
cancelTerminalCreateRetryWait()
recovery.cancel()
recoveryRequiresReplacement = false
clearPublishedHandleWait()
lastAttachOptions = options
storedCallbacks = options.callbacks
terminalEnded = false
connecting = true
@@ -1798,7 +1861,7 @@ export function createRemoteRuntimePtyTransport(
const persistedHandle = nextHandle
void (async () => {
if (isWebTerminalSurfaceTabId(tabId ?? '')) {
await attachHostSessionMirror(options, false, generation)
await attachHostSessionMirror(options, false, generation, attachLifecycleEpoch)
return
}
if (!tabId || !leafId || !worktreeId) {
@@ -1998,6 +2061,23 @@ export function createRemoteRuntimePtyTransport(
getRecoveryState,
retryRecovery() {
if (
!destroyed &&
!terminalEnded &&
!connected &&
isWebTerminalSurfaceTabId(tabId ?? '') &&
recovery.currentPhase === 'disconnected'
) {
recovery.cancel()
if (lastAttachOptions) {
transport.attach(lastAttachOptions)
return true
}
if (lastConnectOptions) {
void transport.connect(lastConnectOptions)
return true
}
}
if (
!destroyed &&
!terminalEnded &&
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createRoot, type Root } from 'react-dom/client'
import { getOrCreateRendererRoot } from './react-renderer-root'
vi.mock('react-dom/client', () => ({
createRoot: vi.fn()
}))
beforeEach(() => {
vi.mocked(createRoot).mockReset()
})
describe('getOrCreateRendererRoot', () => {
it('reuses the root retained by an HMR entry module', () => {
const hotData: { orcaRendererRoot?: Root } = {}
const root = { render: vi.fn(), unmount: vi.fn() } as unknown as Root
vi.mocked(createRoot).mockReturnValue(root)
expect(getOrCreateRendererRoot({} as HTMLElement, hotData)).toBe(root)
expect(getOrCreateRendererRoot({} as HTMLElement, hotData)).toBe(root)
expect(createRoot).toHaveBeenCalledTimes(1)
})
it('creates an independent root without HMR state', () => {
const first = { render: vi.fn(), unmount: vi.fn() } as unknown as Root
const second = { render: vi.fn(), unmount: vi.fn() } as unknown as Root
vi.mocked(createRoot).mockReturnValueOnce(first).mockReturnValueOnce(second)
expect(getOrCreateRendererRoot({} as HTMLElement)).toBe(first)
expect(getOrCreateRendererRoot({} as HTMLElement)).toBe(second)
})
})
@@ -0,0 +1,20 @@
import { createRoot, type Root } from 'react-dom/client'
type RendererRootHotData = {
orcaRendererRoot?: Root
}
export function getOrCreateRendererRoot(
container: HTMLElement,
hotData?: RendererRootHotData
): Root {
const existingRoot = hotData?.orcaRendererRoot
if (existingRoot) {
return existingRoot
}
const root = createRoot(container)
if (hotData) {
hotData.orcaRendererRoot = root
}
return root
}
+2 -2
View File
@@ -1,7 +1,6 @@
import './assets/main.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { useTranslation } from 'react-i18next'
import App from './App'
import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary'
@@ -14,6 +13,7 @@ import { installTypingLatencyDiagnostic } from './lib/typing-latency-diagnostic'
import { shouldEnableReactGrab } from './lib/react-grab-dev-gate'
import { I18nProvider } from './i18n/I18nProvider'
import { translate } from './i18n/i18n'
import { getOrCreateRendererRoot } from './lib/react-renderer-root'
recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV })
installRendererCrashDiagnostics()
@@ -55,7 +55,7 @@ function RendererRoot(): React.JSX.Element {
)
}
createRoot(rootElement).render(
getOrCreateRendererRoot(rootElement, import.meta.hot?.data).render(
<StrictMode>
<I18nProvider>
<RendererRoot />
+2 -2
View File
@@ -1,7 +1,6 @@
import './assets/main.css'
import { StrictMode, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import { useTranslation } from 'react-i18next'
import { DashboardPopoutRoot } from './components/dashboard-popout/DashboardPopoutRoot'
import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary'
@@ -15,6 +14,7 @@ import { I18nProvider } from './i18n/I18nProvider'
import { translate } from './i18n/i18n'
import { useAppStore } from './store'
import type { GlobalSettings } from '../../shared/types'
import { getOrCreateRendererRoot } from './lib/react-renderer-root'
// Why: the pop-out window is a separate BrowserWindow with its own React root,
// so it must run the same renderer bootstrap as main.tsx (crash diagnostics,
@@ -114,7 +114,7 @@ function PopoutRoot(): React.JSX.Element {
)
}
createRoot(rootElement).render(
getOrCreateRendererRoot(rootElement, import.meta.hot?.data).render(
<StrictMode>
<I18nProvider>
<PopoutSettingsSync />