From 321e2cf0ba9f61c1a24ac4e1bd2e85097d90e413 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:54:41 -0700 Subject: [PATCH 1/9] fix(mobile): background-mount never-mounted terminals on subscribe so they don't render blank (STA-1840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mobile terminal.subscribe to a tab the desktop never mounted this session — a workspace the desktop isn't currently showing, a cold-activation-deferred tab (#8597), or a cold-parked tab — has no attached PTY, so the runtime has no headless emulator and the initial snapshot is empty. The mobile terminal then renders blank (repro: on the phone, scroll to an old workspace whose terminals the desktop unmounted; they stay blank). When a mobile subscribe hits an empty snapshot (or resolves no PTY), the runtime now asks the renderer to background-mount that tab so the PTY attaches and the live data stream — already subscribed on that path — delivers its output. A never-mounted workspace has no renderer-graph leaf, so its terminals are surfaced to mobile via synthetic pty: handles that carry no real UI tabId. The runtime passes the ptyId through the mount request and the renderer resolves the owning tab from the persisted tab model (resolveTerminalTabIdForPtyId), falling back to a whole-worktree background mount if it cannot. The request is idempotent for already-mounted tabs and never unmounts a live pane, so the worst case is the same blank as before — no regression. Validated on an iOS simulator with a two-workspace desktop: opening the workspace the desktop was not showing rendered a fully blank terminal without the fix and the live shell prompt with it. Tests: runtime mount-request gating (real-tab / pty-form / no-ptyId / unknown), subscribe-handler empty-snapshot-vs-content, and the ptyId->tabId resolver; all 608 RPC method tests pass. --- src/main/runtime/orca-runtime.ts | 37 +++++ src/main/runtime/rpc/methods/terminal.ts | 15 ++ .../terminal-subscribe-blank-mount.test.ts | 138 ++++++++++++++++++ .../rpc/terminal-subscribe-buffer.test.ts | 3 + ...erminal-mobile-subscribe-tab-mount.test.ts | 131 +++++++++++++++++ src/preload/api-types.ts | 3 + src/preload/index.ts | 10 ++ src/renderer/src/hooks/useIpcEvents.ts | 24 +++ .../src/lib/terminal-tab-for-pty-id.test.ts | 47 ++++++ .../src/lib/terminal-tab-for-pty-id.ts | 28 ++++ src/renderer/src/web/web-preload-api.ts | 1 + 11 files changed, 437 insertions(+) create mode 100644 src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts create mode 100644 src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts create mode 100644 src/renderer/src/lib/terminal-tab-for-pty-id.test.ts create mode 100644 src/renderer/src/lib/terminal-tab-for-pty-id.ts diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0458efbdc82..b2dab546bcd 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -18722,6 +18722,43 @@ export class OrcaRuntimeService { }) } + // Why: a mobile subscribe can target a tab whose pane the desktop never + // mounted this session — a cold-activation-deferred tab (#8597), a cold-parked + // tab, or any tab in a workspace the desktop isn't currently showing. With no + // mounted pane the PTY is never attached, so the runtime has no headless + // emulator and the mobile snapshot is empty → the terminal renders blank + // (STA-1840). Ask the renderer to background-mount that tab so it attaches/ + // cold-restores; the live data stream the subscribe already holds then + // delivers output. Idempotent — a request for an already-mounted tab is a + // no-op in the renderer. + // + // Why pass ptyId: a workspace the renderer never mounted has no renderer-graph + // leaves, so its terminals are surfaced to mobile via synthetic `pty:` + // handles that carry no real UI tabId. The renderer resolves the owning tab + // from the ptyId; when the handle already names a real tab we send that + // directly. + requestRendererTerminalTabMount(handle: string): void { + const record = this.handles.get(handle) + if (!record?.worktreeId) { + return + } + const tabId = record.tabId.startsWith('pty:') ? undefined : record.tabId + const ptyId = record.ptyId ?? undefined + if (!tabId && !ptyId) { + return + } + try { + this.getAuthoritativeWindow().webContents.send('terminal:requestTabMount', { + worktreeId: record.worktreeId, + ...(tabId ? { tabId } : {}), + ...(ptyId ? { ptyId } : {}) + }) + } catch { + // No authoritative window (shutdown/headless): the subscribe keeps its + // existing empty-snapshot fallback. + } + } + // Why: a leaf appears in the graph before its PTY spawns. If we issue a // handle while ptyId is null, the next graph sync after PTY spawn will // change ptyId and invalidate the handle. Wait for a connected PTY so diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 7a9b9e448a6..b8db56adaa8 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -2362,6 +2362,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // scrollback+end with no live stream or phone-fit. Wait for the PTY so // the subscribe can proceed normally. if (!leaf?.ptyId && isMobile) { + // Why: a tab the desktop never mounted this session (cold-activation + // deferral / cold-park / a workspace the desktop isn't showing) has no + // leaf in the runtime graph, so no PTY is attached to wait for. Ask the + // renderer to background-mount it so one attaches (#8597, STA-1840). + runtime.requestRendererTerminalTabMount(params.terminal) try { const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal) leaf = { ptyId } @@ -2772,6 +2777,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (closed) { return } + // Why: the handle resolved a ptyId (from the persisted layout graph) but + // the runtime has no headless emulator for it — the desktop never mounted + // this tab this session, so nothing ever attached the PTY to feed one. + // The initial snapshot is therefore empty and the mobile terminal renders + // blank (STA-1840). Ask the renderer to background-mount the tab; that + // attaches the PTY and the live data stream (already subscribed above) + // then delivers the output. Idempotent for already-mounted tabs. + if (isMobile && !serialized?.data && read.tail.length === 0) { + runtime.requestRendererTerminalTabMount(params.terminal) + } let initialOutputOverflowed = false if (pendingOutputOverflowed) { pendingOutput.splice(0) diff --git a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts new file mode 100644 index 00000000000..a46ed3eb633 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts @@ -0,0 +1,138 @@ +/** + * STA-1840: a mobile terminal.subscribe to a tab the desktop never mounted this + * session resolves a ptyId (from the persisted layout graph) but the runtime + * has no headless emulator for it, so the snapshot is empty and the terminal + * renders blank. The subscribe now asks the renderer to background-mount the tab + * when the snapshot comes back empty. These tests pin that the mount request + * fires on an empty snapshot and stays quiet when the snapshot has content. + */ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' +import { TERMINAL_METHODS } from './methods/terminal' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + registerRemoteTerminalViewSubscriber: () => () => {}, + requestRendererTerminalTabMount: () => {}, + ...overrides + } as OrcaRuntimeService +} + +const makeRequest = (method: string, params?: unknown): RpcRequest => ({ + id: 'req-1', + authToken: 'tok', + method, + params +}) + +describe('terminal.subscribe blank-tab background mount', () => { + it('requests a renderer tab mount when a mobile subscribe resolves a ptyId but the snapshot is empty', async () => { + // Why: STA-1840 blank path — the handle resolves a ptyId but the desktop + // never mounted the tab this session, so there is no headless emulator and + // serialize is empty. The subscribe must ask the renderer to background-mount + // the tab so the PTY attaches and the live stream fills it. + const cleanups = new Map void>() + const requestRendererTerminalTabMount = vi.fn() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + requestRendererTerminalTabMount, + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + // Empty snapshot: no headless emulator for a never-attached PTY. + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => + expect(requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1') + ) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + + it('does not request a renderer tab mount when the mobile snapshot has content', async () => { + const cleanups = new Map void>() + const requestRendererTerminalTabMount = vi.fn() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + requestRendererTerminalTabMount, + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValue({ data: 'live content', cols: 80, rows: 24, seq: 4 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + expect(requestRendererTerminalTabMount).not.toHaveBeenCalled() + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) +}) diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index 4a674f9df91..c8ae7dc5a5f 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -17,6 +17,9 @@ function stubRuntime(overrides: Partial = {}): OrcaRuntimeSe // Why: subscribe streams register as remote view subscribers for Phase-5 // query-authority suppression (terminal-query-authority.md). registerRemoteTerminalViewSubscriber: () => () => {}, + // Why: a no-PTY / empty-snapshot mobile subscribe asks the renderer to + // background-mount the tab (STA-1840). No-op by default. + requestRendererTerminalTabMount: () => {}, ...overrides } as OrcaRuntimeService } diff --git a/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts b/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts new file mode 100644 index 00000000000..56a982b3bc9 --- /dev/null +++ b/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts @@ -0,0 +1,131 @@ +/** + * Mobile blank-terminal regression (STA-1840 / #8597): a mobile terminal.subscribe + * to a tab the desktop never mounted this session (cold-activation deferral, + * cold-park, or a workspace the desktop isn't showing) has no attached PTY, so + * the runtime has no headless emulator and the snapshot is empty → the terminal + * renders blank. The runtime now asks the renderer to background-mount that tab + * (terminal:requestTabMount) so the PTY attaches and streams. These tests pin + * the request's gating: it fires for a known handle whether or not the handle + * carries a ptyId (both blank paths), skips pty-form/unknown handles, and + * degrades safely when there is no window. + */ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +type HandleSeed = { + handle: string + worktreeId: string + tabId: string + ptyId: string | null +} + +type RuntimeInternals = { + handles: Map< + string, + HandleSeed & { + runtimeId: string + rendererGraphEpoch: number + leafId: string + ptyGeneration: number + } + > + getAuthoritativeWindow: () => { + webContents: { send: (channel: string, payload: unknown) => void } + } +} + +function seedRuntime(seeds: HandleSeed[]): { + runtime: OrcaRuntimeService + send: ReturnType +} { + const runtime = new OrcaRuntimeService() + const internals = runtime as unknown as RuntimeInternals + for (const seed of seeds) { + internals.handles.set(seed.handle, { + ...seed, + runtimeId: 'rt-test', + rendererGraphEpoch: 1, + leafId: 'leaf-1', + ptyGeneration: 1 + }) + } + const send = vi.fn() + internals.getAuthoritativeWindow = () => ({ webContents: { send } }) + return { runtime, send } +} + +describe('requestRendererTerminalTabMount', () => { + it('requests a tab mount by tabId for a real-tab handle awaiting its PTY (null-leaf blank path)', () => { + const { runtime, send } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: null } + ]) + + runtime.requestRendererTerminalTabMount('h1') + + expect(send).toHaveBeenCalledWith('terminal:requestTabMount', { + worktreeId: 'wt-1', + tabId: 'tab-1' + }) + }) + + it('requests a tab mount by tabId even when a real-tab handle carries a ptyId', () => { + const { runtime, send } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: 'wt-1@@abc' } + ]) + + runtime.requestRendererTerminalTabMount('h1') + + expect(send).toHaveBeenCalledWith('terminal:requestTabMount', { + worktreeId: 'wt-1', + tabId: 'tab-1', + ptyId: 'wt-1@@abc' + }) + }) + + it('requests a mount by ptyId for a synthetic pty-form handle (never-mounted workspace)', () => { + // Why: a workspace the renderer never mounted has no renderer-graph leaf, so + // its terminals surface via `pty:` handles with no real UI tabId. The + // mount must still fire — carried by the ptyId — or the never-mounted tab + // stays blank (STA-1840 regression the first fix attempt missed). + const { runtime, send } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'pty:wt-1@@abc', ptyId: 'wt-1@@abc' } + ]) + + runtime.requestRendererTerminalTabMount('h1') + + expect(send).toHaveBeenCalledWith('terminal:requestTabMount', { + worktreeId: 'wt-1', + ptyId: 'wt-1@@abc' + }) + }) + + it('does not request a mount when a pty-form handle has no ptyId', () => { + const { runtime, send } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'pty:abc', ptyId: null } + ]) + + runtime.requestRendererTerminalTabMount('h1') + + expect(send).not.toHaveBeenCalled() + }) + + it('does not request a mount for an unknown handle', () => { + const { runtime, send } = seedRuntime([]) + + runtime.requestRendererTerminalTabMount('missing') + + expect(send).not.toHaveBeenCalled() + }) + + it('swallows window lookup failures so subscribe keeps its fallback', () => { + const { runtime } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: null } + ]) + const internals = runtime as unknown as RuntimeInternals + internals.getAuthoritativeWindow = () => { + throw new Error('no window') + } + + expect(() => runtime.requestRendererTerminalTabMount('h1')).not.toThrow() + }) +}) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 9151a2268dd..03ee878f5a1 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2821,6 +2821,9 @@ export type PreloadApi = { onRequestTerminalCreate: ( callback: (data: RuntimeTerminalCreateRequestPayload) => void ) => () => void + onRequestTerminalTabMount: ( + callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void + ) => () => void replyTerminalCreate: (reply: { requestId: string tabId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 1f0436caf5a..edc255ce570 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3495,6 +3495,16 @@ const api = { ipcRenderer.on('terminal:requestTabCreate', listener) return () => ipcRenderer.removeListener('terminal:requestTabCreate', listener) }, + onRequestTerminalTabMount: ( + callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId: string; tabId?: string; ptyId?: string } + ) => callback(data) + ipcRenderer.on('terminal:requestTabMount', listener) + return () => ipcRenderer.removeListener('terminal:requestTabMount', listener) + }, replyTerminalCreate: (reply: { requestId: string tabId?: string diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index efec4bae01c..dd73096a225 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -14,6 +14,7 @@ import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel' import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' +import { resolveTerminalTabIdForPtyId } from '@/lib/terminal-tab-for-pty-id' import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal' import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' @@ -1666,6 +1667,29 @@ export function useIpcEvents(): void { ) ) + // Why: a mobile subscribe against a terminal this renderer never mounted + // this session (cold-activation deferral / cold-park / a workspace the + // desktop isn't showing) has no attached PTY, so its snapshot is empty and + // the mobile terminal is blank. Background-mount that tab so the PTY attaches + // and streams (STA-1840). Idempotent for already-mounted tabs. + unsubs.push( + window.api.ui.onRequestTerminalTabMount(({ worktreeId, tabId, ptyId }) => { + if (!worktreeId) { + return + } + // Why: a never-mounted workspace has no renderer-graph leaf, so the + // runtime only knows the ptyId (synthetic pty: handle) — resolve + // the owning UI tab from the persisted tab model. Fall back to a + // whole-worktree mount so the terminal still attaches if we can't. + const resolvedTabId = + tabId ?? + (ptyId ? resolveTerminalTabIdForPtyId(useAppStore.getState(), worktreeId, ptyId) : null) + requestBackgroundTerminalWorktreeMount( + resolvedTabId ? { worktreeId, tabIds: [resolvedTabId] } : { worktreeId } + ) + }) + ) + // Why: CLI-driven terminal creation sends a request and waits for the // tabId reply so it can resolve a handle the caller can use immediately. // This mirrors the browser's onRequestTabCreate/replyTabCreate pattern. diff --git a/src/renderer/src/lib/terminal-tab-for-pty-id.test.ts b/src/renderer/src/lib/terminal-tab-for-pty-id.test.ts new file mode 100644 index 00000000000..e64f7e0d47f --- /dev/null +++ b/src/renderer/src/lib/terminal-tab-for-pty-id.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { resolveTerminalTabIdForPtyId } from './terminal-tab-for-pty-id' +import type { AppState } from '@/store/types' + +type ResolverState = Pick + +function state(partial: { + tabs?: Record + layouts?: Record }> +}): ResolverState { + return { + tabsByWorktree: (partial.tabs ?? {}) as unknown as AppState['tabsByWorktree'], + terminalLayoutsByTabId: (partial.layouts ?? {}) as unknown as AppState['terminalLayoutsByTabId'] + } +} + +describe('resolveTerminalTabIdForPtyId', () => { + it('matches a tab by its own ptyId', () => { + const s = state({ + tabs: { + wt: [ + { id: 'tab-a', ptyId: 'wt@@1' }, + { id: 'tab-b', ptyId: 'wt@@2' } + ] + } + }) + expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@2')).toBe('tab-b') + }) + + it('matches a tab by a split leaf ptyId in its saved layout', () => { + const s = state({ + tabs: { wt: [{ id: 'tab-a', ptyId: null }] }, + layouts: { 'tab-a': { ptyIdsByLeafId: { leaf1: 'wt@@1', leaf2: 'wt@@9' } } } + }) + expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@9')).toBe('tab-a') + }) + + it('returns null when no tab owns the ptyId', () => { + const s = state({ tabs: { wt: [{ id: 'tab-a', ptyId: 'wt@@1' }] } }) + expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@nope')).toBeNull() + }) + + it('returns null for an unknown worktree', () => { + const s = state({ tabs: { wt: [{ id: 'tab-a', ptyId: 'wt@@1' }] } }) + expect(resolveTerminalTabIdForPtyId(s, 'other', 'wt@@1')).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/terminal-tab-for-pty-id.ts b/src/renderer/src/lib/terminal-tab-for-pty-id.ts new file mode 100644 index 00000000000..a77ac6fd95d --- /dev/null +++ b/src/renderer/src/lib/terminal-tab-for-pty-id.ts @@ -0,0 +1,28 @@ +import type { AppState } from '@/store/types' + +/** + * Resolve the UI terminal tab id that owns a given ptyId within a worktree. + * + * Why: a workspace the renderer never mounted has no live pane and no + * renderer-graph leaf, so a mobile subscribe only knows the ptyId (surfaced via + * a synthetic `pty:` handle). To background-mount the pane so its PTY + * attaches (STA-1840), we map the ptyId back to the persisted tab — either the + * tab's own `ptyId` or a split leaf recorded in its saved layout. + */ +export function resolveTerminalTabIdForPtyId( + state: Pick, + worktreeId: string, + ptyId: string +): string | null { + const tabs = state.tabsByWorktree[worktreeId] ?? [] + for (const tab of tabs) { + if (tab.ptyId === ptyId) { + return tab.id + } + const ptyIdsByLeafId = state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId + if (ptyIdsByLeafId && Object.values(ptyIdsByLeafId).includes(ptyId)) { + return tab.id + } + } + return null +} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 08faded33e6..4abfb47b724 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2368,6 +2368,7 @@ function createWebUiApi(): NonNullable['ui']> { onActivateWorktree: () => noopUnsubscribe, onCreateTerminal: () => noopUnsubscribe, onRequestTerminalCreate: () => noopUnsubscribe, + onRequestTerminalTabMount: () => noopUnsubscribe, replyTerminalCreate: () => {}, onSplitTerminal: () => noopUnsubscribe, onRenameTerminal: () => noopUnsubscribe, From 8082166a5ba84bb5e0befa71805e44685540adc2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:00:52 -0700 Subject: [PATCH 2/9] test(mobile): mock onRequestTerminalTabMount in useIpcEvents tests (fixes CI for #8811) --- src/renderer/src/hooks/useIpcEvents.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index bc0ca7fec92..9e188a80805 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -969,6 +969,7 @@ describe('useIpcEvents browser tab create routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -1190,6 +1191,7 @@ describe('useIpcEvents updater integration', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -1434,6 +1436,7 @@ describe('useIpcEvents updater integration', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -1918,6 +1921,7 @@ describe('useIpcEvents updater integration', () => { requestTerminalCreateListenerRef.current = listener return () => {} }, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -2807,6 +2811,7 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -3296,6 +3301,7 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -3513,6 +3519,7 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -3725,6 +3732,7 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -3964,6 +3972,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { }, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -4211,6 +4220,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -4444,6 +4454,7 @@ describe('useIpcEvents agent status snapshot integration', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, From 730d6e1387dfae5c8e53ddf23a90ab6caa404592 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:02:09 -0700 Subject: [PATCH 3/9] fix(mobile): harden blank terminal recovery mounts --- src/main/runtime/orca-runtime.ts | 21 +++------- src/main/runtime/rpc/methods/terminal.ts | 23 ++++++----- .../terminal-subscribe-blank-mount.test.ts | 31 ++++++--------- .../rpc/terminal-subscribe-buffer.test.ts | 2 - ...erminal-mobile-subscribe-tab-mount.test.ts | 17 +------- src/renderer/src/hooks/useIpcEvents.ts | 29 +++++++------- .../src/lib/mobile-terminal-tab-mount.test.ts | 39 +++++++++++++++++++ .../src/lib/mobile-terminal-tab-mount.ts | 23 +++++++++++ .../src/lib/terminal-tab-for-pty-id.ts | 10 +---- 9 files changed, 106 insertions(+), 89 deletions(-) create mode 100644 src/renderer/src/lib/mobile-terminal-tab-mount.test.ts create mode 100644 src/renderer/src/lib/mobile-terminal-tab-mount.ts diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b2dab546bcd..2d9199a3f7e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -6721,6 +6721,10 @@ export class OrcaRuntimeService { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) } + hasHeadlessTerminalState(ptyId: string): boolean { + return this.headlessTerminals.has(ptyId) + } + serializeMainTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} @@ -18722,21 +18726,8 @@ export class OrcaRuntimeService { }) } - // Why: a mobile subscribe can target a tab whose pane the desktop never - // mounted this session — a cold-activation-deferred tab (#8597), a cold-parked - // tab, or any tab in a workspace the desktop isn't currently showing. With no - // mounted pane the PTY is never attached, so the runtime has no headless - // emulator and the mobile snapshot is empty → the terminal renders blank - // (STA-1840). Ask the renderer to background-mount that tab so it attaches/ - // cold-restores; the live data stream the subscribe already holds then - // delivers output. Idempotent — a request for an already-mounted tab is a - // no-op in the renderer. - // - // Why pass ptyId: a workspace the renderer never mounted has no renderer-graph - // leaves, so its terminals are surfaced to mobile via synthetic `pty:` - // handles that carry no real UI tabId. The renderer resolves the owning tab - // from the ptyId; when the handle already names a real tab we send that - // directly. + // Why: never-mounted tabs have no attached PTY or mobile snapshot; synthetic + // handles need the ptyId so the renderer can mount the exact owning tab. requestRendererTerminalTabMount(handle: string): void { const record = this.handles.get(handle) if (!record?.worktreeId) { diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index b8db56adaa8..ff9b8203af2 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -2356,17 +2356,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let leaf = runtime.resolveLeafForHandle(params.terminal) const isMobile = params.client?.type === 'mobile' const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary) + let rendererMountRequested = false // Why: the left pane's PTY spawns asynchronously after the tab is created. // Mobile clients that subscribe before the PTY is ready would get a bare // scrollback+end with no live stream or phone-fit. Wait for the PTY so // the subscribe can proceed normally. if (!leaf?.ptyId && isMobile) { - // Why: a tab the desktop never mounted this session (cold-activation - // deferral / cold-park / a workspace the desktop isn't showing) has no - // leaf in the runtime graph, so no PTY is attached to wait for. Ask the - // renderer to background-mount it so one attaches (#8597, STA-1840). + // Why: a never-mounted tab has no graph leaf to await; mounting the + // exact tab lets its PTY attach without activating the worktree. runtime.requestRendererTerminalTabMount(params.terminal) + rendererMountRequested = true try { const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal) leaf = { ptyId } @@ -2777,14 +2777,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (closed) { return } - // Why: the handle resolved a ptyId (from the persisted layout graph) but - // the runtime has no headless emulator for it — the desktop never mounted - // this tab this session, so nothing ever attached the PTY to feed one. - // The initial snapshot is therefore empty and the mobile terminal renders - // blank (STA-1840). Ask the renderer to background-mount the tab; that - // attaches the PTY and the live data stream (already subscribed above) - // then delivers the output. Idempotent for already-mounted tabs. - if (isMobile && !serialized?.data && read.tail.length === 0) { + // Why: missing model state—not snapshot text—is the signal that this + // PTY may never have attached; avoid remounting legitimate blank panes. + if ( + isMobile && + !rendererMountRequested && + runtime.hasHeadlessTerminalState?.(ptyId) === false + ) { runtime.requestRendererTerminalTabMount(params.terminal) } let initialOutputOverflowed = false diff --git a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts index a46ed3eb633..d72692a1042 100644 --- a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts @@ -1,11 +1,4 @@ -/** - * STA-1840: a mobile terminal.subscribe to a tab the desktop never mounted this - * session resolves a ptyId (from the persisted layout graph) but the runtime - * has no headless emulator for it, so the snapshot is empty and the terminal - * renders blank. The subscribe now asks the renderer to background-mount the tab - * when the snapshot comes back empty. These tests pin that the mount request - * fires on an empty snapshot and stays quiet when the snapshot has content. - */ +/** STA-1840 regression: missing mobile terminal models request an exact renderer tab mount. */ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import type { RpcRequest } from './core' @@ -18,6 +11,7 @@ function stubRuntime(overrides: Partial = {}): OrcaRuntimeSe getRuntimeId: () => 'test-runtime', registerRemoteTerminalViewSubscriber: () => () => {}, requestRendererTerminalTabMount: () => {}, + hasHeadlessTerminalState: () => true, ...overrides } as OrcaRuntimeService } @@ -30,22 +24,21 @@ const makeRequest = (method: string, params?: unknown): RpcRequest => ({ }) describe('terminal.subscribe blank-tab background mount', () => { - it('requests a renderer tab mount when a mobile subscribe resolves a ptyId but the snapshot is empty', async () => { - // Why: STA-1840 blank path — the handle resolves a ptyId but the desktop - // never mounted the tab this session, so there is no headless emulator and - // serialize is empty. The subscribe must ask the renderer to background-mount - // the tab so the PTY attaches and the live stream fills it. + it('requests a renderer tab mount when a mobile subscribe has no headless model', async () => { + // Why: stale preview text must not hide the missing live model/attachment. const cleanups = new Map void>() const requestRendererTerminalTabMount = vi.fn() const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), requestRendererTerminalTabMount, + hasHeadlessTerminalState: vi.fn().mockReturnValue(false), handleMobileSubscribe: vi.fn().mockResolvedValue(true), handleMobileUnsubscribe: vi.fn(), subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), - // Empty snapshot: no headless emulator for a never-attached PTY. - readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), - serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + readTerminal: vi.fn().mockResolvedValue({ tail: ['stale preview'], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValue({ data: 'stale snapshot', cols: 80, rows: 24, seq: 4 }), getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), getLayout: vi.fn().mockReturnValue({ seq: 1 }), @@ -85,7 +78,7 @@ describe('terminal.subscribe blank-tab background mount', () => { await dispatchPromise }) - it('does not request a renderer tab mount when the mobile snapshot has content', async () => { + it('does not request a renderer tab mount when an attached terminal is legitimately blank', async () => { const cleanups = new Map void>() const requestRendererTerminalTabMount = vi.fn() const runtime = stubRuntime({ @@ -95,9 +88,7 @@ describe('terminal.subscribe blank-tab background mount', () => { handleMobileUnsubscribe: vi.fn(), subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), - serializeTerminalBuffer: vi - .fn() - .mockResolvedValue({ data: 'live content', cols: 80, rows: 24, seq: 4 }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), getLayout: vi.fn().mockReturnValue({ seq: 1 }), diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index c8ae7dc5a5f..84e96ddd77f 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -17,8 +17,6 @@ function stubRuntime(overrides: Partial = {}): OrcaRuntimeSe // Why: subscribe streams register as remote view subscribers for Phase-5 // query-authority suppression (terminal-query-authority.md). registerRemoteTerminalViewSubscriber: () => () => {}, - // Why: a no-PTY / empty-snapshot mobile subscribe asks the renderer to - // background-mount the tab (STA-1840). No-op by default. requestRendererTerminalTabMount: () => {}, ...overrides } as OrcaRuntimeService diff --git a/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts b/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts index 56a982b3bc9..17a2057fc5a 100644 --- a/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts +++ b/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts @@ -1,14 +1,4 @@ -/** - * Mobile blank-terminal regression (STA-1840 / #8597): a mobile terminal.subscribe - * to a tab the desktop never mounted this session (cold-activation deferral, - * cold-park, or a workspace the desktop isn't showing) has no attached PTY, so - * the runtime has no headless emulator and the snapshot is empty → the terminal - * renders blank. The runtime now asks the renderer to background-mount that tab - * (terminal:requestTabMount) so the PTY attaches and streams. These tests pin - * the request's gating: it fires for a known handle whether or not the handle - * carries a ptyId (both blank paths), skips pty-form/unknown handles, and - * degrades safely when there is no window. - */ +/** STA-1840 regression: known blank-terminal handles request a bounded renderer mount. */ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' @@ -83,10 +73,7 @@ describe('requestRendererTerminalTabMount', () => { }) it('requests a mount by ptyId for a synthetic pty-form handle (never-mounted workspace)', () => { - // Why: a workspace the renderer never mounted has no renderer-graph leaf, so - // its terminals surface via `pty:` handles with no real UI tabId. The - // mount must still fire — carried by the ptyId — or the never-mounted tab - // stays blank (STA-1840 regression the first fix attempt missed). + // Why: never-mounted workspaces expose only synthetic pty handles to mobile. const { runtime, send } = seedRuntime([ { handle: 'h1', worktreeId: 'wt-1', tabId: 'pty:wt-1@@abc', ptyId: 'wt-1@@abc' } ]) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index dd73096a225..e9d8641de9d 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -14,7 +14,7 @@ import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel' import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' -import { resolveTerminalTabIdForPtyId } from '@/lib/terminal-tab-for-pty-id' +import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal' import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' @@ -1667,26 +1667,23 @@ export function useIpcEvents(): void { ) ) - // Why: a mobile subscribe against a terminal this renderer never mounted - // this session (cold-activation deferral / cold-park / a workspace the - // desktop isn't showing) has no attached PTY, so its snapshot is empty and - // the mobile terminal is blank. Background-mount that tab so the PTY attaches - // and streams (STA-1840). Idempotent for already-mounted tabs. + // Why: background-mounting a mobile-subscribed tab attaches a PTY that this + // renderer never mounted, without navigating the desktop (STA-1840). unsubs.push( window.api.ui.onRequestTerminalTabMount(({ worktreeId, tabId, ptyId }) => { if (!worktreeId) { return } - // Why: a never-mounted workspace has no renderer-graph leaf, so the - // runtime only knows the ptyId (synthetic pty: handle) — resolve - // the owning UI tab from the persisted tab model. Fall back to a - // whole-worktree mount so the terminal still attaches if we can't. - const resolvedTabId = - tabId ?? - (ptyId ? resolveTerminalTabIdForPtyId(useAppStore.getState(), worktreeId, ptyId) : null) - requestBackgroundTerminalWorktreeMount( - resolvedTabId ? { worktreeId, tabIds: [resolvedTabId] } : { worktreeId } - ) + // Why: synthetic pty handles need persisted-tab resolution, but a miss + // must not mount every saved terminal in a large hidden worktree. + const mount = planMobileTerminalTabMount(useAppStore.getState(), { + worktreeId, + ...(tabId ? { tabId } : {}), + ...(ptyId ? { ptyId } : {}) + }) + if (mount) { + requestBackgroundTerminalWorktreeMount(mount) + } }) ) diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts new file mode 100644 index 00000000000..1107f098207 --- /dev/null +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { planMobileTerminalTabMount } from './mobile-terminal-tab-mount' + +type PlannerState = Pick + +function state(tabCount = 1): PlannerState { + return { + tabsByWorktree: { + wt: Array.from({ length: tabCount }, (_, index) => ({ + id: `tab-${index}`, + ptyId: `wt@@${index}` + })) + } as unknown as AppState['tabsByWorktree'], + terminalLayoutsByTabId: {} + } +} + +describe('planMobileTerminalTabMount', () => { + it('keeps real-tab requests targeted to exactly one tab', () => { + expect(planMobileTerminalTabMount(state(), { worktreeId: 'wt', tabId: 'tab-0' })).toEqual({ + worktreeId: 'wt', + tabIds: ['tab-0'] + }) + }) + + it('resolves synthetic handles to exactly one owning tab at workspace scale', () => { + expect(planMobileTerminalTabMount(state(200), { worktreeId: 'wt', ptyId: 'wt@@173' })).toEqual({ + worktreeId: 'wt', + tabIds: ['tab-173'] + }) + }) + + it('does not mount the whole worktree when a stale pty id has no owner', () => { + expect( + planMobileTerminalTabMount(state(200), { worktreeId: 'wt', ptyId: 'wt@@missing' }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.ts new file mode 100644 index 00000000000..ead0ade6b31 --- /dev/null +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.ts @@ -0,0 +1,23 @@ +import type { AppState } from '@/store/types' +import type { BackgroundMountTerminalWorktreeDetail } from '@/constants/terminal' +import { resolveTerminalTabIdForPtyId } from './terminal-tab-for-pty-id' + +export type MobileTerminalTabMountRequest = { + worktreeId: string + tabId?: string + ptyId?: string +} + +/** Why: exact-tab planning prevents a stale ptyId from mounting every saved xterm (#8597). */ +export function planMobileTerminalTabMount( + state: Pick, + request: MobileTerminalTabMountRequest +): BackgroundMountTerminalWorktreeDetail | null { + if (!request.worktreeId) { + return null + } + const tabId = + request.tabId ?? + (request.ptyId ? resolveTerminalTabIdForPtyId(state, request.worktreeId, request.ptyId) : null) + return tabId ? { worktreeId: request.worktreeId, tabIds: [tabId] } : null +} diff --git a/src/renderer/src/lib/terminal-tab-for-pty-id.ts b/src/renderer/src/lib/terminal-tab-for-pty-id.ts index a77ac6fd95d..cbfa46945b8 100644 --- a/src/renderer/src/lib/terminal-tab-for-pty-id.ts +++ b/src/renderer/src/lib/terminal-tab-for-pty-id.ts @@ -1,14 +1,6 @@ import type { AppState } from '@/store/types' -/** - * Resolve the UI terminal tab id that owns a given ptyId within a worktree. - * - * Why: a workspace the renderer never mounted has no live pane and no - * renderer-graph leaf, so a mobile subscribe only knows the ptyId (surfaced via - * a synthetic `pty:` handle). To background-mount the pane so its PTY - * attaches (STA-1840), we map the ptyId back to the persisted tab — either the - * tab's own `ptyId` or a split leaf recorded in its saved layout. - */ +/** Resolve a synthetic mobile handle's ptyId through persisted tab and split bindings. */ export function resolveTerminalTabIdForPtyId( state: Pick, worktreeId: string, From cc4567b7f48be5d293b6117a0011f18f2ca359b9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:00:21 -0700 Subject: [PATCH 4/9] perf(mobile): avoid redundant terminal recovery mounts --- .../rpc/terminal-subscribe-blank-mount.test.ts | 13 +++++++++++-- src/renderer/src/hooks/useIpcEvents.ts | 17 ++++++++++++----- .../src/lib/mobile-terminal-tab-mount.test.ts | 16 +++++++++++++++- .../src/lib/mobile-terminal-tab-mount.ts | 13 +++++++++++-- 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts index d72692a1042..fbbbad77f34 100644 --- a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts @@ -27,14 +27,21 @@ describe('terminal.subscribe blank-tab background mount', () => { it('requests a renderer tab mount when a mobile subscribe has no headless model', async () => { // Why: stale preview text must not hide the missing live model/attachment. const cleanups = new Map void>() - const requestRendererTerminalTabMount = vi.fn() + const callOrder: string[] = [] + const unsubscribeData = vi.fn() + const requestRendererTerminalTabMount = vi.fn(() => { + callOrder.push('request-mount') + }) const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), requestRendererTerminalTabMount, hasHeadlessTerminalState: vi.fn().mockReturnValue(false), handleMobileSubscribe: vi.fn().mockResolvedValue(true), handleMobileUnsubscribe: vi.fn(), - subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalData: vi.fn(() => { + callOrder.push('subscribe-data') + return unsubscribeData + }), readTerminal: vi.fn().mockResolvedValue({ tail: ['stale preview'], truncated: false }), serializeTerminalBuffer: vi .fn() @@ -73,9 +80,11 @@ describe('terminal.subscribe blank-tab background mount', () => { await vi.waitFor(() => expect(requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1') ) + expect(callOrder).toEqual(['subscribe-data', 'request-mount']) runtime.cleanupSubscription('terminal-1:phone-1') await dispatchPromise + expect(unsubscribeData).toHaveBeenCalledTimes(1) }) it('does not request a renderer tab mount when an attached terminal is legitimately blank', async () => { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index e9d8641de9d..b3e2dae6b17 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -15,6 +15,7 @@ import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoa import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' +import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph' import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal' import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' @@ -1676,11 +1677,17 @@ export function useIpcEvents(): void { } // Why: synthetic pty handles need persisted-tab resolution, but a miss // must not mount every saved terminal in a large hidden worktree. - const mount = planMobileTerminalTabMount(useAppStore.getState(), { - worktreeId, - ...(tabId ? { tabId } : {}), - ...(ptyId ? { ptyId } : {}) - }) + const mount = planMobileTerminalTabMount( + useAppStore.getState(), + { + worktreeId, + ...(tabId ? { tabId } : {}), + ...(ptyId ? { ptyId } : {}) + }, + { + isTabMounted: hasRegisteredRuntimeTerminalTab + } + ) if (mount) { requestBackgroundTerminalWorktreeMount(mount) } diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts index 1107f098207..500a1abedae 100644 --- a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store/types' import { planMobileTerminalTabMount } from './mobile-terminal-tab-mount' @@ -36,4 +36,18 @@ describe('planMobileTerminalTabMount', () => { planMobileTerminalTabMount(state(200), { worktreeId: 'wt', ptyId: 'wt@@missing' }) ).toBeNull() }) + + it('does not schedule hidden layout work for an already-mounted tab', () => { + const isTabMounted = vi.fn().mockReturnValue(true) + + expect( + planMobileTerminalTabMount( + state(200), + { worktreeId: 'wt', ptyId: 'wt@@173' }, + { isTabMounted } + ) + ).toBeNull() + expect(isTabMounted).toHaveBeenCalledTimes(1) + expect(isTabMounted).toHaveBeenCalledWith('tab-173') + }) }) diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.ts index ead0ade6b31..0daa8df5549 100644 --- a/src/renderer/src/lib/mobile-terminal-tab-mount.ts +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.ts @@ -8,10 +8,15 @@ export type MobileTerminalTabMountRequest = { ptyId?: string } +type MobileTerminalTabMountOptions = { + isTabMounted?: (tabId: string) => boolean +} + /** Why: exact-tab planning prevents a stale ptyId from mounting every saved xterm (#8597). */ export function planMobileTerminalTabMount( state: Pick, - request: MobileTerminalTabMountRequest + request: MobileTerminalTabMountRequest, + options: MobileTerminalTabMountOptions = {} ): BackgroundMountTerminalWorktreeDetail | null { if (!request.worktreeId) { return null @@ -19,5 +24,9 @@ export function planMobileTerminalTabMount( const tabId = request.tabId ?? (request.ptyId ? resolveTerminalTabIdForPtyId(state, request.worktreeId, request.ptyId) : null) - return tabId ? { worktreeId: request.worktreeId, tabIds: [tabId] } : null + // Why: replaying the background-mount event for a live pane restarts its + // three-second hidden measurement window on every mobile reconnect. + return tabId && !options.isTabMounted?.(tabId) + ? { worktreeId: request.worktreeId, tabIds: [tabId] } + : null } From 5342ecbea80634392cb781c8715fe0c824dc88c7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:27:10 -0700 Subject: [PATCH 5/9] fix(mobile): ignore stale terminal mount requests --- .../src/lib/mobile-terminal-tab-mount.test.ts | 13 +++++++++++++ src/renderer/src/lib/mobile-terminal-tab-mount.ts | 15 ++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts index 500a1abedae..c8647a62c64 100644 --- a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts @@ -37,6 +37,19 @@ describe('planMobileTerminalTabMount', () => { ).toBeNull() }) + it('does not mount a hidden worktree for a stale direct tab id', () => { + const isTabMounted = vi.fn() + + expect( + planMobileTerminalTabMount( + state(200), + { worktreeId: 'wt', tabId: 'tab-missing' }, + { isTabMounted } + ) + ).toBeNull() + expect(isTabMounted).not.toHaveBeenCalled() + }) + it('does not schedule hidden layout work for an already-mounted tab', () => { const isTabMounted = vi.fn().mockReturnValue(true) diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.ts index 0daa8df5549..28431cc5ad6 100644 --- a/src/renderer/src/lib/mobile-terminal-tab-mount.ts +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.ts @@ -21,9 +21,18 @@ export function planMobileTerminalTabMount( if (!request.worktreeId) { return null } - const tabId = - request.tabId ?? - (request.ptyId ? resolveTerminalTabIdForPtyId(state, request.worktreeId, request.ptyId) : null) + const requestedTabExists = request.tabId + ? (state.tabsByWorktree[request.worktreeId] ?? []).some((tab) => tab.id === request.tabId) + : false + // Why: stale real-tab handles must fail closed like stale synthetic handles; + // otherwise they mount and measure a hidden worktree with no pane to recover. + const tabId = request.tabId + ? requestedTabExists + ? request.tabId + : null + : request.ptyId + ? resolveTerminalTabIdForPtyId(state, request.worktreeId, request.ptyId) + : null // Why: replaying the background-mount event for a live pane restarts its // three-second hidden measurement window on every mobile reconnect. return tabId && !options.isTabMounted?.(tabId) From 667ce40c9d8e1b8827a46efd8ff5bc739c13e89d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:22:09 -0700 Subject: [PATCH 6/9] fix(mobile): skip recovery mounts for closed streams --- src/main/runtime/rpc/methods/terminal.ts | 5 +++ .../terminal-subscribe-blank-mount.test.ts | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index ff9b8203af2..b2bf80004d9 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -2357,6 +2357,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const isMobile = params.client?.type === 'mobile' const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary) let rendererMountRequested = false + // Why: a closed stream must not allocate listeners, mobile-fit state, or + // a hidden renderer surface that no client remains to consume. + if (signal?.aborted) { + return + } // Why: the left pane's PTY spawns asynchronously after the tab is created. // Mobile clients that subscribe before the PTY is ready would get a bare diff --git a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts index fbbbad77f34..f80a4d69c2f 100644 --- a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts @@ -24,6 +24,38 @@ const makeRequest = (method: string, params?: unknown): RpcRequest => ({ }) describe('terminal.subscribe blank-tab background mount', () => { + it('does not mount a hidden tab for an already-aborted mobile subscribe', async () => { + const controller = new AbortController() + controller.abort() + const requestRendererTerminalTabMount = vi.fn() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue(null), + requestRendererTerminalTabMount, + waitForLeafPtyId: vi.fn(), + readTerminal: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + signal: controller.signal, + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + expect(requestRendererTerminalTabMount).not.toHaveBeenCalled() + expect(runtime.waitForLeafPtyId).not.toHaveBeenCalled() + expect(runtime.readTerminal).not.toHaveBeenCalled() + }) + it('requests a renderer tab mount when a mobile subscribe has no headless model', async () => { // Why: stale preview text must not hide the missing live model/attachment. const cleanups = new Map void>() From 629a7889b959e5e4fcadda4f2080fc6951cf5069 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:58:31 -0700 Subject: [PATCH 7/9] fix(mobile): reject ambiguous terminal mount ownership --- .../src/lib/mobile-terminal-tab-mount.test.ts | 12 ++++++++++++ .../src/lib/terminal-tab-for-pty-id.test.ts | 13 +++++++++++++ .../src/lib/terminal-tab-for-pty-id.ts | 19 +++++++++++++------ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts index c8647a62c64..6529ebca17f 100644 --- a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts @@ -37,6 +37,18 @@ describe('planMobileTerminalTabMount', () => { ).toBeNull() }) + it('does not mount either tab when stale persistence has duplicate pty ownership', () => { + const s = state(200) + s.terminalLayoutsByTabId['tab-199'] = { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { leaf: 'wt@@173' } + } + + expect(planMobileTerminalTabMount(s, { worktreeId: 'wt', ptyId: 'wt@@173' })).toBeNull() + }) + it('does not mount a hidden worktree for a stale direct tab id', () => { const isTabMounted = vi.fn() diff --git a/src/renderer/src/lib/terminal-tab-for-pty-id.test.ts b/src/renderer/src/lib/terminal-tab-for-pty-id.test.ts index e64f7e0d47f..4fdc698e79e 100644 --- a/src/renderer/src/lib/terminal-tab-for-pty-id.test.ts +++ b/src/renderer/src/lib/terminal-tab-for-pty-id.test.ts @@ -40,6 +40,19 @@ describe('resolveTerminalTabIdForPtyId', () => { expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@nope')).toBeNull() }) + it('returns null when stale persistence binds the ptyId to multiple tabs', () => { + const s = state({ + tabs: { + wt: [ + { id: 'tab-a', ptyId: 'wt@@1' }, + { id: 'tab-b', ptyId: null } + ] + }, + layouts: { 'tab-b': { ptyIdsByLeafId: { leaf2: 'wt@@1' } } } + }) + expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@1')).toBeNull() + }) + it('returns null for an unknown worktree', () => { const s = state({ tabs: { wt: [{ id: 'tab-a', ptyId: 'wt@@1' }] } }) expect(resolveTerminalTabIdForPtyId(s, 'other', 'wt@@1')).toBeNull() diff --git a/src/renderer/src/lib/terminal-tab-for-pty-id.ts b/src/renderer/src/lib/terminal-tab-for-pty-id.ts index cbfa46945b8..77ee3c29706 100644 --- a/src/renderer/src/lib/terminal-tab-for-pty-id.ts +++ b/src/renderer/src/lib/terminal-tab-for-pty-id.ts @@ -7,14 +7,21 @@ export function resolveTerminalTabIdForPtyId( ptyId: string ): string | null { const tabs = state.tabsByWorktree[worktreeId] ?? [] + let resolvedTabId: string | null = null for (const tab of tabs) { - if (tab.ptyId === ptyId) { - return tab.id - } const ptyIdsByLeafId = state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId - if (ptyIdsByLeafId && Object.values(ptyIdsByLeafId).includes(ptyId)) { - return tab.id + const ownsPty = + tab.ptyId === ptyId || + (ptyIdsByLeafId !== undefined && Object.values(ptyIdsByLeafId).includes(ptyId)) + if (!ownsPty) { + continue + } + if (resolvedTabId && resolvedTabId !== tab.id) { + // Why: stale duplicate ownership must not attach whichever hidden tab + // happens to appear first in persisted order. + return null } + resolvedTabId = tab.id } - return null + return resolvedTabId } From 9e270c44844b2eb5c1766ad1951f571f7b44bc62 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:55:42 -0700 Subject: [PATCH 8/9] fix(mobile): preserve terminal handle through recovery mount --- src/main/runtime/orca-runtime.ts | 22 ++++++++- ...erminal-mobile-subscribe-tab-mount.test.ts | 46 ++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 873b1ce8952..af21db0043e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -3254,7 +3254,13 @@ export class OrcaRuntimeService { } if (existing && (existing.ptyId !== ptyId || existing.ptyGeneration !== ptyGeneration)) { - this.invalidateLeafHandle(leafKey) + // Why: mobile can subscribe while the pane is waiting for its first PTY. + // Keep that handle usable after the recovery mount binds it. + const adoptedFirstPty = + existing.ptyId === null && this.adoptFirstPtyForLeafHandle(leafKey, ptyId, ptyGeneration) + if (!adoptedFirstPty) { + this.invalidateLeafHandle(leafKey) + } } } @@ -22287,6 +22293,20 @@ export class OrcaRuntimeService { this.rejectWaitersForHandle(handle, 'terminal_handle_stale') } + private adoptFirstPtyForLeafHandle( + leafKey: string, + ptyId: string | null, + ptyGeneration: number + ): boolean { + const handle = this.handleByLeafKey.get(leafKey) + const record = handle ? this.handles.get(handle) : null + if (!handle || !record || record.ptyId !== null || ptyId === null) { + return false + } + this.handles.set(handle, { ...record, ptyId, ptyGeneration }) + return true + } + private rememberDetachedPreAllocatedLeaves(): void { for (const leaf of this.leaves.values()) { if (leaf.ptyId && this.handleByPtyId.has(leaf.ptyId)) { diff --git a/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts b/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts index 17a2057fc5a..d84283e9181 100644 --- a/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts +++ b/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts @@ -22,6 +22,8 @@ type RuntimeInternals = { getAuthoritativeWindow: () => { webContents: { send: (channel: string, payload: unknown) => void } } + leaves: Map + issueHandle: (leaf: unknown) => string } function seedRuntime(seeds: HandleSeed[]): { @@ -44,7 +46,49 @@ function seedRuntime(seeds: HandleSeed[]): { return { runtime, send } } -describe('requestRendererTerminalTabMount', () => { +describe('mobile terminal subscribe tab mount', () => { + it('keeps a waiting real-tab handle usable when the mount binds its first PTY', async () => { + const runtime = new OrcaRuntimeService() + const syncGraph = (ptyId: string | null): void => { + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + title: 'Terminal', + activeLeafId: 'leaf-1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + leafId: 'leaf-1', + paneRuntimeId: 1, + ptyId + } + ] + }) + } + + runtime.attachWindow(1) + syncGraph(null) + const internals = runtime as unknown as RuntimeInternals + const leaf = internals.leaves.values().next().value + if (!leaf) { + throw new Error('expected terminal leaf') + } + const handle = internals.issueHandle(leaf) + const ptyWait = runtime.waitForLeafPtyId(handle) + + syncGraph('pty-1') + + await expect(ptyWait).resolves.toBe('pty-1') + expect(runtime.resolveLiveLeafForHandle(handle)).toEqual({ ptyId: 'pty-1' }) + await expect(runtime.readTerminal(handle)).resolves.toMatchObject({ handle }) + }) + it('requests a tab mount by tabId for a real-tab handle awaiting its PTY (null-leaf blank path)', () => { const { runtime, send } = seedRuntime([ { handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: null } From 292199c32bdeca7dcb6427c314a957dea6c484ec Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 15 Jul 2026 19:02:32 -0400 Subject: [PATCH 9/9] fix(mobile): recover legacy terminal history safely --- src/main/ipc/pty.test.ts | 108 +++++ src/main/ipc/pty.ts | 123 +++-- ...erer-terminal-serializer-readiness.test.ts | 81 ++++ .../renderer-terminal-serializer-readiness.ts | 129 ++++++ src/main/runtime/orca-runtime.test.ts | 22 + src/main/runtime/orca-runtime.ts | 81 +++- src/main/runtime/rpc/methods/terminal.ts | 190 +++++++- .../terminal-subscribe-blank-mount.test.ts | 71 ++- .../rpc/terminal-subscribe-buffer.test.ts | 2 +- .../terminal-subscribe-mount-replay.test.ts | 437 ++++++++++++++++++ ...subscribe-renderer-recovery-output.test.ts | 109 +++++ src/preload/api-types.ts | 3 +- src/preload/index.ts | 5 +- .../pty-buffer-serializer.test.ts | 21 + .../terminal-pane/pty-buffer-serializer.ts | 4 + .../terminal-pane/pty-connection.test.ts | 92 +++- .../terminal-pane/pty-connection.ts | 93 +++- src/renderer/src/web/web-preload-api.ts | 1 + 18 files changed, 1496 insertions(+), 76 deletions(-) create mode 100644 src/main/ipc/renderer-terminal-serializer-readiness.test.ts create mode 100644 src/main/ipc/renderer-terminal-serializer-readiness.ts create mode 100644 src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts create mode 100644 src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 4acea214fb8..ded73ae1766 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6085,6 +6085,7 @@ describe('registerPtyHandlers', () => { env?: Record }): Promise<{ id: string }> hasRendererSerializer?(ptyId: string): boolean + getRendererSerializerGeneration?(ptyId: string): number } let controller: RuntimeSpawnController | null = null const runtime = { @@ -6112,10 +6113,117 @@ describe('registerPtyHandlers', () => { worktreeId: 'wt-1', env: { ORCA_PANE_KEY: ` ${paneKey} ` } }) + const replacementGen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, { + paneKey + })) as number expect(spawnController.hasRendererSerializer?.(result.id)).toBe(false) await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen }) + expect(spawnController.hasRendererSerializer?.(result.id)).toBe(false) + expect(spawnController.getRendererSerializerGeneration?.(result.id)).toBe(0) + await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: replacementGen }) expect(spawnController.hasRendererSerializer?.(result.id)).toBe(true) + expect(spawnController.getRendererSerializerGeneration?.(result.id)).toBe(1) + }) + + it('does not let old teardown cancel serializer settlement for a reused PTY id', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + worktreeId?: string + env?: Record + }): Promise<{ id: string }> + getRendererSerializerGeneration?(ptyId: string): number + hasRendererSerializer?(ptyId: string): boolean + waitForRendererSerializer?( + ptyId: string, + afterGeneration: number, + timeoutMs?: number + ): Promise + } + const reusedPtyId = 'pty-reused' + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ id: reusedPtyId })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }), + preAllocateHandleForPty: vi.fn(() => null), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + registerPtyHandlers(mainWindow as never, runtime as never) + const paneKey = makePaneKey('tab-reused', '33333333-3333-4333-8333-333333333333') + const spawnController = controller as unknown as RuntimeSpawnController + const spawn = async (): Promise => { + await spawnController.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-1', + env: { ORCA_PANE_KEY: paneKey } + }) + } + + const firstGen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, { + paneKey + })) as number + await spawn() + await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: firstGen }) + const priorGeneration = spawnController.getRendererSerializerGeneration?.(reusedPtyId) ?? 0 + + const secondGen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, { + paneKey + })) as number + await spawn() + const ready = spawnController.waitForRendererSerializer?.( + reusedPtyId, + priorGeneration, + 1_000 + ) + clearProviderPtyState(reusedPtyId) + clearProviderPtyState(reusedPtyId) + await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: secondGen }) + + await expect(ready).resolves.toBe(true) + expect(spawnController.hasRendererSerializer?.(reusedPtyId)).toBe(true) + }) + + it('tracks exact remote-runtime serializer readiness without a local spawn mapping', async () => { + type RuntimeSpawnController = { + hasRendererSerializer?(ptyId: string): boolean + getRendererSerializerGeneration?(ptyId: string): number + } + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }) + } + + registerPtyHandlers(mainWindow as never, runtime as never) + const ptyId = 'remote:env-1@@terminal-1' + const spawnController = controller as unknown as RuntimeSpawnController + + expect(spawnController.hasRendererSerializer?.(ptyId)).toBe(false) + await handlers.get('pty:reportRendererSerializerReady')!(null, { ptyId: 'local-pty' }) + expect(spawnController.hasRendererSerializer?.('local-pty')).toBe(false) + await handlers.get('pty:reportRendererSerializerReady')!(null, { ptyId }) + expect(spawnController.hasRendererSerializer?.(ptyId)).toBe(true) + expect(spawnController.getRendererSerializerGeneration?.(ptyId)).toBe(1) }) it('clears pending pane serializer declarations when their renderer is destroyed', async () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 045a52009a8..dbfe03d3b0d 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -96,6 +96,7 @@ import { } from '../../shared/terminal-input' import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' import { createTerminalSessionStateSaveFailureMessage } from '../../shared/terminal-session-state-save-failure' +import { RendererTerminalSerializerReadiness } from './renderer-terminal-serializer-readiness' import { readShellStartupEnvVar } from '../pty/shell-startup-env' import { isTerminalLeafId, @@ -256,15 +257,13 @@ type PaneSpawnReservationResult = { // race to spawn the same tab/leaf. Key by stable paneKey so the loser adopts // the winner's PTY instead of creating a duplicate shell. const paneSpawnReservationsByPaneKey = new Map() -// Why: at PTY spawn time we capture the gen that was pending for the spawn's -// paneKey, so teardown can settle ONLY that gen. Without this, a paneKey -// remount that replaces the pending entry with a new gen would still get -// stomped by the old PTY's teardown firing settle on the wrong gen. -const ptyPendingGenByPtyId = new Map() +// Why: bind the declaration generation directly to its spawn result. PTY ids +// are reusable and teardown callbacks carry no incarnation token, so teardown +// must never guess which pending renderer generation it owns. +const pendingPtyIdBySerializerGeneration = new Map() // Why: the runtime's hasRendererSerializer probe needs a ptyId-keyed signal. -// Populated on settlePaneSerializer (renderer has registered for this ptyId) -// and cleared on PTY teardown. -const rendererSerializerByPtyId = new Set() +// A later spawn starts a fresh incarnation; subscription abort owns waiter cleanup. +const rendererSerializerReadiness = new RendererTerminalSerializerReadiness() function parseValidPaneKey(paneKey: unknown): ReturnType { if (typeof paneKey !== 'string' || paneKey.length > 256) { @@ -302,6 +301,7 @@ function cleanupPendingPaneSerializersForSender(ownerWebContentsId: number): voi for (const [paneKey, pending] of pendingByPaneKey) { if (pending.ownerWebContentsId === ownerWebContentsId) { pendingByPaneKey.delete(paneKey) + pendingPtyIdBySerializerGeneration.delete(pending.gen) } } } @@ -317,7 +317,15 @@ function registerPendingPaneSerializerCleanup(sender: WebContents | undefined): function declarePendingPaneSerializer(paneKey: string, sender: WebContents | undefined): number { const gen = ++pendingSerializerGenSeq registerPendingPaneSerializerCleanup(sender) + const replaced = pendingByPaneKey.get(paneKey) + if (replaced) { + pendingPtyIdBySerializerGeneration.delete(replaced.gen) + } pendingByPaneKey.set(paneKey, { gen, ownerWebContentsId: sender?.id ?? null }) + const existingPtyId = paneKeyPtyId.get(paneKey) + if (existingPtyId) { + pendingPtyIdBySerializerGeneration.set(gen, existingPtyId) + } return gen } @@ -369,10 +377,12 @@ function resolvePaneSpawnReservation( return response } -function settlePendingPaneSerializer(paneKey: string, gen: number): void { - if (pendingByPaneKey.get(paneKey)?.gen === gen) { - pendingByPaneKey.delete(paneKey) +function settlePendingPaneSerializer(paneKey: string, gen: number): boolean { + if (pendingByPaneKey.get(paneKey)?.gen !== gen) { + return false } + pendingByPaneKey.delete(paneKey) + return true } export function hasPendingRendererSerializerForPaneKey(paneKey: string): boolean { @@ -1180,7 +1190,6 @@ export function clearProviderPtyState(id: string): void { return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey) } }) - rendererSerializerByPtyId.delete(id) // Why: the hook server's per-paneKey caches (lastPrompt / lastTool) would // otherwise accumulate entries for dead panes over the process lifetime. // Use the spawn-time paneKey mapping since the server has no other way to @@ -1191,17 +1200,6 @@ export function clearProviderPtyState(id: string): void { paneKeyPtyId.delete(paneKey) } ptyPaneKey.delete(id) - // Why: drop the pre-signal pending entry only if it still belongs to THIS - // PTY's spawn generation. If a remount for the same paneKey has already - // pre-signaled a new gen, this teardown must NOT touch it — otherwise - // the second mount's hydration loses to the daemon-snapshot seed. See - // the generation-token rationale in - // docs/mobile-prefer-renderer-scrollback.md. - const ownedGen = ptyPendingGenByPtyId.get(id) - if (ownedGen !== undefined) { - settlePendingPaneSerializer(paneKey, ownedGen) - } - ptyPendingGenByPtyId.delete(id) if (stillOwnsPaneKey) { // Why: notify registered consumers AFTER we've dropped the paneKey↔ptyId // entries so a listener that re-reads the map sees the post-teardown @@ -1522,6 +1520,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:declarePendingPaneSerializer') ipcMain.removeHandler('pty:settlePaneSerializer') ipcMain.removeHandler('pty:clearPendingPaneSerializer') + ipcMain.removeHandler('pty:reportRendererSerializerReady') ipcMain.removeHandler('pty:getMainBufferSnapshot') ipcMain.removeHandler('pty:sideEffectSnapshot') ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot') @@ -2762,7 +2761,13 @@ export function registerPtyHandlers( // not stack listeners and trip Node's MaxListeners=10 warning. Many // sleeping PTYs waking at once (e.g. on relaunch) routinely fan out 10+ // concurrent calls. - type SerializeResult = { data: string; cols: number; rows: number; lastTitle?: string } | null + type SerializeResult = { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + } | null const pendingSerializeRequests = new Map< string, { resolve: (result: SerializeResult) => void; timeout: NodeJS.Timeout } @@ -2788,6 +2793,7 @@ export function registerPtyHandlers( data?: unknown cols?: unknown rows?: unknown + seq?: unknown lastTitle?: unknown } | null } @@ -2802,11 +2808,20 @@ export function registerPtyHandlers( typeof snapshot.cols === 'number' && typeof snapshot.rows === 'number' ) { - const result: { data: string; cols: number; rows: number; lastTitle?: string } = { + const result: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + } = { data: snapshot.data, cols: snapshot.cols, rows: snapshot.rows } + if (typeof snapshot.seq === 'number' && Number.isFinite(snapshot.seq)) { + result.seq = snapshot.seq + } if (typeof snapshot.lastTitle === 'string' && snapshot.lastTitle.length > 0) { result.lastTitle = snapshot.lastTitle } @@ -3303,6 +3318,15 @@ export function registerPtyHandlers( // so record their spawn-time paneKey here too. Synthetic hook titles and // paneKey-scoped cache cleanup both depend on this reverse lookup. const paneKey = rememberPaneKeyForPty(result.id, env?.ORCA_PANE_KEY) + const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined + const inheritRendererReadiness = + result.isReattach === true && + !pendingSerializer && + rendererSerializerReadiness.has(result.id) + rendererSerializerReadiness.beginIncarnation(result.id, inheritRendererReadiness) + if (paneKey && pendingSerializer) { + pendingPtyIdBySerializerGeneration.set(pendingSerializer.gen, result.id) + } if (!args.connectionId) { registerPty({ ptyId: result.id, @@ -3514,7 +3538,13 @@ export function registerPtyHandlers( // seed (no renderer authoritative for this PTY). A registry write happens // when the renderer calls registerPtySerializer; we check via the same // pendingByPaneKey + ptyId pairing that the cooperation gate uses. - return rendererSerializerByPtyId.has(ptyId) + return rendererSerializerReadiness.has(ptyId) + }, + getRendererSerializerGeneration: (ptyId) => { + return rendererSerializerReadiness.generation(ptyId) + }, + waitForRendererSerializer: (ptyId, afterGeneration, timeoutMs, signal) => { + return rendererSerializerReadiness.wait(ptyId, afterGeneration, timeoutMs, signal) }, getSize: (ptyId) => ptySizes.get(ptyId) ?? null, resize: (ptyId, cols, rows) => { @@ -4278,14 +4308,18 @@ export function registerPtyHandlers( const rendererPreSignaled = validatedPaneKey ? pendingByPaneKey.has(validatedPaneKey) : false - const rendererAlreadyRegistered = rendererSerializerByPtyId.has(result.id) + const rendererAlreadyRegistered = + result.isReattach === true && + !rendererPreSignaled && + rendererSerializerReadiness.has(result.id) + rendererSerializerReadiness.beginIncarnation(result.id, rendererAlreadyRegistered) // Why: capture the pending gen at spawn time so teardown for THIS PTY // only settles its own generation. A remount that replaces the entry // with a new gen must not be stomped by the old PTY's teardown. if (validatedPaneKey && rendererPreSignaled) { const pending = pendingByPaneKey.get(validatedPaneKey) if (pending) { - ptyPendingGenByPtyId.set(result.id, pending.gen) + pendingPtyIdBySerializerGeneration.set(pending.gen, result.id) } } @@ -5231,15 +5265,13 @@ export function registerPtyHandlers( if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') { return } - settlePendingPaneSerializer(args.paneKey, args.gen) - // Why: settle means the renderer has registered its serializer locally - // for whatever ptyId came back from spawn. The renderer doesn't carry - // the ptyId back through this IPC because the cooperation gate ran - // pre-spawn; instead we mark the pane as authoritative by paneKey → - // ptyId via the existing paneKeyPtyId mapping populated at spawn. - const ptyId = paneKeyPtyId.get(args.paneKey) - if (ptyId) { - rendererSerializerByPtyId.add(ptyId) + const ptyId = pendingPtyIdBySerializerGeneration.get(args.gen) + const settledCurrentGeneration = settlePendingPaneSerializer(args.paneKey, args.gen) + // Why: the generation-to-PTY binding survives late teardown of a reused + // id; paneKey reverse maps are provider lifecycle state and may already be gone. + pendingPtyIdBySerializerGeneration.delete(args.gen) + if (settledCurrentGeneration && ptyId) { + rendererSerializerReadiness.markReady(ptyId) } } ) @@ -5251,6 +5283,23 @@ export function registerPtyHandlers( return } settlePendingPaneSerializer(args.paneKey, args.gen) + pendingPtyIdBySerializerGeneration.delete(args.gen) + } + ) + + ipcMain.handle( + 'pty:reportRendererSerializerReady', + async (_event, args: { ptyId?: unknown }): Promise => { + if ( + typeof args.ptyId !== 'string' || + !args.ptyId.startsWith('remote:') || + args.ptyId.length > 512 + ) { + return + } + // Why: remote-runtime panes do not pass through the local spawn + // cooperation gate, so their exact PTY id is the only readiness key. + rendererSerializerReadiness.markReady(args.ptyId) } ) } diff --git a/src/main/ipc/renderer-terminal-serializer-readiness.test.ts b/src/main/ipc/renderer-terminal-serializer-readiness.test.ts new file mode 100644 index 00000000000..821aaa8f8aa --- /dev/null +++ b/src/main/ipc/renderer-terminal-serializer-readiness.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import { RendererTerminalSerializerReadiness } from './renderer-terminal-serializer-readiness' + +describe('RendererTerminalSerializerReadiness', () => { + it('resolves current and future waiters when the renderer settles', async () => { + const readiness = new RendererTerminalSerializerReadiness() + const pending = readiness.wait('pty-1', 0, 1_000) + + readiness.markReady('pty-1') + + await expect(pending).resolves.toBe(true) + expect(readiness.generation('pty-1')).toBe(1) + await expect(readiness.wait('pty-1', 0, 1_000)).resolves.toBe(true) + }) + + it('waits for a fresh settlement when historical readiness is stale', async () => { + const readiness = new RendererTerminalSerializerReadiness() + readiness.markReady('pty-1') + const pending = readiness.wait('pty-1', readiness.generation('pty-1'), 1_000) + + readiness.markReady('pty-1') + + await expect(pending).resolves.toBe(true) + expect(readiness.generation('pty-1')).toBe(2) + }) + + it('releases waiters when the PTY is cleared', async () => { + const readiness = new RendererTerminalSerializerReadiness() + readiness.markReady('pty-1') + const pending = readiness.wait('pty-1', readiness.generation('pty-1'), 1_000) + + readiness.clear('pty-1') + + await expect(pending).resolves.toBe(false) + expect(readiness.has('pty-1')).toBe(false) + }) + + it('releases waiters on abort and timeout', async () => { + vi.useFakeTimers() + const readiness = new RendererTerminalSerializerReadiness() + const abortController = new AbortController() + const aborted = readiness.wait('pty-abort', 0, 1_000, abortController.signal) + const timedOut = readiness.wait('pty-timeout', 0, 1_000) + + abortController.abort() + await vi.advanceTimersByTimeAsync(1_000) + + await expect(aborted).resolves.toBe(false) + await expect(timedOut).resolves.toBe(false) + vi.useRealTimers() + }) + + it('keeps a lifecycle waiter until readiness or abort when no timeout is supplied', async () => { + vi.useFakeTimers() + const readiness = new RendererTerminalSerializerReadiness() + const abortController = new AbortController() + const pending = readiness.wait('pty-lifecycle', 0, undefined, abortController.signal) + + await vi.advanceTimersByTimeAsync(60_000) + abortController.abort() + + await expect(pending).resolves.toBe(false) + vi.useRealTimers() + }) + + it('ignores late teardown from an older incarnation of a reused PTY id', async () => { + const readiness = new RendererTerminalSerializerReadiness() + const oldIncarnation = readiness.beginIncarnation('pty-reused') + readiness.markReady('pty-reused') + const priorGeneration = readiness.generation('pty-reused') + + readiness.beginIncarnation('pty-reused') + const pending = readiness.wait('pty-reused', priorGeneration, 1_000) + const oldTeardownCleared = readiness.clear('pty-reused', oldIncarnation) + readiness.markReady('pty-reused') + + expect(oldTeardownCleared).toBe(false) + await expect(pending).resolves.toBe(true) + expect(readiness.has('pty-reused')).toBe(true) + }) +}) diff --git a/src/main/ipc/renderer-terminal-serializer-readiness.ts b/src/main/ipc/renderer-terminal-serializer-readiness.ts new file mode 100644 index 00000000000..f58a9b2062f --- /dev/null +++ b/src/main/ipc/renderer-terminal-serializer-readiness.ts @@ -0,0 +1,129 @@ +type RendererTerminalSerializerWaiter = { + afterGeneration: number + finish: (ready: boolean) => void +} + +export class RendererTerminalSerializerReadiness { + private incarnationSequence = 0 + private readonly incarnationByPtyId = new Map() + private readonly readyIncarnationByPtyId = new Map() + private readonly generationByPtyId = new Map() + private readonly waitersByPtyId = new Map>() + + has(ptyId: string): boolean { + const incarnation = this.incarnationByPtyId.get(ptyId) + return incarnation !== undefined && this.readyIncarnationByPtyId.get(ptyId) === incarnation + } + + generation(ptyId: string): number { + return this.generationByPtyId.get(ptyId) ?? 0 + } + + beginIncarnation(ptyId: string, inheritReady = false): number { + const wasReady = this.has(ptyId) + const incarnation = ++this.incarnationSequence + this.incarnationByPtyId.set(ptyId, incarnation) + if (inheritReady && wasReady) { + this.readyIncarnationByPtyId.set(ptyId, incarnation) + } else { + this.readyIncarnationByPtyId.delete(ptyId) + } + return incarnation + } + + markReady(ptyId: string): void { + let incarnation = this.incarnationByPtyId.get(ptyId) + if (incarnation === undefined) { + incarnation = ++this.incarnationSequence + this.incarnationByPtyId.set(ptyId, incarnation) + } + this.readyIncarnationByPtyId.set(ptyId, incarnation) + const generation = this.generation(ptyId) + 1 + this.generationByPtyId.set(ptyId, generation) + const waiters = this.waitersByPtyId.get(ptyId) + if (!waiters) { + return + } + for (const waiter of waiters) { + if (generation > waiter.afterGeneration) { + waiter.finish(true) + } + } + } + + clear(ptyId: string, incarnation?: number): boolean { + if (incarnation !== undefined && this.incarnationByPtyId.get(ptyId) !== incarnation) { + return false + } + this.incarnationByPtyId.delete(ptyId) + this.readyIncarnationByPtyId.delete(ptyId) + this.generationByPtyId.delete(ptyId) + this.finishWaiters(ptyId, false) + return true + } + + private finishWaiters(ptyId: string, ready: boolean): void { + const waiters = this.waitersByPtyId.get(ptyId) + if (!waiters) { + return + } + for (const waiter of waiters) { + waiter.finish(ready) + } + } + + wait( + ptyId: string, + afterGeneration: number, + timeoutMs?: number, + signal?: AbortSignal + ): Promise { + if (this.generation(ptyId) > afterGeneration) { + return Promise.resolve(true) + } + if (signal?.aborted) { + return Promise.resolve(false) + } + + return new Promise((resolve) => { + let timer: ReturnType | null = null + let settled = false + let waiters = this.waitersByPtyId.get(ptyId) + if (!waiters) { + waiters = new Set() + this.waitersByPtyId.set(ptyId, waiters) + } + + const onAbort = (): void => finish(false) + const waiter: RendererTerminalSerializerWaiter = { + afterGeneration, + finish: (ready) => finish(ready) + } + const finish = (ready: boolean): void => { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + timer = null + } + signal?.removeEventListener('abort', onAbort) + waiters.delete(waiter) + if (waiters.size === 0) { + this.waitersByPtyId.delete(ptyId) + } + resolve(ready) + } + + waiters.add(waiter) + signal?.addEventListener('abort', onAbort, { once: true }) + if (timeoutMs !== undefined) { + timer = setTimeout(() => finish(false), timeoutMs) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + }) + } +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b5d4cef08b5..a2ff8902b76 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -8132,6 +8132,28 @@ describe('OrcaRuntimeService', () => { ) }) + it('replaces suffix-only headless state with the recovered renderer snapshot', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + runtime.seedHeadlessTerminal('pty-1', 'suffix-only redraw', { cols: 80, rows: 24 }) + + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery('pty-1', { + data: 'restored history\r\nprompt $ ', + cols: 80, + rows: 24, + cwd: '/projects/restored' + }) + runtime.onPtyData('pty-1', 'after recovery\r\n', 100) + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { + scrollbackRows: 100 + }) + expect(snapshot?.data).toContain('restored history') + expect(snapshot?.data).toContain('after recovery') + expect(snapshot?.data).not.toContain('suffix-only redraw') + expect(snapshot?.cwd).toBe('/projects/restored') + }) + it('adopts OSC7 host metadata from seeded headless terminal scrollback', async () => { const runtime = createRuntime() syncSinglePty(runtime, 'pty-1') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index af21db0043e..b60829c619c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1222,7 +1222,7 @@ type RuntimePtyController = { serializeBuffer?( ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - ): Promise<{ data: string; cols: number; rows: number; lastTitle?: string } | null> + ): Promise<{ data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null> /** Authoritative provider-owned snapshot for restored PTYs with no mounted renderer. */ serializeProviderBuffer?( ptyId: string, @@ -1232,6 +1232,13 @@ type RuntimePtyController = { // hydration when no renderer is authoritative for this PTY. See // docs/mobile-prefer-renderer-scrollback.md. hasRendererSerializer?(ptyId: string): boolean + getRendererSerializerGeneration?(ptyId: string): number + waitForRendererSerializer?( + ptyId: string, + afterGeneration: number, + timeoutMs?: number, + signal?: AbortSignal + ): Promise getSize?(ptyId: string): { cols: number; rows: number } | null } @@ -6834,9 +6841,9 @@ export class OrcaRuntimeService { data: string cols: number rows: number + seq?: number cwd?: string | null lastTitle?: string - seq?: number source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean @@ -6857,9 +6864,9 @@ export class OrcaRuntimeService { data: string cols: number rows: number + seq?: number cwd?: string | null lastTitle?: string - seq?: number source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean @@ -7295,13 +7302,14 @@ export class OrcaRuntimeService { : rendererSnapshot } - private async serializeRendererTerminalBuffer( + async serializeRendererTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} ): Promise<{ data: string cols: number rows: number + seq?: number cwd?: string | null lastTitle?: string source?: 'renderer' @@ -7314,6 +7322,7 @@ export class OrcaRuntimeService { data: string cols: number rows: number + seq?: number cwd?: string | null lastTitle?: string oscLinks?: TerminalOscLinkRange[] @@ -19002,15 +19011,15 @@ export class OrcaRuntimeService { // Why: never-mounted tabs have no attached PTY or mobile snapshot; synthetic // handles need the ptyId so the renderer can mount the exact owning tab. - requestRendererTerminalTabMount(handle: string): void { + requestRendererTerminalTabMount(handle: string): boolean { const record = this.handles.get(handle) if (!record?.worktreeId) { - return + return false } const tabId = record.tabId.startsWith('pty:') ? undefined : record.tabId const ptyId = record.ptyId ?? undefined if (!tabId && !ptyId) { - return + return false } try { this.getAuthoritativeWindow().webContents.send('terminal:requestTabMount', { @@ -19018,10 +19027,68 @@ export class OrcaRuntimeService { ...(tabId ? { tabId } : {}), ...(ptyId ? { ptyId } : {}) }) + return true } catch { // No authoritative window (shutdown/headless): the subscribe keeps its // existing empty-snapshot fallback. + return false + } + } + + getRendererTerminalSerializerGeneration(ptyId: string): number { + return this.ptyController?.getRendererSerializerGeneration?.(ptyId) ?? 0 + } + + getRendererTerminalSerializerGenerationForHandle(handle: string): number { + const ptyId = this.handles.get(handle)?.ptyId + return ptyId ? this.getRendererTerminalSerializerGeneration(ptyId) : 0 + } + + replaceHeadlessTerminalFromRendererSnapshotForRecovery( + ptyId: string, + snapshot: { + data: string + cols: number + rows: number + cwd?: string | null + oscLinks?: TerminalOscLinkRange[] + }, + trailingOutput: { data: string; seq: number }[] = [] + ): void { + if (!snapshot.data) { + return } + // Why: a redraw byte can create a suffix-only model before the restored + // renderer settles. Replace it with the exact snapshot already sent mobile. + this.providerSnapshotPreferredPtys.add(ptyId) + this.disposeHeadlessTerminal(ptyId) + this.seedHeadlessTerminal( + ptyId, + snapshot.data, + { cols: snapshot.cols, rows: snapshot.rows }, + { cwd: snapshot.cwd, oscLinks: snapshot.oscLinks } + ) + for (const chunk of trailingOutput) { + this.trackHeadlessTerminalData(ptyId, chunk.data, chunk.seq) + } + // The seed's write chain already owns subsequent live bytes; suppress the + // ordinary on-data hydration path from replacing this known-good seed. + this.headlessHydrationState.set(ptyId, 'done') + } + + waitForRendererTerminalSerializer( + ptyId: string, + afterGeneration: number, + timeoutMs?: number, + signal?: AbortSignal + ): Promise { + return this.ptyController?.waitForRendererSerializer?.( + ptyId, + afterGeneration, + timeoutMs, + signal + ) ?? + Promise.resolve(false) } // Why: a leaf appears in the graph before its PTY spawns. If we issue a diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index b2bf80004d9..445a1f204e2 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -50,6 +50,9 @@ const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024 // payload bytes rather than UTF-16 code units. const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024 const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024 +// Why: keep initial subscribe latency bounded; readiness remains observed after +// this deadline and triggers an in-stream recovery snapshot when it arrives. +const MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS = 3_000 let nextTerminalStreamId = 1 type SnapshotFrameOptions = { @@ -650,6 +653,44 @@ async function serializeBudgetedMobileSnapshot( return null } +async function serializeStableMobileRendererSnapshot( + runtime: OrcaRuntimeService, + ptyId: string +): Promise { + const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] + let candidateIndex = 0 + for (let attempt = 0; attempt < candidates.length; attempt += 1) { + // Why: stability retries share the six-call snapshot budget. Advance + // toward zero scrollback so the final attempt always has a bounded payload. + candidateIndex = Math.max(candidateIndex, attempt) + const rows = candidates[candidateIndex] + const outputSequenceBefore = runtime.getPtyOutputSequence(ptyId) + const serialized = await runtime.serializeRendererTerminalBuffer(ptyId, { + scrollbackRows: rows + }) + const outputSequenceAfter = runtime.getPtyOutputSequence(ptyId) + if (outputSequenceBefore !== outputSequenceAfter) { + continue + } + if (!serialized) { + return null + } + const overByteBudget = terminalStreamByteLengthExceeds( + serialized.data, + MOBILE_SNAPSHOT_BYTE_BUDGET + ) + if (!overByteBudget || rows === 0) { + return { + ...serialized, + scrollbackRows: rows, + truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget + } + } + candidateIndex += 1 + } + return null +} + // Why: mobile xterm can only re-wrap SOFT-wrapped lines on a client-side // term.resize(); the restored scrollback snapshot contains HARD newlines from // the host serialization, so a width change leaves prior output wrapped at the @@ -2355,8 +2396,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ) => { let leaf = runtime.resolveLeafForHandle(params.terminal) const isMobile = params.client?.type === 'mobile' + const serializerGenerationBeforeAnyMount = isMobile + ? (runtime.getRendererTerminalSerializerGenerationForHandle?.(params.terminal) ?? 0) + : 0 + let rendererMountRequestedBeforePty = false const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary) - let rendererMountRequested = false // Why: a closed stream must not allocate listeners, mobile-fit state, or // a hidden renderer surface that no client remains to consume. if (signal?.aborted) { @@ -2370,8 +2414,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (!leaf?.ptyId && isMobile) { // Why: a never-mounted tab has no graph leaf to await; mounting the // exact tab lets its PTY attach without activating the worktree. - runtime.requestRendererTerminalTabMount(params.terminal) - rendererMountRequested = true + rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal) try { const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal) leaf = { ptyId } @@ -2401,6 +2444,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const ptyId = leaf.ptyId const clientId = params.client?.id + // Why: the initial mount/PTY wait and phone-fit can both emit a redraw + // that creates suffix-only state, so preserve the pre-mount absence signal. + const missingHeadlessStateBeforeMobileFit = + isMobile && + (rendererMountRequestedBeforePty || runtime.hasHeadlessTerminalState?.(ptyId) === false) + const serializerGenerationBeforeMobileFit = missingHeadlessStateBeforeMobileFit + ? rendererMountRequestedBeforePty + ? serializerGenerationBeforeAnyMount + : runtime.getRendererTerminalSerializerGeneration(ptyId) + : 0 const supportsDesktopViewportClaims = params.capabilities?.desktopViewportClaims === 1 // Why: only unregister the width floor this subscription took (see the // multiplex stream's registeredRemoteDesktopDriver note). @@ -2542,6 +2595,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let unsubscribeResize = (): void => {} let unsubscribeFit = (): void => {} let unregisterBinaryHandler = (): void => {} + let abortRendererMountWait = (): void => {} + let lateRendererReadyPromise: Promise | null = null let outputBatcher: ReturnType | null = null let resolveStream = (): void => {} const streamClosed = new Promise((resolve) => { @@ -2561,6 +2616,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ unsubscribeResize() unsubscribeFit() unregisterBinaryHandler() + abortRendererMountWait() if (isMobile && clientId) { runtime.handleMobileUnsubscribe(ptyId, clientId) } else if (registeredRemoteDesktopDriver && clientId) { @@ -2784,12 +2840,80 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } // Why: missing model state—not snapshot text—is the signal that this // PTY may never have attached; avoid remounting legitimate blank panes. - if ( - isMobile && - !rendererMountRequested && - runtime.hasHeadlessTerminalState?.(ptyId) === false - ) { - runtime.requestRendererTerminalTabMount(params.terminal) + // A renderer-sourced snapshot also proves the exact pane is already + // attached, so waiting for a fresh mount generation would only stall. + const mountRequested = + missingHeadlessStateBeforeMobileFit && + serialized?.source !== 'renderer' && + (rendererMountRequestedBeforePty || + runtime.requestRendererTerminalTabMount(params.terminal)) + if (missingHeadlessStateBeforeMobileFit && mountRequested) { + // Why: an idle legacy PTY emits no later byte; a fresh settle proves + // this exact remount completed before we replay its restored screen. + const mountWaitController = new AbortController() + const abortMountWait = (): void => mountWaitController.abort() + abortRendererMountWait = abortMountWait + if (signal?.aborted) { + abortMountWait() + } else { + signal?.addEventListener('abort', abortMountWait, { once: true }) + } + const rendererReadyPromise = runtime + .waitForRendererTerminalSerializer( + ptyId, + serializerGenerationBeforeMobileFit, + undefined, + mountWaitController.signal + ) + .catch(() => false) + const finishMountWait = (): void => { + signal?.removeEventListener('abort', abortMountWait) + if (abortRendererMountWait === abortMountWait) { + abortRendererMountWait = () => {} + } + } + void rendererReadyPromise.then(finishMountWait, finishMountWait) + let deadlineTimer: ReturnType | null = null + const initialDeadline = new Promise((resolve) => { + deadlineTimer = setTimeout(() => resolve(false), MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS) + if (typeof deadlineTimer.unref === 'function') { + deadlineTimer.unref() + } + }) + const rendererReady = await Promise.race([rendererReadyPromise, initialDeadline]) + if (deadlineTimer) { + clearTimeout(deadlineTimer) + } + if (closed || signal?.aborted) { + return + } + if (rendererReady) { + read = await runtime.readTerminal(params.terminal) + const stableRendererSnapshot = await serializeStableMobileRendererSnapshot( + runtime, + ptyId + ) + if (closed) { + return + } + if (stableRendererSnapshot?.data.length) { + serialized = stableRendererSnapshot + const trailingOutput = pendingOutput.flatMap((item) => { + const data = getOutputAfterSnapshotSeq(item, stableRendererSnapshot.seq) + const seq = item.meta?.seq + return data && typeof seq === 'number' ? [{ data, seq }] : [] + }) + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery( + ptyId, + stableRendererSnapshot, + trailingOutput + ) + } + } else { + // Why: a renderer can settle after the bounded initial response. + // Keep observing it so an idle PTY still self-heals without bytes. + lateRendererReadyPromise = rendererReadyPromise + } } let initialOutputOverflowed = false if (pendingOutputOverflowed) { @@ -2951,6 +3075,54 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } pendingOutputBytes = 0 outputBatcher.flush() + const lateRendererReady = lateRendererReadyPromise + lateRendererReadyPromise = null + if (lateRendererReady) { + void lateRendererReady + .then(async (rendererReady) => { + if (!rendererReady || closed) { + return + } + outputBatcher?.flush() + const recovery = await serializeStableMobileRendererSnapshot(runtime, ptyId) + if (closed) { + return + } + if (!recovery?.data.length) { + return + } + // Why: late recovery has no buffered-output gate. Only an exact + // renderer high-water may reset mobile without erasing live bytes. + if (recovery.seq !== runtime.getPtyOutputSequence(ptyId)) { + return + } + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(ptyId, recovery) + // Why: shipped mobile clients apply resized snapshots in place, + // allowing a blank initialized xterm to recover without resubscribe. + const recoveryStats = sendSnapshotFrames(sendFrame, { + kind: 'resized', + cols: recovery.cols, + rows: recovery.rows, + displayMode, + reason: 'renderer-mount-ready', + source: recovery.source, + truncated: false, + truncatedByByteBudget: recovery.truncatedByByteBudget, + data: recovery.data + }) + lastResizeCols = recovery.cols + console.log('[mobile-terminal-stream] recovery snapshot', { + terminal: params.terminal, + streamId, + reason: 'renderer-mount-ready', + bytes: recoveryStats.bytes, + chunks: recoveryStats.chunks, + scrollbackRows: recovery.scrollbackRows, + truncatedByByteBudget: recovery.truncatedByByteBudget === true + }) + }) + .catch(() => {}) + } const sendResizedFrame = (event: { cols: number rows: number diff --git a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts index f80a4d69c2f..89a46d36073 100644 --- a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts @@ -10,7 +10,13 @@ function stubRuntime(overrides: Partial = {}): OrcaRuntimeSe return { getRuntimeId: () => 'test-runtime', registerRemoteTerminalViewSubscriber: () => () => {}, - requestRendererTerminalTabMount: () => {}, + requestRendererTerminalTabMount: () => false, + getRendererTerminalSerializerGenerationForHandle: () => 0, + getRendererTerminalSerializerGeneration: () => 0, + waitForRendererTerminalSerializer: async () => false, + getPtyOutputSequence: () => 0, + replaceHeadlessTerminalFromRendererSnapshotForRecovery: () => {}, + serializeRendererTerminalBuffer: async () => null, hasHeadlessTerminalState: () => true, ...overrides } as OrcaRuntimeService @@ -27,7 +33,7 @@ describe('terminal.subscribe blank-tab background mount', () => { it('does not mount a hidden tab for an already-aborted mobile subscribe', async () => { const controller = new AbortController() controller.abort() - const requestRendererTerminalTabMount = vi.fn() + const requestRendererTerminalTabMount = vi.fn(() => true) const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue(null), requestRendererTerminalTabMount, @@ -63,6 +69,7 @@ describe('terminal.subscribe blank-tab background mount', () => { const unsubscribeData = vi.fn() const requestRendererTerminalTabMount = vi.fn(() => { callOrder.push('request-mount') + return true }) const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), @@ -121,7 +128,7 @@ describe('terminal.subscribe blank-tab background mount', () => { it('does not request a renderer tab mount when an attached terminal is legitimately blank', async () => { const cleanups = new Map void>() - const requestRendererTerminalTabMount = vi.fn() + const requestRendererTerminalTabMount = vi.fn(() => true) const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), requestRendererTerminalTabMount, @@ -167,4 +174,62 @@ describe('terminal.subscribe blank-tab background mount', () => { runtime.cleanupSubscription('terminal-1:phone-1') await dispatchPromise }) + + it('does not wait for a remount when the current snapshot came from the renderer', async () => { + const cleanups = new Map void>() + const requestRendererTerminalTabMount = vi.fn(() => true) + const waitForRendererTerminalSerializer = vi.fn() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + requestRendererTerminalTabMount, + waitForRendererTerminalSerializer, + hasHeadlessTerminalState: vi.fn().mockReturnValue(false), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'current renderer prompt $ ', + cols: 80, + rows: 24, + source: 'renderer' + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + expect(requestRendererTerminalTabMount).not.toHaveBeenCalled() + expect(waitForRendererTerminalSerializer).not.toHaveBeenCalled() + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) }) diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index 84e96ddd77f..5c02a25326f 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -17,7 +17,7 @@ function stubRuntime(overrides: Partial = {}): OrcaRuntimeSe // Why: subscribe streams register as remote view subscribers for Phase-5 // query-authority suppression (terminal-query-authority.md). registerRemoteTerminalViewSubscriber: () => () => {}, - requestRendererTerminalTabMount: () => {}, + requestRendererTerminalTabMount: () => false, ...overrides } as OrcaRuntimeService } diff --git a/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts b/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts new file mode 100644 index 00000000000..3a7614587f1 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts @@ -0,0 +1,437 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' + +const request: RpcRequest = { + id: 'req-1', + authToken: 'tok', + method: 'terminal.subscribe', + params: { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + } +} + +describe('terminal subscribe mount replay', () => { + it('includes the restored idle screen when a missing model is background-mounted', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let mounted = false + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + hasHeadlessTerminalState: vi.fn(() => mounted), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 3), + getRendererTerminalSerializerGeneration: vi.fn(() => 3), + getPtyOutputSequence: vi.fn(() => (mounted ? 4 : 0)), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn(async (_ptyId, afterGeneration) => { + expect(afterGeneration).toBe(3) + mounted = true + return true + }), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn(async () => + mounted + ? { data: 'idle prompt $ ', cols: 80, rows: 24, seq: 4 } + : { data: '', cols: 80, rows: 24, seq: 1 } + ), + serializeRendererTerminalBuffer: vi.fn(async () => + mounted ? { data: 'idle prompt $ ', cols: 80, rows: 24, seq: 4 } : null + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => expect(runtime.requestRendererTerminalTabMount).toHaveBeenCalled()) + await vi.waitFor(() => + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('idle prompt $ ') + ) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + + it('prefers restored history when phone-fit creates suffix-only headless state', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let generation = 1 + let headlessPresent = false + let serializeCalls = 0 + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + hasHeadlessTerminalState: vi.fn(() => headlessPresent), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => generation), + getRendererTerminalSerializerGeneration: vi.fn(() => generation), + getPtyOutputSequence: vi.fn(() => 0), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn(async (_ptyId, afterGeneration) => { + return generation > afterGeneration + }), + handleMobileSubscribe: vi.fn(async () => { + headlessPresent = true + generation = 2 + return true + }), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn(async () => { + serializeCalls += 1 + if (serializeCalls === 1) { + return { data: 'suffix-only redraw', cols: 80, rows: 24, seq: 2 } + } + return { data: 'raced idle prompt $ ', cols: 80, rows: 24, seq: 5 } + }), + serializeRendererTerminalBuffer: vi.fn(async () => ({ + data: 'raced idle prompt $ ', + cols: 80, + rows: 24, + seq: 5 + })), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('raced idle prompt $ ') + ) + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).not.toContain('suffix-only redraw') + expect(runtime.waitForRendererTerminalSerializer).toHaveBeenCalledWith( + 'pty-1', + 1, + undefined, + expect.any(AbortSignal) + ) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + + it('replays a late recovery when readiness lands after the bounded initial response', async () => { + vi.useFakeTimers() + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let mounted = false + let signalWaitStarted!: () => void + const waitStarted = new Promise((resolve) => { + signalWaitStarted = resolve + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-delayed' }), + hasHeadlessTerminalState: vi.fn(() => false), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 1), + getRendererTerminalSerializerGeneration: vi.fn(() => 1), + getPtyOutputSequence: vi.fn(() => (mounted ? 4 : 0)), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn( + (_ptyId: string, _afterGeneration: number, timeoutMs: number | undefined) => { + expect(timeoutMs).toBeUndefined() + signalWaitStarted() + return new Promise((resolve) => { + setTimeout(() => { + mounted = true + resolve(true) + }, 3_001) + }) + } + ), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn(async () => ({ + data: mounted ? 'delayed idle prompt $ ' : 'late suffix-only redraw', + cols: 80, + rows: 24, + seq: mounted ? 4 : 1 + })), + serializeRendererTerminalBuffer: vi.fn(async () => + mounted ? { data: 'delayed idle prompt $ ', cols: 80, rows: 24, seq: 4 } : null + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await waitStarted + await vi.advanceTimersByTimeAsync(3_000) + await Promise.resolve() + expect(binaryFrames.length).toBeGreaterThan(0) + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('late suffix-only redraw') + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).not.toContain('delayed idle prompt $ ') + await vi.advanceTimersByTimeAsync(1) + await Promise.resolve() + await Promise.resolve() + + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('delayed idle prompt $ ') + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + vi.useRealTimers() + }) + + it('recovers from the pre-mount generation when suffix state appears during the PTY wait', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let generation = 0 + let headlessPresent = false + const requestRendererTerminalTabMount = vi.fn(() => { + generation = 1 + return true + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue(null), + waitForLeafPtyId: vi.fn(async () => { + // A redraw may reach main before the PTY wait completes, but it does + // not contain the legacy terminal's restored history. + headlessPresent = true + return 'pty-late-leaf' + }), + hasHeadlessTerminalState: vi.fn(() => headlessPresent), + requestRendererTerminalTabMount, + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 0), + getRendererTerminalSerializerGeneration: vi.fn(() => generation), + getPtyOutputSequence: vi.fn(() => 0), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn(async (_ptyId, afterGeneration) => { + return generation > afterGeneration + }), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'suffix-only redraw', + cols: 80, + rows: 24, + seq: 1 + }), + serializeRendererTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'late leaf prompt $ ', + cols: 80, + rows: 24 + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('late leaf prompt $ ') + ) + expect(requestRendererTerminalTabMount).toHaveBeenCalledTimes(1) + expect(runtime.waitForRendererTerminalSerializer).toHaveBeenCalledWith( + 'pty-late-leaf', + 0, + undefined, + expect.any(AbortSignal) + ) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + + it('cancels the mount-ready wait when the mobile subscription closes', async () => { + const cleanups = new Map void>() + let waitSignal: AbortSignal | undefined + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + hasHeadlessTerminalState: vi.fn(() => false), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 1), + getRendererTerminalSerializerGeneration: vi.fn(() => 1), + getPtyOutputSequence: vi.fn(() => 0), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn( + (_ptyId: string, _afterGeneration: number, _timeoutMs: number, signal?: AbortSignal) => { + waitSignal = signal + return new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true }) + }) + } + ), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + serializeRendererTerminalBuffer: vi.fn().mockResolvedValue(null), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => expect(runtime.waitForRendererTerminalSerializer).toHaveBeenCalled()) + runtime.cleanupSubscription('terminal-1:phone-1') + + expect(waitSignal?.aborted).toBe(true) + await dispatchPromise + }) +}) diff --git a/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts b/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts new file mode 100644 index 00000000000..91cd571e0f4 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' + +const request: RpcRequest = { + id: 'req-output-race', + authToken: 'tok', + method: 'terminal.subscribe', + params: { + terminal: 'terminal-output-race', + client: { id: 'phone-output-race', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + } +} + +describe('terminal subscribe renderer recovery output ordering', () => { + it('replays bytes absent from the renderer snapshot using its exact sequence boundary', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let outputSequence = 0 + let rendererSerializeCalls = 0 + let onData: + | ((data: string, meta?: { seq?: number; rawLength?: number; cwd?: string }) => void) + | undefined + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-output-race' }), + hasHeadlessTerminalState: vi.fn(() => false), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 1), + getRendererTerminalSerializerGeneration: vi.fn(() => 1), + waitForRendererTerminalSerializer: vi.fn().mockResolvedValue(true), + getPtyOutputSequence: vi.fn(() => outputSequence), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn((_ptyId, listener) => { + onData = listener + return vi.fn() + }), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + serializeRendererTerminalBuffer: vi.fn(async () => { + rendererSerializeCalls += 1 + if (rendererSerializeCalls === 1) { + outputSequence = 6 + onData?.('during', { seq: 6, rawLength: 6 }) + return { data: 'stale renderer', cols: 80, rows: 24 } + } + return { data: 'restored history', cols: 80, rows: 24, seq: 0 } + }), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(() => { + outputSequence = 11 + onData?.('after', { seq: 11, rawLength: 5 }) + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => { + const output = binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + expect(output).toBe('duringafter') + }) + + const snapshot = binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + expect(snapshot).toBe('restored history') + expect(rendererSerializeCalls).toBe(2) + + runtime.cleanupSubscription('terminal-output-race:phone-output-race') + await dispatchPromise + }) +}) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 6315416e41a..f0667b3686b 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1407,11 +1407,12 @@ export type PreloadApi = { onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void sendSerializedBuffer: ( requestId: string, - snapshot: { data: string; cols: number; rows: number; lastTitle?: string } | null + snapshot: { data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null ) => void declarePendingPaneSerializer: (paneKey: string) => Promise settlePaneSerializer: (paneKey: string, gen: number) => Promise clearPendingPaneSerializer: (paneKey: string, gen: number) => Promise + reportRendererSerializerReady?: (ptyId: string) => Promise management: PtyManagementApi } feedback: { diff --git a/src/preload/index.ts b/src/preload/index.ts index d879c2c65f3..e2535534578 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1140,7 +1140,7 @@ const api = { sendSerializedBuffer: ( requestId: string, - snapshot: { data: string; cols: number; rows: number; lastTitle?: string } | null + snapshot: { data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null ): void => { ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot }) }, @@ -1159,6 +1159,9 @@ const api = { clearPendingPaneSerializer: (paneKey: string, gen: number): Promise => ipcRenderer.invoke('pty:clearPendingPaneSerializer', { paneKey, gen }), + reportRendererSerializerReady: (ptyId: string): Promise => + ipcRenderer.invoke('pty:reportRendererSerializerReady', { ptyId }), + management: { listSessions: () => ipcRenderer.invoke('pty:management:listSessions'), killAll: () => ipcRenderer.invoke('pty:management:killAll'), diff --git a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts index 2907e2b3341..436982b061b 100644 --- a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts @@ -78,4 +78,25 @@ describe('pty buffer serializer registry', () => { expect(window.api.pty.sendSerializedBuffer).toHaveBeenCalledWith('request-1', null) }) + + it('preserves the renderer-parsed output sequence in the response', async () => { + const { registerPtySerializer } = await import('./pty-buffer-serializer') + registerPtySerializer('pty-sequenced', () => ({ + data: 'parsed output', + cols: 80, + rows: 24, + seq: 42 + })) + const serializeRequestHandler = vi.mocked(window.api.pty.onSerializeBufferRequest).mock + .calls[0]?.[0] + + serializeRequestHandler?.({ requestId: 'request-sequenced', ptyId: 'pty-sequenced' }) + await Promise.resolve() + await Promise.resolve() + + expect(window.api.pty.sendSerializedBuffer).toHaveBeenCalledWith( + 'request-sequenced', + expect.objectContaining({ seq: 42 }) + ) + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts index 2958b5aa08f..c32ea0aafaf 100644 --- a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts +++ b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts @@ -14,6 +14,7 @@ export type SerializedBuffer = { data: string cols: number rows: number + seq?: number lastTitle?: string } @@ -148,6 +149,9 @@ function ensureSerializerListener(): void { cols: result.cols, rows: result.rows } + if (result.seq !== undefined) { + payload.seq = result.seq + } if (lastTitle !== undefined) { payload.lastTitle = lastTitle } else if (result.lastTitle !== undefined) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index ac0598f5396..8e3686326ce 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -225,6 +225,7 @@ function resolveMockPaneWindowsShiftEnterEncoding( } type ConnectCallbacks = { + onConnect?: () => void onData?: ( data: string, meta?: { seq?: number; rawLength?: number; background?: boolean; droppedOutput?: boolean } @@ -866,7 +867,8 @@ describe('connectPanePty', () => { sendSerializedBuffer: vi.fn(), declarePendingPaneSerializer: vi.fn().mockResolvedValue(1), settlePaneSerializer: vi.fn().mockResolvedValue(undefined), - clearPendingPaneSerializer: vi.fn().mockResolvedValue(undefined) + clearPendingPaneSerializer: vi.fn().mockResolvedValue(undefined), + reportRendererSerializerReady: vi.fn().mockResolvedValue(undefined) }, platform: { get: vi.fn(() => ({ platform: 'win32', osRelease: '10.0.26100' })) @@ -13565,8 +13567,10 @@ describe('connectPanePty', () => { it('attaches remote runtime PTY handles instead of creating a replacement terminal', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() - transport.attach.mockImplementation(() => { + transport.attach.mockImplementation(({ callbacks }: { callbacks?: ConnectCallbacks }) => { transport.getPtyId.mockReturnValue('remote:env-1@@terminal-1') + callbacks?.onReplayData?.('restored remote prompt $ ') + callbacks?.onConnect?.() }) transportFactoryQueue.push(transport) @@ -13582,10 +13586,12 @@ describe('connectPanePty', () => { } as StoreState const pane = createPane(2) + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => callback?.()) const manager = createManager(2) const deps = createDeps() connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() expect(transport.connect).not.toHaveBeenCalled() expect(transport.attach).toHaveBeenCalledWith( @@ -13593,6 +13599,60 @@ describe('connectPanePty', () => { ) expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1') expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:env-1@@terminal-1') + await vi.waitFor(() => + expect(window.api.pty.reportRendererSerializerReady).toHaveBeenCalledWith( + 'remote:env-1@@terminal-1' + ) + ) + }) + + it('reports remote renderer readiness only after delayed restore output is parsed', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + let remoteCallbacks: ConnectCallbacks | undefined + transport.attach.mockImplementation( + ({ callbacks }: { callbacks?: ConnectCallbacks }) => { + transport.getPtyId.mockReturnValue('remote:env-1@@terminal-delayed') + remoteCallbacks = callbacks + } + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'remote:terminal-delayed' }] + }, + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'env-1' + } + } as StoreState + const pane = createPane(2) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty(pane as never, createManager(2) as never, createDeps() as never) + await flushAsyncTicks() + expect(window.api.pty.reportRendererSerializerReady).not.toHaveBeenCalled() + + remoteCallbacks?.onReplayData?.('delayed restored prompt $ ') + remoteCallbacks?.onConnect?.() + await vi.waitFor(() => expect(parseCallbacks.length).toBeGreaterThan(0)) + expect(window.api.pty.reportRendererSerializerReady).not.toHaveBeenCalled() + + for (let step = 0; step < 10; step += 1) { + await vi.waitFor(() => expect(parseCallbacks.length).toBeGreaterThan(0)) + parseCallbacks.shift()?.() + await flushAsyncTicks() + if (vi.mocked(window.api.pty.reportRendererSerializerReady!).mock.calls.length > 0) { + break + } + } + expect(writes.join('')).toContain('delayed restored prompt $ ') + await vi.waitFor(() => + expect(window.api.pty.reportRendererSerializerReady).toHaveBeenCalledWith( + 'remote:env-1@@terminal-delayed' + ) + ) }) it('cold-spawns slept remote runtime PTYs instead of reattaching the preserved handle', async () => { @@ -13612,6 +13672,9 @@ describe('connectPanePty', () => { | ((ptyId: string) => void) | undefined onPtySpawn?.(freshPtyId) + const connectCallbacks = callbacks as ConnectCallbacks | undefined + connectCallbacks?.onReplayData?.('shell ready\r\n') + connectCallbacks?.onConnect?.() return freshPtyId } ) @@ -13646,6 +13709,7 @@ describe('connectPanePty', () => { } as StoreState const pane = createPane(1) + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => callback?.()) const manager = createManager(1) const deps = createDeps({ restoredLeafId: LEAF_1, @@ -13685,6 +13749,7 @@ describe('connectPanePty', () => { expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', restoredPtyId) expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, freshPtyId) expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', freshPtyId) + expect(window.api.pty.reportRendererSerializerReady).toHaveBeenCalledWith(freshPtyId) expect(transport.sendInput).not.toHaveBeenCalled() expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) }) @@ -15813,7 +15878,9 @@ describe('connectPanePty', () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() transport.connect.mockImplementation(async (opts: { sessionId?: string }) => { - return { id: opts.sessionId ?? 'pty-new', replay: 'restored-ssh-output' } + const id = opts.sessionId ?? 'pty-new' + transport.getPtyId.mockReturnValue(id) + return { id, replay: 'restored-ssh-output' } }) transportFactoryQueue.push(transport) @@ -15826,6 +15893,7 @@ describe('connectPanePty', () => { } const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) const manager = createManager(1) const deps = createDeps({ restoredLeafId: LEAF_1, @@ -15835,6 +15903,15 @@ describe('connectPanePty', () => { connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(20) + const settlePaneSerializer = vi.mocked(window.api.pty.settlePaneSerializer) + expect(parseCallbacks.length).toBeGreaterThan(0) + expect(settlePaneSerializer).not.toHaveBeenCalled() + for (let step = 0; step < 20 && settlePaneSerializer.mock.calls.length === 0; step += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks() + } + expect(settlePaneSerializer).toHaveBeenCalledWith(expect.any(String), 1) + const api = ( globalThis as unknown as { window: { @@ -15855,12 +15932,9 @@ describe('connectPanePty', () => { // Why: the relay's replay buffer holds the full terminal history, so the // client clears xterm before writing to prevent duplication with any // content already in the terminal from a prior session. - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', expect.any(Function)) - expect(pane.terminal.write).toHaveBeenCalledWith('restored-ssh-output', expect.any(Function)) - expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_REATTACH_RESET, - expect.any(Function) - ) + expect(writes).toContain('\x1b[2J\x1b[3J\x1b[H') + expect(writes).toContain('restored-ssh-output') + expect(writes).toContain(POST_REPLAY_REATTACH_RESET) expect(api.pty.signal).toHaveBeenCalledWith('leaf-session', 'SIGWINCH') }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index dba323e3d36..d381c8c7547 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -98,7 +98,11 @@ import { } from '../../../../shared/terminal-color-scheme-protocol' import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics' import { subscribeToTerminalUserInput } from './terminal-user-input-signal' -import { registerPtySerializer, registerPtyTitleSource } from './pty-buffer-serializer' +import { + hasPtySerializer, + registerPtySerializer, + registerPtyTitleSource +} from './pty-buffer-serializer' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' import { @@ -3980,7 +3984,10 @@ export function connectPanePty( return { data, cols: pane.terminal.cols, - rows: pane.terminal.rows + rows: pane.terminal.rows, + ...(rendererOrderedPtyId === ptyId && rendererOrderedSeq !== null + ? { seq: rendererOrderedSeq } + : {}) } } catch { return null @@ -4003,6 +4010,47 @@ export function connectPanePty( } } + let replayWriteQueue = Promise.resolve() + const settlePaneSerializerAfterReplay = async ( + ptyId: string, + generation: number + ): Promise => { + try { + await replayWriteQueue + if (disposed || transport.getPtyId() !== ptyId) { + await window.api.pty.clearPendingPaneSerializer(cacheKey, generation).catch(() => {}) + return + } + await waitForTerminalOutputParsed(pane.terminal) + if (!disposed && transport.getPtyId() === ptyId) { + await window.api.pty.settlePaneSerializer(cacheKey, generation) + return + } + } catch { + // Clear below so a failed parser/replay cannot leave the pane generation pending. + } + await window.api.pty.clearPendingPaneSerializer(cacheKey, generation).catch(() => {}) + } + const reportRemoteRendererSerializerReady = (): void => { + const ptyId = transport.getPtyId() + if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { + return + } + if (!hasPtySerializer(ptyId)) { + registerPaneSerializerFor(ptyId) + } + // Why: onSubscribed follows the snapshot callback, but replay drains + // asynchronously; join it and xterm's parser before reporting readiness. + void replayWriteQueue + .then(() => waitForTerminalOutputParsed(pane.terminal)) + .then(() => { + if (!disposed && transport.getPtyId() === ptyId) { + void window.api.pty.reportRendererSerializerReady?.(ptyId) + } + }) + .catch(() => {}) + } + // Why: for ordinary local startup commands, the local PTY provider already // writes via the shell-ready barrier. terminal-paste and SSH startup // commands stay renderer-delivered so xterm/relay can apply their handling. @@ -4422,6 +4470,7 @@ export function connectPanePty( ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { + onConnect: reportRemoteRendererSerializerReady, onData: dataCallback, onReplayData: replayDataCallback, onError: reportError @@ -4489,9 +4538,14 @@ export function connectPanePty( reconcilePtySizeAfterSpawn(resolvedPtyId, cols, rows) } const gen = await preSignalPromise - if (typeof gen === 'number' && resolvedPtyId) { - if (!isRemoteRuntimePtyId(resolvedPtyId)) { + if ( + resolvedPtyId && + (typeof gen === 'number' || isRemoteRuntimePtyId(resolvedPtyId)) + ) { + if (!isRemoteRuntimePtyId(resolvedPtyId) || !hasPtySerializer(resolvedPtyId)) { registerPaneSerializerFor(resolvedPtyId) + } + if (typeof gen === 'number') { void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) } } else if (typeof gen === 'number') { @@ -4738,7 +4792,6 @@ export function connectPanePty( }) } - let replayWriteQueue = Promise.resolve() type PendingReplayData = { data: string clearBeforeReplay: boolean @@ -7077,6 +7130,7 @@ export function connectPanePty( ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { + onConnect: reportRemoteRendererSerializerReady, onData: dataCallback, onReplayData: replayDataCallback, onError: (message) => { @@ -7125,7 +7179,17 @@ export function connectPanePty( const gen = await preSignalPromise if (typeof gen === 'number') { if (!isRemoteRuntimePtyId(pendingSessionId)) { - void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + const settledPtyId = + result && typeof result === 'object' && 'id' in result + ? result.id + : (transport.getPtyId() ?? pendingSessionId) + const hasRestorePayload = + result && + typeof result === 'object' && + ('snapshot' in result || 'replay' in result || 'coldRestore' in result) + await (hasRestorePayload + ? settlePaneSerializerAfterReplay(settledPtyId, gen) + : window.api.pty.settlePaneSerializer(cacheKey, gen)) } } }) @@ -7271,6 +7335,7 @@ export function connectPanePty( ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { + onConnect: reportRemoteRendererSerializerReady, onData: dataCallback, onReplayData: replayDataCallback, onError: (message) => { @@ -7315,7 +7380,17 @@ export function connectPanePty( const gen = await preSignalPromise if (typeof gen === 'number') { if (!isRemoteRuntimePtyId(deferredReattachSessionId)) { - void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + const settledPtyId = + result && typeof result === 'object' && 'id' in result + ? result.id + : (transport.getPtyId() ?? deferredReattachSessionId) + const hasRestorePayload = + result && + typeof result === 'object' && + ('snapshot' in result || 'replay' in result || 'coldRestore' in result) + await (hasRestorePayload + ? settlePaneSerializerAfterReplay(settledPtyId, gen) + : window.api.pty.settlePaneSerializer(cacheKey, gen)) } } }) @@ -7374,6 +7449,7 @@ export function connectPanePty( cols, rows, callbacks: { + onConnect: reportRemoteRendererSerializerReady, onData: dataCallback, onReplayData: replayDataCallback, onError: reportError @@ -7384,7 +7460,7 @@ export function connectPanePty( updateTabPtyId: 'if-missing', sampleVisibleForegroundAgent: true }) - if (attachPtyId === eagerLivePtyId) { + if (attachPtyId === eagerLivePtyId || isRemoteRuntimePtyId(attachedPtyId)) { registerPaneSerializerFor(attachedPtyId) } } catch (err) { @@ -7430,6 +7506,7 @@ export function connectPanePty( cols, rows, callbacks: { + onConnect: reportRemoteRendererSerializerReady, onData: dataCallback, onReplayData: replayDataCallback, onError: reportError diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index e84eadce112..b992c39204e 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2810,6 +2810,7 @@ function createPtyApi(): NonNullable['pty']> { declarePendingPaneSerializer: () => Promise.resolve(0), settlePaneSerializer: () => Promise.resolve(), clearPendingPaneSerializer: () => Promise.resolve(), + reportRendererSerializerReady: () => Promise.resolve(), management: { listSessions: () => Promise.resolve({ sessions: [], degraded: false }), killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }),