mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
fix(mobile): recover legacy terminal history safely
This commit is contained in:
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [] }),
|
||||
|
||||
Reference in New Issue
Block a user