fix(mobile): recover legacy terminal history safely

This commit is contained in:
Jinwoo-H
2026-07-15 19:02:40 -04:00
parent 9e270c4484
commit 292199c32b
18 changed files with 1496 additions and 76 deletions
+108
View File
@@ -6085,6 +6085,7 @@ describe('registerPtyHandlers', () => {
env?: Record<string, string>
}): Promise<{ id: string }>
hasRendererSerializer?(ptyId: string): boolean
getRendererSerializerGeneration?(ptyId: string): number
}
let controller: RuntimeSpawnController | null = null
const runtime = {
@@ -6112,10 +6113,117 @@ describe('registerPtyHandlers', () => {
worktreeId: 'wt-1',
env: { ORCA_PANE_KEY: ` ${paneKey} ` }
})
const replacementGen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, {
paneKey
})) as number
expect(spawnController.hasRendererSerializer?.(result.id)).toBe(false)
await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen })
expect(spawnController.hasRendererSerializer?.(result.id)).toBe(false)
expect(spawnController.getRendererSerializerGeneration?.(result.id)).toBe(0)
await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: replacementGen })
expect(spawnController.hasRendererSerializer?.(result.id)).toBe(true)
expect(spawnController.getRendererSerializerGeneration?.(result.id)).toBe(1)
})
it('does not let old teardown cancel serializer settlement for a reused PTY id', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
env?: Record<string, string>
}): Promise<{ id: string }>
getRendererSerializerGeneration?(ptyId: string): number
hasRendererSerializer?(ptyId: string): boolean
waitForRendererSerializer?(
ptyId: string,
afterGeneration: number,
timeoutMs?: number
): Promise<boolean>
}
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<void> => {
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 () => {
+86 -37
View File
@@ -96,6 +96,7 @@ import {
} from '../../shared/terminal-input'
import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay'
import { createTerminalSessionStateSaveFailureMessage } from '../../shared/terminal-session-state-save-failure'
import { RendererTerminalSerializerReadiness } from './renderer-terminal-serializer-readiness'
import { readShellStartupEnvVar } from '../pty/shell-startup-env'
import {
isTerminalLeafId,
@@ -256,15 +257,13 @@ type PaneSpawnReservationResult = {
// race to spawn the same tab/leaf. Key by stable paneKey so the loser adopts
// the winner's PTY instead of creating a duplicate shell.
const paneSpawnReservationsByPaneKey = new Map<string, PaneSpawnReservation>()
// 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<string, number>()
// 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<number, string>()
// 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<string>()
// A later spawn starts a fresh incarnation; subscription abort owns waiter cleanup.
const rendererSerializerReadiness = new RendererTerminalSerializerReadiness()
function parseValidPaneKey(paneKey: unknown): ReturnType<typeof parsePaneKey> {
if (typeof paneKey !== 'string' || paneKey.length > 256) {
@@ -302,6 +301,7 @@ function cleanupPendingPaneSerializersForSender(ownerWebContentsId: number): voi
for (const [paneKey, pending] of pendingByPaneKey) {
if (pending.ownerWebContentsId === ownerWebContentsId) {
pendingByPaneKey.delete(paneKey)
pendingPtyIdBySerializerGeneration.delete(pending.gen)
}
}
}
@@ -317,7 +317,15 @@ function registerPendingPaneSerializerCleanup(sender: WebContents | undefined):
function declarePendingPaneSerializer(paneKey: string, sender: WebContents | undefined): number {
const gen = ++pendingSerializerGenSeq
registerPendingPaneSerializerCleanup(sender)
const replaced = pendingByPaneKey.get(paneKey)
if (replaced) {
pendingPtyIdBySerializerGeneration.delete(replaced.gen)
}
pendingByPaneKey.set(paneKey, { gen, ownerWebContentsId: sender?.id ?? null })
const existingPtyId = paneKeyPtyId.get(paneKey)
if (existingPtyId) {
pendingPtyIdBySerializerGeneration.set(gen, existingPtyId)
}
return gen
}
@@ -369,10 +377,12 @@ function resolvePaneSpawnReservation<T extends PaneSpawnReservationResult>(
return response
}
function settlePendingPaneSerializer(paneKey: string, gen: number): void {
if (pendingByPaneKey.get(paneKey)?.gen === gen) {
pendingByPaneKey.delete(paneKey)
function settlePendingPaneSerializer(paneKey: string, gen: number): boolean {
if (pendingByPaneKey.get(paneKey)?.gen !== gen) {
return false
}
pendingByPaneKey.delete(paneKey)
return true
}
export function hasPendingRendererSerializerForPaneKey(paneKey: string): boolean {
@@ -1180,7 +1190,6 @@ export function clearProviderPtyState(id: string): void {
return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey)
}
})
rendererSerializerByPtyId.delete(id)
// Why: the hook server's per-paneKey caches (lastPrompt / lastTool) would
// otherwise accumulate entries for dead panes over the process lifetime.
// Use the spawn-time paneKey mapping since the server has no other way to
@@ -1191,17 +1200,6 @@ export function clearProviderPtyState(id: string): void {
paneKeyPtyId.delete(paneKey)
}
ptyPaneKey.delete(id)
// Why: drop the pre-signal pending entry only if it still belongs to THIS
// PTY's spawn generation. If a remount for the same paneKey has already
// pre-signaled a new gen, this teardown must NOT touch it — otherwise
// the second mount's hydration loses to the daemon-snapshot seed. See
// the generation-token rationale in
// docs/mobile-prefer-renderer-scrollback.md.
const ownedGen = ptyPendingGenByPtyId.get(id)
if (ownedGen !== undefined) {
settlePendingPaneSerializer(paneKey, ownedGen)
}
ptyPendingGenByPtyId.delete(id)
if (stillOwnsPaneKey) {
// Why: notify registered consumers AFTER we've dropped the paneKey↔ptyId
// entries so a listener that re-reads the map sees the post-teardown
@@ -1522,6 +1520,7 @@ export function registerPtyHandlers(
ipcMain.removeHandler('pty:declarePendingPaneSerializer')
ipcMain.removeHandler('pty:settlePaneSerializer')
ipcMain.removeHandler('pty:clearPendingPaneSerializer')
ipcMain.removeHandler('pty:reportRendererSerializerReady')
ipcMain.removeHandler('pty:getMainBufferSnapshot')
ipcMain.removeHandler('pty:sideEffectSnapshot')
ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot')
@@ -2762,7 +2761,13 @@ export function registerPtyHandlers(
// not stack listeners and trip Node's MaxListeners=10 warning. Many
// sleeping PTYs waking at once (e.g. on relaunch) routinely fan out 10+
// concurrent calls.
type SerializeResult = { data: string; cols: number; rows: number; lastTitle?: string } | null
type SerializeResult = {
data: string
cols: number
rows: number
seq?: number
lastTitle?: string
} | null
const pendingSerializeRequests = new Map<
string,
{ resolve: (result: SerializeResult) => void; timeout: NodeJS.Timeout }
@@ -2788,6 +2793,7 @@ export function registerPtyHandlers(
data?: unknown
cols?: unknown
rows?: unknown
seq?: unknown
lastTitle?: unknown
} | null
}
@@ -2802,11 +2808,20 @@ export function registerPtyHandlers(
typeof snapshot.cols === 'number' &&
typeof snapshot.rows === 'number'
) {
const result: { data: string; cols: number; rows: number; lastTitle?: string } = {
const result: {
data: string
cols: number
rows: number
seq?: number
lastTitle?: string
} = {
data: snapshot.data,
cols: snapshot.cols,
rows: snapshot.rows
}
if (typeof snapshot.seq === 'number' && Number.isFinite(snapshot.seq)) {
result.seq = snapshot.seq
}
if (typeof snapshot.lastTitle === 'string' && snapshot.lastTitle.length > 0) {
result.lastTitle = snapshot.lastTitle
}
@@ -3303,6 +3318,15 @@ export function registerPtyHandlers(
// so record their spawn-time paneKey here too. Synthetic hook titles and
// paneKey-scoped cache cleanup both depend on this reverse lookup.
const paneKey = rememberPaneKeyForPty(result.id, env?.ORCA_PANE_KEY)
const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined
const inheritRendererReadiness =
result.isReattach === true &&
!pendingSerializer &&
rendererSerializerReadiness.has(result.id)
rendererSerializerReadiness.beginIncarnation(result.id, inheritRendererReadiness)
if (paneKey && pendingSerializer) {
pendingPtyIdBySerializerGeneration.set(pendingSerializer.gen, result.id)
}
if (!args.connectionId) {
registerPty({
ptyId: result.id,
@@ -3514,7 +3538,13 @@ export function registerPtyHandlers(
// seed (no renderer authoritative for this PTY). A registry write happens
// when the renderer calls registerPtySerializer; we check via the same
// pendingByPaneKey + ptyId pairing that the cooperation gate uses.
return rendererSerializerByPtyId.has(ptyId)
return rendererSerializerReadiness.has(ptyId)
},
getRendererSerializerGeneration: (ptyId) => {
return rendererSerializerReadiness.generation(ptyId)
},
waitForRendererSerializer: (ptyId, afterGeneration, timeoutMs, signal) => {
return rendererSerializerReadiness.wait(ptyId, afterGeneration, timeoutMs, signal)
},
getSize: (ptyId) => ptySizes.get(ptyId) ?? null,
resize: (ptyId, cols, rows) => {
@@ -4278,14 +4308,18 @@ export function registerPtyHandlers(
const rendererPreSignaled = validatedPaneKey
? pendingByPaneKey.has(validatedPaneKey)
: false
const rendererAlreadyRegistered = rendererSerializerByPtyId.has(result.id)
const rendererAlreadyRegistered =
result.isReattach === true &&
!rendererPreSignaled &&
rendererSerializerReadiness.has(result.id)
rendererSerializerReadiness.beginIncarnation(result.id, rendererAlreadyRegistered)
// Why: capture the pending gen at spawn time so teardown for THIS PTY
// only settles its own generation. A remount that replaces the entry
// with a new gen must not be stomped by the old PTY's teardown.
if (validatedPaneKey && rendererPreSignaled) {
const pending = pendingByPaneKey.get(validatedPaneKey)
if (pending) {
ptyPendingGenByPtyId.set(result.id, pending.gen)
pendingPtyIdBySerializerGeneration.set(pending.gen, result.id)
}
}
@@ -5231,15 +5265,13 @@ export function registerPtyHandlers(
if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') {
return
}
settlePendingPaneSerializer(args.paneKey, args.gen)
// Why: settle means the renderer has registered its serializer locally
// for whatever ptyId came back from spawn. The renderer doesn't carry
// the ptyId back through this IPC because the cooperation gate ran
// pre-spawn; instead we mark the pane as authoritative by paneKey →
// ptyId via the existing paneKeyPtyId mapping populated at spawn.
const ptyId = paneKeyPtyId.get(args.paneKey)
if (ptyId) {
rendererSerializerByPtyId.add(ptyId)
const ptyId = pendingPtyIdBySerializerGeneration.get(args.gen)
const settledCurrentGeneration = settlePendingPaneSerializer(args.paneKey, args.gen)
// Why: the generation-to-PTY binding survives late teardown of a reused
// id; paneKey reverse maps are provider lifecycle state and may already be gone.
pendingPtyIdBySerializerGeneration.delete(args.gen)
if (settledCurrentGeneration && ptyId) {
rendererSerializerReadiness.markReady(ptyId)
}
}
)
@@ -5251,6 +5283,23 @@ export function registerPtyHandlers(
return
}
settlePendingPaneSerializer(args.paneKey, args.gen)
pendingPtyIdBySerializerGeneration.delete(args.gen)
}
)
ipcMain.handle(
'pty:reportRendererSerializerReady',
async (_event, args: { ptyId?: unknown }): Promise<void> => {
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)
}
)
}
@@ -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)
})
})
@@ -0,0 +1,129 @@
type RendererTerminalSerializerWaiter = {
afterGeneration: number
finish: (ready: boolean) => void
}
export class RendererTerminalSerializerReadiness {
private incarnationSequence = 0
private readonly incarnationByPtyId = new Map<string, number>()
private readonly readyIncarnationByPtyId = new Map<string, number>()
private readonly generationByPtyId = new Map<string, number>()
private readonly waitersByPtyId = new Map<string, Set<RendererTerminalSerializerWaiter>>()
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<boolean> {
if (this.generation(ptyId) > afterGeneration) {
return Promise.resolve(true)
}
if (signal?.aborted) {
return Promise.resolve(false)
}
return new Promise<boolean>((resolve) => {
let timer: ReturnType<typeof setTimeout> | 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()
}
}
})
}
}
+22
View File
@@ -8132,6 +8132,28 @@ describe('OrcaRuntimeService', () => {
)
})
it('replaces suffix-only headless state with the recovered renderer snapshot', async () => {
const runtime = createRuntime()
syncSinglePty(runtime, 'pty-1')
runtime.seedHeadlessTerminal('pty-1', 'suffix-only redraw', { cols: 80, rows: 24 })
runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery('pty-1', {
data: 'restored history\r\nprompt $ ',
cols: 80,
rows: 24,
cwd: '/projects/restored'
})
runtime.onPtyData('pty-1', 'after recovery\r\n', 100)
const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', {
scrollbackRows: 100
})
expect(snapshot?.data).toContain('restored history')
expect(snapshot?.data).toContain('after recovery')
expect(snapshot?.data).not.toContain('suffix-only redraw')
expect(snapshot?.cwd).toBe('/projects/restored')
})
it('adopts OSC7 host metadata from seeded headless terminal scrollback', async () => {
const runtime = createRuntime()
syncSinglePty(runtime, 'pty-1')
+74 -7
View File
@@ -1222,7 +1222,7 @@ type RuntimePtyController = {
serializeBuffer?(
ptyId: string,
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
): Promise<{ data: string; cols: number; rows: number; lastTitle?: string } | null>
): Promise<{ data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null>
/** Authoritative provider-owned snapshot for restored PTYs with no mounted renderer. */
serializeProviderBuffer?(
ptyId: string,
@@ -1232,6 +1232,13 @@ type RuntimePtyController = {
// hydration when no renderer is authoritative for this PTY. See
// docs/mobile-prefer-renderer-scrollback.md.
hasRendererSerializer?(ptyId: string): boolean
getRendererSerializerGeneration?(ptyId: string): number
waitForRendererSerializer?(
ptyId: string,
afterGeneration: number,
timeoutMs?: number,
signal?: AbortSignal
): Promise<boolean>
getSize?(ptyId: string): { cols: number; rows: number } | null
}
@@ -6834,9 +6841,9 @@ export class OrcaRuntimeService {
data: string
cols: number
rows: number
seq?: number
cwd?: string | null
lastTitle?: string
seq?: number
source?: 'headless' | 'renderer'
oscLinks?: TerminalOscLinkRange[]
alternateScreen?: boolean
@@ -6857,9 +6864,9 @@ export class OrcaRuntimeService {
data: string
cols: number
rows: number
seq?: number
cwd?: string | null
lastTitle?: string
seq?: number
source?: 'headless' | 'renderer'
oscLinks?: TerminalOscLinkRange[]
alternateScreen?: boolean
@@ -7295,13 +7302,14 @@ export class OrcaRuntimeService {
: rendererSnapshot
}
private async serializeRendererTerminalBuffer(
async serializeRendererTerminalBuffer(
ptyId: string,
opts: { scrollbackRows?: number } = {}
): Promise<{
data: string
cols: number
rows: number
seq?: number
cwd?: string | null
lastTitle?: string
source?: 'renderer'
@@ -7314,6 +7322,7 @@ export class OrcaRuntimeService {
data: string
cols: number
rows: number
seq?: number
cwd?: string | null
lastTitle?: string
oscLinks?: TerminalOscLinkRange[]
@@ -19002,15 +19011,15 @@ export class OrcaRuntimeService {
// Why: never-mounted tabs have no attached PTY or mobile snapshot; synthetic
// handles need the ptyId so the renderer can mount the exact owning tab.
requestRendererTerminalTabMount(handle: string): void {
requestRendererTerminalTabMount(handle: string): boolean {
const record = this.handles.get(handle)
if (!record?.worktreeId) {
return
return false
}
const tabId = record.tabId.startsWith('pty:') ? undefined : record.tabId
const ptyId = record.ptyId ?? undefined
if (!tabId && !ptyId) {
return
return false
}
try {
this.getAuthoritativeWindow().webContents.send('terminal:requestTabMount', {
@@ -19018,12 +19027,70 @@ export class OrcaRuntimeService {
...(tabId ? { tabId } : {}),
...(ptyId ? { ptyId } : {})
})
return true
} catch {
// No authoritative window (shutdown/headless): the subscribe keeps its
// existing empty-snapshot fallback.
return false
}
}
getRendererTerminalSerializerGeneration(ptyId: string): number {
return this.ptyController?.getRendererSerializerGeneration?.(ptyId) ?? 0
}
getRendererTerminalSerializerGenerationForHandle(handle: string): number {
const ptyId = this.handles.get(handle)?.ptyId
return ptyId ? this.getRendererTerminalSerializerGeneration(ptyId) : 0
}
replaceHeadlessTerminalFromRendererSnapshotForRecovery(
ptyId: string,
snapshot: {
data: string
cols: number
rows: number
cwd?: string | null
oscLinks?: TerminalOscLinkRange[]
},
trailingOutput: { data: string; seq: number }[] = []
): void {
if (!snapshot.data) {
return
}
// Why: a redraw byte can create a suffix-only model before the restored
// renderer settles. Replace it with the exact snapshot already sent mobile.
this.providerSnapshotPreferredPtys.add(ptyId)
this.disposeHeadlessTerminal(ptyId)
this.seedHeadlessTerminal(
ptyId,
snapshot.data,
{ cols: snapshot.cols, rows: snapshot.rows },
{ cwd: snapshot.cwd, oscLinks: snapshot.oscLinks }
)
for (const chunk of trailingOutput) {
this.trackHeadlessTerminalData(ptyId, chunk.data, chunk.seq)
}
// The seed's write chain already owns subsequent live bytes; suppress the
// ordinary on-data hydration path from replacing this known-good seed.
this.headlessHydrationState.set(ptyId, 'done')
}
waitForRendererTerminalSerializer(
ptyId: string,
afterGeneration: number,
timeoutMs?: number,
signal?: AbortSignal
): Promise<boolean> {
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
+181 -9
View File
@@ -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<SerializedSnapshot> {
const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0]
let candidateIndex = 0
for (let attempt = 0; attempt < candidates.length; attempt += 1) {
// Why: stability retries share the six-call snapshot budget. Advance
// toward zero scrollback so the final attempt always has a bounded payload.
candidateIndex = Math.max(candidateIndex, attempt)
const rows = candidates[candidateIndex]
const outputSequenceBefore = runtime.getPtyOutputSequence(ptyId)
const serialized = await runtime.serializeRendererTerminalBuffer(ptyId, {
scrollbackRows: rows
})
const outputSequenceAfter = runtime.getPtyOutputSequence(ptyId)
if (outputSequenceBefore !== outputSequenceAfter) {
continue
}
if (!serialized) {
return null
}
const overByteBudget = terminalStreamByteLengthExceeds(
serialized.data,
MOBILE_SNAPSHOT_BYTE_BUDGET
)
if (!overByteBudget || rows === 0) {
return {
...serialized,
scrollbackRows: rows,
truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget
}
}
candidateIndex += 1
}
return null
}
// Why: mobile xterm can only re-wrap SOFT-wrapped lines on a client-side
// term.resize(); the restored scrollback snapshot contains HARD newlines from
// the host serialization, so a width change leaves prior output wrapped at the
@@ -2355,8 +2396,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
) => {
let leaf = runtime.resolveLeafForHandle(params.terminal)
const isMobile = params.client?.type === 'mobile'
const serializerGenerationBeforeAnyMount = isMobile
? (runtime.getRendererTerminalSerializerGenerationForHandle?.(params.terminal) ?? 0)
: 0
let rendererMountRequestedBeforePty = false
const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary)
let rendererMountRequested = false
// Why: a closed stream must not allocate listeners, mobile-fit state, or
// a hidden renderer surface that no client remains to consume.
if (signal?.aborted) {
@@ -2370,8 +2414,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (!leaf?.ptyId && isMobile) {
// Why: a never-mounted tab has no graph leaf to await; mounting the
// exact tab lets its PTY attach without activating the worktree.
runtime.requestRendererTerminalTabMount(params.terminal)
rendererMountRequested = true
rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal)
try {
const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal)
leaf = { ptyId }
@@ -2401,6 +2444,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const ptyId = leaf.ptyId
const clientId = params.client?.id
// Why: the initial mount/PTY wait and phone-fit can both emit a redraw
// that creates suffix-only state, so preserve the pre-mount absence signal.
const missingHeadlessStateBeforeMobileFit =
isMobile &&
(rendererMountRequestedBeforePty || runtime.hasHeadlessTerminalState?.(ptyId) === false)
const serializerGenerationBeforeMobileFit = missingHeadlessStateBeforeMobileFit
? rendererMountRequestedBeforePty
? serializerGenerationBeforeAnyMount
: runtime.getRendererTerminalSerializerGeneration(ptyId)
: 0
const supportsDesktopViewportClaims = params.capabilities?.desktopViewportClaims === 1
// Why: only unregister the width floor this subscription took (see the
// multiplex stream's registeredRemoteDesktopDriver note).
@@ -2542,6 +2595,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
let unsubscribeResize = (): void => {}
let unsubscribeFit = (): void => {}
let unregisterBinaryHandler = (): void => {}
let abortRendererMountWait = (): void => {}
let lateRendererReadyPromise: Promise<boolean> | null = null
let outputBatcher: ReturnType<typeof createTerminalOutputBatcher> | null = null
let resolveStream = (): void => {}
const streamClosed = new Promise<void>((resolve) => {
@@ -2561,6 +2616,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
unsubscribeResize()
unsubscribeFit()
unregisterBinaryHandler()
abortRendererMountWait()
if (isMobile && clientId) {
runtime.handleMobileUnsubscribe(ptyId, clientId)
} else if (registeredRemoteDesktopDriver && clientId) {
@@ -2784,12 +2840,80 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
// Why: missing model state—not snapshot text—is the signal that this
// PTY may never have attached; avoid remounting legitimate blank panes.
if (
isMobile &&
!rendererMountRequested &&
runtime.hasHeadlessTerminalState?.(ptyId) === false
) {
runtime.requestRendererTerminalTabMount(params.terminal)
// A renderer-sourced snapshot also proves the exact pane is already
// attached, so waiting for a fresh mount generation would only stall.
const mountRequested =
missingHeadlessStateBeforeMobileFit &&
serialized?.source !== 'renderer' &&
(rendererMountRequestedBeforePty ||
runtime.requestRendererTerminalTabMount(params.terminal))
if (missingHeadlessStateBeforeMobileFit && mountRequested) {
// Why: an idle legacy PTY emits no later byte; a fresh settle proves
// this exact remount completed before we replay its restored screen.
const mountWaitController = new AbortController()
const abortMountWait = (): void => mountWaitController.abort()
abortRendererMountWait = abortMountWait
if (signal?.aborted) {
abortMountWait()
} else {
signal?.addEventListener('abort', abortMountWait, { once: true })
}
const rendererReadyPromise = runtime
.waitForRendererTerminalSerializer(
ptyId,
serializerGenerationBeforeMobileFit,
undefined,
mountWaitController.signal
)
.catch(() => false)
const finishMountWait = (): void => {
signal?.removeEventListener('abort', abortMountWait)
if (abortRendererMountWait === abortMountWait) {
abortRendererMountWait = () => {}
}
}
void rendererReadyPromise.then(finishMountWait, finishMountWait)
let deadlineTimer: ReturnType<typeof setTimeout> | null = null
const initialDeadline = new Promise<boolean>((resolve) => {
deadlineTimer = setTimeout(() => resolve(false), MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS)
if (typeof deadlineTimer.unref === 'function') {
deadlineTimer.unref()
}
})
const rendererReady = await Promise.race([rendererReadyPromise, initialDeadline])
if (deadlineTimer) {
clearTimeout(deadlineTimer)
}
if (closed || signal?.aborted) {
return
}
if (rendererReady) {
read = await runtime.readTerminal(params.terminal)
const stableRendererSnapshot = await serializeStableMobileRendererSnapshot(
runtime,
ptyId
)
if (closed) {
return
}
if (stableRendererSnapshot?.data.length) {
serialized = stableRendererSnapshot
const trailingOutput = pendingOutput.flatMap((item) => {
const data = getOutputAfterSnapshotSeq(item, stableRendererSnapshot.seq)
const seq = item.meta?.seq
return data && typeof seq === 'number' ? [{ data, seq }] : []
})
runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(
ptyId,
stableRendererSnapshot,
trailingOutput
)
}
} else {
// Why: a renderer can settle after the bounded initial response.
// Keep observing it so an idle PTY still self-heals without bytes.
lateRendererReadyPromise = rendererReadyPromise
}
}
let initialOutputOverflowed = false
if (pendingOutputOverflowed) {
@@ -2951,6 +3075,54 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
pendingOutputBytes = 0
outputBatcher.flush()
const lateRendererReady = lateRendererReadyPromise
lateRendererReadyPromise = null
if (lateRendererReady) {
void lateRendererReady
.then(async (rendererReady) => {
if (!rendererReady || closed) {
return
}
outputBatcher?.flush()
const recovery = await serializeStableMobileRendererSnapshot(runtime, ptyId)
if (closed) {
return
}
if (!recovery?.data.length) {
return
}
// Why: late recovery has no buffered-output gate. Only an exact
// renderer high-water may reset mobile without erasing live bytes.
if (recovery.seq !== runtime.getPtyOutputSequence(ptyId)) {
return
}
runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(ptyId, recovery)
// Why: shipped mobile clients apply resized snapshots in place,
// allowing a blank initialized xterm to recover without resubscribe.
const recoveryStats = sendSnapshotFrames(sendFrame, {
kind: 'resized',
cols: recovery.cols,
rows: recovery.rows,
displayMode,
reason: 'renderer-mount-ready',
source: recovery.source,
truncated: false,
truncatedByByteBudget: recovery.truncatedByByteBudget,
data: recovery.data
})
lastResizeCols = recovery.cols
console.log('[mobile-terminal-stream] recovery snapshot', {
terminal: params.terminal,
streamId,
reason: 'renderer-mount-ready',
bytes: recoveryStats.bytes,
chunks: recoveryStats.chunks,
scrollbackRows: recovery.scrollbackRows,
truncatedByByteBudget: recovery.truncatedByByteBudget === true
})
})
.catch(() => {})
}
const sendResizedFrame = (event: {
cols: number
rows: number
@@ -10,7 +10,13 @@ function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeSe
return {
getRuntimeId: () => 'test-runtime',
registerRemoteTerminalViewSubscriber: () => () => {},
requestRendererTerminalTabMount: () => {},
requestRendererTerminalTabMount: () => false,
getRendererTerminalSerializerGenerationForHandle: () => 0,
getRendererTerminalSerializerGeneration: () => 0,
waitForRendererTerminalSerializer: async () => false,
getPtyOutputSequence: () => 0,
replaceHeadlessTerminalFromRendererSnapshotForRecovery: () => {},
serializeRendererTerminalBuffer: async () => null,
hasHeadlessTerminalState: () => true,
...overrides
} as OrcaRuntimeService
@@ -27,7 +33,7 @@ describe('terminal.subscribe blank-tab background mount', () => {
it('does not mount a hidden tab for an already-aborted mobile subscribe', async () => {
const controller = new AbortController()
controller.abort()
const requestRendererTerminalTabMount = vi.fn()
const requestRendererTerminalTabMount = vi.fn(() => true)
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue(null),
requestRendererTerminalTabMount,
@@ -63,6 +69,7 @@ describe('terminal.subscribe blank-tab background mount', () => {
const unsubscribeData = vi.fn()
const requestRendererTerminalTabMount = vi.fn(() => {
callOrder.push('request-mount')
return true
})
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
@@ -121,7 +128,7 @@ describe('terminal.subscribe blank-tab background mount', () => {
it('does not request a renderer tab mount when an attached terminal is legitimately blank', async () => {
const cleanups = new Map<string, () => void>()
const requestRendererTerminalTabMount = vi.fn()
const requestRendererTerminalTabMount = vi.fn(() => true)
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
requestRendererTerminalTabMount,
@@ -167,4 +174,62 @@ describe('terminal.subscribe blank-tab background mount', () => {
runtime.cleanupSubscription('terminal-1:phone-1')
await dispatchPromise
})
it('does not wait for a remount when the current snapshot came from the renderer', async () => {
const cleanups = new Map<string, () => 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<RuntimeTerminalWait>(() => {}))
})
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
})
})
@@ -17,7 +17,7 @@ function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeSe
// Why: subscribe streams register as remote view subscribers for Phase-5
// query-authority suppression (terminal-query-authority.md).
registerRemoteTerminalViewSubscriber: () => () => {},
requestRendererTerminalTabMount: () => {},
requestRendererTerminalTabMount: () => false,
...overrides
} as OrcaRuntimeService
}
@@ -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<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => 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<RuntimeTerminalWait>(() => {}))
} 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<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => 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<RuntimeTerminalWait>(() => {}))
} 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<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => void>()
let mounted = false
let signalWaitStarted!: () => void
const waitStarted = new Promise<void>((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<boolean>((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<RuntimeTerminalWait>(() => {}))
} 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<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => 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<RuntimeTerminalWait>(() => {}))
} 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<string, () => 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<boolean>((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<RuntimeTerminalWait>(() => {}))
} 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
})
})
@@ -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<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => 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<RuntimeTerminalWait>(() => {}))
} 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
})
})
+2 -1
View File
@@ -1407,11 +1407,12 @@ export type PreloadApi = {
onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void
sendSerializedBuffer: (
requestId: string,
snapshot: { data: string; cols: number; rows: number; lastTitle?: string } | null
snapshot: { data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null
) => void
declarePendingPaneSerializer: (paneKey: string) => Promise<number>
settlePaneSerializer: (paneKey: string, gen: number) => Promise<void>
clearPendingPaneSerializer: (paneKey: string, gen: number) => Promise<void>
reportRendererSerializerReady?: (ptyId: string) => Promise<void>
management: PtyManagementApi
}
feedback: {
+4 -1
View File
@@ -1140,7 +1140,7 @@ const api = {
sendSerializedBuffer: (
requestId: string,
snapshot: { data: string; cols: number; rows: number; lastTitle?: string } | null
snapshot: { data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null
): void => {
ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot })
},
@@ -1159,6 +1159,9 @@ const api = {
clearPendingPaneSerializer: (paneKey: string, gen: number): Promise<void> =>
ipcRenderer.invoke('pty:clearPendingPaneSerializer', { paneKey, gen }),
reportRendererSerializerReady: (ptyId: string): Promise<void> =>
ipcRenderer.invoke('pty:reportRendererSerializerReady', { ptyId }),
management: {
listSessions: () => ipcRenderer.invoke('pty:management:listSessions'),
killAll: () => ipcRenderer.invoke('pty:management:killAll'),
@@ -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 })
)
})
})
@@ -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) {
@@ -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')
})
@@ -98,7 +98,11 @@ import {
} from '../../../../shared/terminal-color-scheme-protocol'
import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics'
import { subscribeToTerminalUserInput } from './terminal-user-input-signal'
import { registerPtySerializer, registerPtyTitleSource } from './pty-buffer-serializer'
import {
hasPtySerializer,
registerPtySerializer,
registerPtyTitleSource
} from './pty-buffer-serializer'
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
import {
@@ -3980,7 +3984,10 @@ export function connectPanePty(
return {
data,
cols: pane.terminal.cols,
rows: pane.terminal.rows
rows: pane.terminal.rows,
...(rendererOrderedPtyId === ptyId && rendererOrderedSeq !== null
? { seq: rendererOrderedSeq }
: {})
}
} catch {
return null
@@ -4003,6 +4010,47 @@ export function connectPanePty(
}
}
let replayWriteQueue = Promise.resolve()
const settlePaneSerializerAfterReplay = async (
ptyId: string,
generation: number
): Promise<void> => {
try {
await replayWriteQueue
if (disposed || transport.getPtyId() !== ptyId) {
await window.api.pty.clearPendingPaneSerializer(cacheKey, generation).catch(() => {})
return
}
await waitForTerminalOutputParsed(pane.terminal)
if (!disposed && transport.getPtyId() === ptyId) {
await window.api.pty.settlePaneSerializer(cacheKey, generation)
return
}
} catch {
// Clear below so a failed parser/replay cannot leave the pane generation pending.
}
await window.api.pty.clearPendingPaneSerializer(cacheKey, generation).catch(() => {})
}
const reportRemoteRendererSerializerReady = (): void => {
const ptyId = transport.getPtyId()
if (!ptyId || !isRemoteRuntimePtyId(ptyId)) {
return
}
if (!hasPtySerializer(ptyId)) {
registerPaneSerializerFor(ptyId)
}
// Why: onSubscribed follows the snapshot callback, but replay drains
// asynchronously; join it and xterm's parser before reporting readiness.
void replayWriteQueue
.then(() => waitForTerminalOutputParsed(pane.terminal))
.then(() => {
if (!disposed && transport.getPtyId() === ptyId) {
void window.api.pty.reportRendererSerializerReady?.(ptyId)
}
})
.catch(() => {})
}
// Why: for ordinary local startup commands, the local PTY provider already
// writes via the shell-ready barrier. terminal-paste and SSH startup
// commands stay renderer-delivered so xterm/relay can apply their handling.
@@ -4422,6 +4470,7 @@ export function connectPanePty(
...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}),
...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
callbacks: {
onConnect: reportRemoteRendererSerializerReady,
onData: dataCallback,
onReplayData: replayDataCallback,
onError: reportError
@@ -4489,9 +4538,14 @@ export function connectPanePty(
reconcilePtySizeAfterSpawn(resolvedPtyId, cols, rows)
}
const gen = await preSignalPromise
if (typeof gen === 'number' && resolvedPtyId) {
if (!isRemoteRuntimePtyId(resolvedPtyId)) {
if (
resolvedPtyId &&
(typeof gen === 'number' || isRemoteRuntimePtyId(resolvedPtyId))
) {
if (!isRemoteRuntimePtyId(resolvedPtyId) || !hasPtySerializer(resolvedPtyId)) {
registerPaneSerializerFor(resolvedPtyId)
}
if (typeof gen === 'number') {
void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {})
}
} else if (typeof gen === 'number') {
@@ -4738,7 +4792,6 @@ export function connectPanePty(
})
}
let replayWriteQueue = Promise.resolve()
type PendingReplayData = {
data: string
clearBeforeReplay: boolean
@@ -7077,6 +7130,7 @@ export function connectPanePty(
...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}),
...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
callbacks: {
onConnect: reportRemoteRendererSerializerReady,
onData: dataCallback,
onReplayData: replayDataCallback,
onError: (message) => {
@@ -7125,7 +7179,17 @@ export function connectPanePty(
const gen = await preSignalPromise
if (typeof gen === 'number') {
if (!isRemoteRuntimePtyId(pendingSessionId)) {
void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {})
const settledPtyId =
result && typeof result === 'object' && 'id' in result
? result.id
: (transport.getPtyId() ?? pendingSessionId)
const hasRestorePayload =
result &&
typeof result === 'object' &&
('snapshot' in result || 'replay' in result || 'coldRestore' in result)
await (hasRestorePayload
? settlePaneSerializerAfterReplay(settledPtyId, gen)
: window.api.pty.settlePaneSerializer(cacheKey, gen))
}
}
})
@@ -7271,6 +7335,7 @@ export function connectPanePty(
...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}),
...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
callbacks: {
onConnect: reportRemoteRendererSerializerReady,
onData: dataCallback,
onReplayData: replayDataCallback,
onError: (message) => {
@@ -7315,7 +7380,17 @@ export function connectPanePty(
const gen = await preSignalPromise
if (typeof gen === 'number') {
if (!isRemoteRuntimePtyId(deferredReattachSessionId)) {
void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {})
const settledPtyId =
result && typeof result === 'object' && 'id' in result
? result.id
: (transport.getPtyId() ?? deferredReattachSessionId)
const hasRestorePayload =
result &&
typeof result === 'object' &&
('snapshot' in result || 'replay' in result || 'coldRestore' in result)
await (hasRestorePayload
? settlePaneSerializerAfterReplay(settledPtyId, gen)
: window.api.pty.settlePaneSerializer(cacheKey, gen))
}
}
})
@@ -7374,6 +7449,7 @@ export function connectPanePty(
cols,
rows,
callbacks: {
onConnect: reportRemoteRendererSerializerReady,
onData: dataCallback,
onReplayData: replayDataCallback,
onError: reportError
@@ -7384,7 +7460,7 @@ export function connectPanePty(
updateTabPtyId: 'if-missing',
sampleVisibleForegroundAgent: true
})
if (attachPtyId === eagerLivePtyId) {
if (attachPtyId === eagerLivePtyId || isRemoteRuntimePtyId(attachedPtyId)) {
registerPaneSerializerFor(attachedPtyId)
}
} catch (err) {
@@ -7430,6 +7506,7 @@ export function connectPanePty(
cols,
rows,
callbacks: {
onConnect: reportRemoteRendererSerializerReady,
onData: dataCallback,
onReplayData: replayDataCallback,
onError: reportError
+1
View File
@@ -2810,6 +2810,7 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['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: [] }),