diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 989561da4e9..7ef9762b510 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6101,6 +6101,7 @@ describe('registerPtyHandlers', () => { env?: Record }): Promise<{ id: string }> hasRendererSerializer?(ptyId: string): boolean + getRendererSerializerGeneration?(ptyId: string): number } let controller: RuntimeSpawnController | null = null const runtime = { @@ -6128,10 +6129,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 e01b8544e8e..d04a84a506e 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -97,6 +97,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, @@ -259,15 +260,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) { @@ -305,6 +304,7 @@ function cleanupPendingPaneSerializersForSender(ownerWebContentsId: number): voi for (const [paneKey, pending] of pendingByPaneKey) { if (pending.ownerWebContentsId === ownerWebContentsId) { pendingByPaneKey.delete(paneKey) + pendingPtyIdBySerializerGeneration.delete(pending.gen) } } } @@ -320,7 +320,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 } @@ -372,10 +380,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 { @@ -1211,7 +1221,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 @@ -1222,17 +1231,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 @@ -1553,6 +1551,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') @@ -2793,7 +2792,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 } @@ -2819,6 +2824,7 @@ export function registerPtyHandlers( data?: unknown cols?: unknown rows?: unknown + seq?: unknown lastTitle?: unknown } | null } @@ -2833,11 +2839,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 } @@ -3335,6 +3350,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, @@ -3548,7 +3572,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) => { @@ -4317,14 +4347,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) } } @@ -5272,15 +5306,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) } } ) @@ -5292,6 +5324,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 2545980a8eb..8bc96e42075 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -8178,6 +8178,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 8e9be4cbb85..88e244b1a76 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1234,7 +1234,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, @@ -1244,6 +1244,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 } @@ -3299,7 +3306,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) + } } } @@ -6958,9 +6971,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 @@ -6970,6 +6983,10 @@ export class OrcaRuntimeService { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) } + hasHeadlessTerminalState(ptyId: string): boolean { + return this.headlessTerminals.has(ptyId) + } + serializeMainTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} @@ -6977,9 +6994,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 @@ -7415,13 +7432,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' @@ -7434,6 +7452,7 @@ export class OrcaRuntimeService { data: string cols: number rows: number + seq?: number cwd?: string | null lastTitle?: string oscLinks?: TerminalOscLinkRange[] @@ -19217,6 +19236,88 @@ 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): boolean { + const record = this.handles.get(handle) + if (!record?.worktreeId) { + return false + } + const tabId = record.tabId.startsWith('pty:') ? undefined : record.tabId + const ptyId = record.ptyId ?? undefined + if (!tabId && !ptyId) { + return false + } + try { + this.getAuthoritativeWindow().webContents.send('terminal:requestTabMount', { + worktreeId: record.worktreeId, + ...(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 // 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 @@ -22515,6 +22616,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/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 7a9b9e448a6..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,13 +2396,25 @@ 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) + // 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 // 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 never-mounted tab has no graph leaf to await; mounting the + // exact tab lets its PTY attach without activating the worktree. + rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal) try { const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal) leaf = { ptyId } @@ -2391,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). @@ -2532,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) => { @@ -2551,6 +2616,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ unsubscribeResize() unsubscribeFit() unregisterBinaryHandler() + abortRendererMountWait() if (isMobile && clientId) { runtime.handleMobileUnsubscribe(ptyId, clientId) } else if (registeredRemoteDesktopDriver && clientId) { @@ -2772,6 +2838,83 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (closed) { return } + // Why: missing model state—not snapshot text—is the signal that this + // PTY may never have attached; avoid remounting legitimate blank panes. + // 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) { pendingOutput.splice(0) @@ -2932,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 new file mode 100644 index 00000000000..89a46d36073 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts @@ -0,0 +1,235 @@ +/** 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' +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: () => false, + getRendererTerminalSerializerGenerationForHandle: () => 0, + getRendererTerminalSerializerGeneration: () => 0, + waitForRendererTerminalSerializer: async () => false, + getPtyOutputSequence: () => 0, + replaceHeadlessTerminalFromRendererSnapshotForRecovery: () => {}, + serializeRendererTerminalBuffer: async () => null, + hasHeadlessTerminalState: () => true, + ...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('does not mount a hidden tab for an already-aborted mobile subscribe', async () => { + const controller = new AbortController() + controller.abort() + const requestRendererTerminalTabMount = vi.fn(() => true) + 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>() + const callOrder: string[] = [] + const unsubscribeData = vi.fn() + const requestRendererTerminalTabMount = vi.fn(() => { + callOrder.push('request-mount') + return true + }) + 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(() => { + callOrder.push('subscribe-data') + return unsubscribeData + }), + 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 }), + 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') + ) + 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 () => { + const cleanups = new Map void>() + const requestRendererTerminalTabMount = vi.fn(() => true) + 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(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(runtime.handleMobileSubscribe).toHaveBeenCalled()) + expect(requestRendererTerminalTabMount).not.toHaveBeenCalled() + + 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 4a674f9df91..5c02a25326f 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -17,6 +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: () => 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/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..d84283e9181 --- /dev/null +++ b/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts @@ -0,0 +1,162 @@ +/** STA-1840 regression: known blank-terminal handles request a bounded renderer mount. */ +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 } + } + leaves: Map + issueHandle: (leaf: unknown) => string +} + +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('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 } + ]) + + 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: 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' } + ]) + + 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 92ba7bd92d9..d404f132226 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1411,11 +1411,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: { @@ -2841,6 +2842,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 05e1d62dafe..8c45e0e074d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1142,7 +1142,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 }) }, @@ -1161,6 +1161,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'), @@ -3512,6 +3515,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/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 e144c0ced8c..317742e68c7 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 8382693987f..351fc5b5e2f 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 { @@ -4020,7 +4024,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 @@ -4043,6 +4050,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. @@ -4462,6 +4510,7 @@ export function connectPanePty( ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { + onConnect: reportRemoteRendererSerializerReady, onData: dataCallback, onReplayData: replayDataCallback, onError: reportError @@ -4529,9 +4578,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') { @@ -4778,7 +4832,6 @@ export function connectPanePty( }) } - let replayWriteQueue = Promise.resolve() type PendingReplayData = { data: string clearBeforeReplay: boolean @@ -7117,6 +7170,7 @@ export function connectPanePty( ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { + onConnect: reportRemoteRendererSerializerReady, onData: dataCallback, onReplayData: replayDataCallback, onError: (message) => { @@ -7165,7 +7219,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)) } } }) @@ -7311,6 +7375,7 @@ export function connectPanePty( ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { + onConnect: reportRemoteRendererSerializerReady, onData: dataCallback, onReplayData: replayDataCallback, onError: (message) => { @@ -7355,7 +7420,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)) } } }) @@ -7414,6 +7489,7 @@ export function connectPanePty( cols, rows, callbacks: { + onConnect: reportRemoteRendererSerializerReady, onData: dataCallback, onReplayData: replayDataCallback, onError: reportError @@ -7424,7 +7500,7 @@ export function connectPanePty( updateTabPtyId: 'if-missing', sampleVisibleForegroundAgent: true }) - if (attachPtyId === eagerLivePtyId) { + if (attachPtyId === eagerLivePtyId || isRemoteRuntimePtyId(attachedPtyId)) { registerPaneSerializerFor(attachedPtyId) } } catch (err) { @@ -7470,6 +7546,7 @@ export function connectPanePty( cols, rows, callbacks: { + onConnect: reportRemoteRendererSerializerReady, onData: dataCallback, onReplayData: replayDataCallback, onError: reportError 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: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index efec4bae01c..b3e2dae6b17 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -14,6 +14,8 @@ 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 { 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' @@ -1666,6 +1668,32 @@ export function useIpcEvents(): void { ) ) + // 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: 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 } : {}) + }, + { + isTabMounted: hasRegisteredRuntimeTerminalTab + } + ) + if (mount) { + requestBackgroundTerminalWorktreeMount(mount) + } + }) + ) + // 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/mobile-terminal-tab-mount.test.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts new file mode 100644 index 00000000000..6529ebca17f --- /dev/null +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } 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() + }) + + 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() + + 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) + + 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 new file mode 100644 index 00000000000..28431cc5ad6 --- /dev/null +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.ts @@ -0,0 +1,41 @@ +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 +} + +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, + options: MobileTerminalTabMountOptions = {} +): BackgroundMountTerminalWorktreeDetail | null { + if (!request.worktreeId) { + return 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) + ? { worktreeId: request.worktreeId, tabIds: [tabId] } + : null +} 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..4fdc698e79e --- /dev/null +++ b/src/renderer/src/lib/terminal-tab-for-pty-id.test.ts @@ -0,0 +1,60 @@ +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 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 new file mode 100644 index 00000000000..77ee3c29706 --- /dev/null +++ b/src/renderer/src/lib/terminal-tab-for-pty-id.ts @@ -0,0 +1,27 @@ +import type { AppState } from '@/store/types' + +/** Resolve a synthetic mobile handle's ptyId through persisted tab and split bindings. */ +export function resolveTerminalTabIdForPtyId( + state: Pick, + worktreeId: string, + ptyId: string +): string | null { + const tabs = state.tabsByWorktree[worktreeId] ?? [] + let resolvedTabId: string | null = null + for (const tab of tabs) { + const ptyIdsByLeafId = state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId + 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 resolvedTabId +} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index ac19ddce7c8..14418329aee 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2373,6 +2373,7 @@ function createWebUiApi(): NonNullable['ui']> { onActivateWorktree: () => noopUnsubscribe, onCreateTerminal: () => noopUnsubscribe, onRequestTerminalCreate: () => noopUnsubscribe, + onRequestTerminalTabMount: () => noopUnsubscribe, replyTerminalCreate: () => {}, onSplitTerminal: () => noopUnsubscribe, onRenameTerminal: () => noopUnsubscribe, @@ -2814,6 +2815,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: [] }),