diff --git a/src/main/runtime/mobile-phone-subscribe-test-fixture.ts b/src/main/runtime/mobile-phone-subscribe-test-fixture.ts new file mode 100644 index 00000000000..b973a141f3b --- /dev/null +++ b/src/main/runtime/mobile-phone-subscribe-test-fixture.ts @@ -0,0 +1,192 @@ +/** + * A real OrcaRuntimeService + real legacy `terminal.subscribe` driven by a phone at 47x40, over a + * daemon PTY reattached after a relaunch: no host model yet, the PTY at desktop size, and a desktop + * pane that answers its serializer at desktop size because a hidden pane never refits. + */ +import { expect, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { RpcDispatcher } from './rpc/dispatcher' +import type { RpcRequest } from './rpc/core' +import { TERMINAL_METHODS } from './rpc/methods/terminal' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + decodeTerminalStreamText +} from '../../shared/terminal-stream-protocol' +import { HeadlessEmulator } from '../daemon/headless-emulator' + +const WORKTREE_ID = 'repo-1::/tmp/wt' +export const PTY_ID = `${WORKTREE_ID}@@9c8d7e6f` +export const DESKTOP = { cols: 200, rows: 50 } +export const PHONE = { cols: 47, rows: 40 } +const LONG_LINE = 'A'.repeat(120) +export const EXPECTED_PHONE_ROWS = ['A'.repeat(47), 'A'.repeat(47), 'A'.repeat(26), '$ prompt'] + +type RuntimeInternals = { + recordPtyWorktree: (ptyId: string, worktreeId: string, state?: { connected?: boolean }) => unknown + issuePtyHandle: (pty: unknown) => string + providerSnapshotPreferredPtys: Set +} + +export function internals(runtime: OrcaRuntimeService): RuntimeInternals { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test reaches protected members the runtime defines. + return runtime as unknown as RuntimeInternals +} + +async function screenOn(grid: { cols: number; rows: number }): Promise { + const emulator = new HeadlessEmulator({ ...grid, scrollback: 1000 }) + try { + await emulator.write(`${LONG_LINE}\r\n$ prompt`) + const snapshot = emulator.getSnapshot() + return snapshot.rehydrateSequences + snapshot.snapshotAnsi + } finally { + emulator.dispose() + } +} + +export type PhoneSubscribeSetup = { + paneMounted: boolean + providerSnapshot?: boolean + repaintOnResize?: boolean + /** How a requested tab mount lands: at once, or when the test calls `finishMount`. */ + mount?: 'ready' | 'late' +} + +export function setupPhoneSubscribe(opts: PhoneSubscribeSetup) { + const sizes = new Map([[PTY_ID, { ...DESKTOP }]]) + let paneMounted = opts.paneMounted + let settleMount: (ready: boolean) => void = () => {} + const mountSettled = new Promise((resolve) => { + settleMount = resolve + }) + const runtime = new OrcaRuntimeService() + // The pane orders its screen against PTY output, as a mounted desktop xterm does. + const serializeBuffer = vi.fn(async () => + paneMounted + ? { data: await screenOn(DESKTOP), ...DESKTOP, seq: runtime.getPtyOutputSequence(PTY_ID) } + : null + ) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: (ptyId: string) => sizes.get(ptyId) ?? null, + resize: (ptyId: string, cols: number, rows: number) => { + sizes.set(ptyId, { cols, rows }) + if (opts.repaintOnResize) { + // A TUI answering SIGWINCH before the subscribe reaches its own hydrate. + runtime.onPtyData(ptyId, '\x1b[?25h', Date.now()) + } + return true + }, + hasRendererSerializer: () => paneMounted, + getRendererSerializerGeneration: () => (paneMounted ? 2 : 1), + waitForRendererSerializer: () => (opts.mount ? mountSettled : Promise.resolve(false)), + serializeBuffer, + // The daemon resizes its model with the PTY. + serializeProviderBuffer: async (ptyId: string) => { + const grid = sizes.get(ptyId) ?? DESKTOP + return opts.providerSnapshot + ? { data: await screenOn(grid), ...grid, seq: 0, source: 'headless' as const } + : null + } + }) + const finishMount = () => { + paneMounted = true + settleMount(true) + } + const requestMount = vi + .spyOn(runtime, 'requestRendererTerminalTabMount') + .mockImplementation(() => { + if (opts.mount === 'ready') { + finishMount() + } + return opts.mount !== undefined + }) + const record = internals(runtime).recordPtyWorktree(PTY_ID, WORKTREE_ID, { connected: true }) + const handle = internals(runtime).issuePtyHandle(record) + return { runtime, handle, sizes, serializeBuffer, requestMount, finishMount } +} + +export type PublishedSnapshot = { + kind: string + reason?: string + cols: number + rows: number + data: string +} + +export function subscribePhone(runtime: OrcaRuntimeService, handle: string) { + const frames: Uint8Array[] = [] + const controller = new AbortController() + const request: RpcRequest = { + id: 'req-phone', + authToken: 'tok', + method: 'terminal.subscribe', + params: { + terminal: handle, + client: { id: 'phone-1', type: 'mobile' }, + viewport: PHONE, + capabilities: { terminalBinaryStream: 1 } + } + } + const done = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }).dispatchStreaming( + request, + () => {}, + { + connectionId: 'conn-phone', + signal: controller.signal, + sendBinary: (bytes) => { + frames.push(bytes) + }, + registerBinaryStreamHandler: () => () => {} + } + ) + /** Every complete snapshot published so far, in order. */ + const snapshots = (): PublishedSnapshot[] => { + const published: PublishedSnapshot[] = [] + let open: PublishedSnapshot | null = null + for (const frame of frames.flatMap((bytes) => decodeTerminalStreamFrame(bytes) ?? [])) { + if (frame.opcode === TerminalStreamOpcode.SnapshotStart) { + const meta = decodeTerminalStreamJson>(frame.payload) + open = meta ? { ...meta, data: '' } : null + } else if (frame.opcode === TerminalStreamOpcode.SnapshotChunk && open) { + open.data += decodeTerminalStreamText(frame.payload) + } else if (frame.opcode === TerminalStreamOpcode.SnapshotEnd && open) { + published.push(open) + open = null + } + } + return published + } + const close = async () => { + runtime.cleanupSubscription(`${handle}:phone-1`) + controller.abort() + await done.catch(() => {}) + } + return { snapshots, close } +} + +/** What a phone xterm sized to the frame's declared grid shows after replaying it. */ +export async function paintedRows(snapshot: { cols: number; rows: number; data: string }) { + const emulator = new HeadlessEmulator({ cols: snapshot.cols, rows: snapshot.rows, scrollback: 0 }) + try { + await emulator.write(snapshot.data) + return emulator.getVisibleLines().map((line) => line.trimEnd()) + } finally { + emulator.dispose() + } +} + +export async function firstSnapshot( + runtime: OrcaRuntimeService, + handle: string, + timeout = 1_000 +): Promise { + const subscription = subscribePhone(runtime, handle) + await vi.waitFor(() => expect(subscription.snapshots().length).toBeGreaterThan(0), { timeout }) + const [snapshot] = subscription.snapshots() + await subscription.close() + return snapshot +} diff --git a/src/main/runtime/mobile-subscribe-idle-pane-hydrate.test.ts b/src/main/runtime/mobile-subscribe-idle-pane-hydrate.test.ts index 219ec7a94e6..e7c1eb411db 100644 --- a/src/main/runtime/mobile-subscribe-idle-pane-hydrate.test.ts +++ b/src/main/runtime/mobile-subscribe-idle-pane-hydrate.test.ts @@ -1,167 +1,21 @@ /** * A phone subscribing to an idle reattached PTY must get its first snapshot on the phone grid. * - * Harness: real OrcaRuntimeService + real legacy `terminal.subscribe`. After a relaunch the - * reattach skips seeding the host model because a pane is mounted; the PTY then emits no byte - * (an agent waiting for input), and the pane, hidden in another workspace, answers its - * serializer at desktop size. + * After a relaunch the reattach skips seeding the host model because a pane is mounted; the PTY + * then emits no byte (an agent waiting for input), and the pane, hidden in another workspace, + * answers its serializer at desktop size. */ import { describe, expect, it, vi } from 'vitest' -import { OrcaRuntimeService } from './orca-runtime' -import { RpcDispatcher } from './rpc/dispatcher' -import type { RpcRequest } from './rpc/core' -import { TERMINAL_METHODS } from './rpc/methods/terminal' import { - TerminalStreamOpcode, - decodeTerminalStreamFrame, - decodeTerminalStreamJson, - decodeTerminalStreamText -} from '../../shared/terminal-stream-protocol' -import { HeadlessEmulator } from '../daemon/headless-emulator' - -const WORKTREE_ID = 'repo-1::/tmp/wt' -const PTY_ID = `${WORKTREE_ID}@@9c8d7e6f` -const DESKTOP = { cols: 200, rows: 50 } -const PHONE = { cols: 47, rows: 40 } -const LONG_LINE = 'A'.repeat(120) -const EXPECTED_PHONE_ROWS = ['A'.repeat(47), 'A'.repeat(47), 'A'.repeat(26), '$ prompt'] - -type RuntimeInternals = { - recordPtyWorktree: (ptyId: string, worktreeId: string, state?: { connected?: boolean }) => unknown - issuePtyHandle: (pty: unknown) => string - providerSnapshotPreferredPtys: Set -} - -function internals(runtime: OrcaRuntimeService): RuntimeInternals { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test reaches protected members the runtime defines. - return runtime as unknown as RuntimeInternals -} - -async function screenOn(grid: { cols: number; rows: number }): Promise { - const emulator = new HeadlessEmulator({ ...grid, scrollback: 1000 }) - try { - await emulator.write(`${LONG_LINE}\r\n$ prompt`) - const snapshot = emulator.getSnapshot() - return snapshot.rehydrateSequences + snapshot.snapshotAnsi - } finally { - emulator.dispose() - } -} - -function setup(opts: { - paneMounted: boolean - providerSnapshot?: boolean - repaintOnResize?: boolean -}) { - const sizes = new Map([[PTY_ID, { ...DESKTOP }]]) - const runtime = new OrcaRuntimeService() - // The pane orders its screen against PTY output, as a mounted desktop xterm does. - const serializeBuffer = vi.fn(async () => - opts.paneMounted - ? { data: await screenOn(DESKTOP), ...DESKTOP, seq: runtime.getPtyOutputSequence(PTY_ID) } - : null - ) - runtime.setPtyController({ - write: () => true, - kill: () => true, - getForegroundProcess: async () => null, - getSize: (ptyId: string) => sizes.get(ptyId) ?? null, - resize: (ptyId: string, cols: number, rows: number) => { - sizes.set(ptyId, { cols, rows }) - if (opts.repaintOnResize) { - // A TUI answering SIGWINCH before the subscribe reaches its own hydrate. - runtime.onPtyData(ptyId, '\x1b[?25h', Date.now()) - } - return true - }, - hasRendererSerializer: () => opts.paneMounted, - getRendererSerializerGeneration: () => 1, - waitForRendererSerializer: async () => false, - serializeBuffer, - // The daemon resizes its model with the PTY. - serializeProviderBuffer: async (ptyId: string) => { - const grid = sizes.get(ptyId) ?? DESKTOP - return opts.providerSnapshot - ? { data: await screenOn(grid), ...grid, seq: 0, source: 'headless' as const } - : null - } - }) - const requestMount = vi.spyOn(runtime, 'requestRendererTerminalTabMount').mockReturnValue(false) - const record = internals(runtime).recordPtyWorktree(PTY_ID, WORKTREE_ID, { connected: true }) - const handle = internals(runtime).issuePtyHandle(record) - return { runtime, handle, sizes, serializeBuffer, requestMount } -} - -function subscribePhone(runtime: OrcaRuntimeService, handle: string) { - const frames: Uint8Array[] = [] - const controller = new AbortController() - const request: RpcRequest = { - id: 'req-phone', - authToken: 'tok', - method: 'terminal.subscribe', - params: { - terminal: handle, - client: { id: 'phone-1', type: 'mobile' }, - viewport: PHONE, - capabilities: { terminalBinaryStream: 1 } - } - } - const done = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }).dispatchStreaming( - request, - () => {}, - { - connectionId: 'conn-phone', - signal: controller.signal, - sendBinary: (bytes) => { - frames.push(bytes) - }, - registerBinaryStreamHandler: () => () => {} - } - ) - const decoded = () => frames.flatMap((bytes) => decodeTerminalStreamFrame(bytes) ?? []) - const snapshot = () => { - const start = decoded().find((frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart) - const meta = start - ? decodeTerminalStreamJson<{ cols: number; rows: number }>(start.payload) - : null - if (!meta) { - return null - } - const data = decoded() - .filter((frame) => frame.opcode === TerminalStreamOpcode.SnapshotChunk) - .map((frame) => decodeTerminalStreamText(frame.payload)) - .join('') - return { cols: meta.cols, rows: meta.rows, data } - } - const close = async () => { - runtime.cleanupSubscription(`${handle}:phone-1`) - controller.abort() - await done.catch(() => {}) - } - return { snapshot, close } -} - -/** What a phone xterm sized to the frame's declared grid shows after replaying it. */ -async function paintedRows(snapshot: { cols: number; rows: number; data: string }) { - const emulator = new HeadlessEmulator({ cols: snapshot.cols, rows: snapshot.rows, scrollback: 0 }) - try { - await emulator.write(snapshot.data) - return emulator.getVisibleLines().map((line) => line.trimEnd()) - } finally { - emulator.dispose() - } -} - -async function firstSnapshot(runtime: OrcaRuntimeService, handle: string) { - const subscription = subscribePhone(runtime, handle) - await vi.waitFor(() => expect(subscription.snapshot()).not.toBeNull()) - const snapshot = subscription.snapshot() - await subscription.close() - if (!snapshot) { - throw new Error('no snapshot frame') - } - return snapshot -} + EXPECTED_PHONE_ROWS, + PHONE, + PTY_ID, + firstSnapshot, + internals, + paintedRows, + setupPhoneSubscribe as setup, + subscribePhone +} from './mobile-phone-subscribe-test-fixture' describe('phone subscribe to an idle PTY whose hidden pane sits at desktop size', () => { it('serves the first snapshot from a host model hydrated onto the phone grid', async () => { @@ -228,7 +82,9 @@ describe('phone subscribe to an idle PTY whose hidden pane sits at desktop size' const { runtime, handle, requestMount } = setup({ paneMounted: false }) const subscription = subscribePhone(runtime, handle) - await vi.waitFor(() => expect(subscription.snapshot()).not.toBeNull(), { timeout: 5_000 }) + await vi.waitFor(() => expect(subscription.snapshots().length).toBeGreaterThan(0), { + timeout: 5_000 + }) await subscription.close() expect(requestMount).toHaveBeenCalledTimes(1) diff --git a/src/main/runtime/mobile-subscribe-tab-mount-grid.test.ts b/src/main/runtime/mobile-subscribe-tab-mount-grid.test.ts new file mode 100644 index 00000000000..f70ee45ffb1 --- /dev/null +++ b/src/main/runtime/mobile-subscribe-tab-mount-grid.test.ts @@ -0,0 +1,53 @@ +/** + * A phone subscribing to a tab not mounted since relaunch must get the PTY's grid, not the pane's. + * + * With no renderer serializer the subscribe asks the desktop to mount the tab and waits for it. + * The freshly mounted pane is hidden, so it answers its serializer at desktop size. + */ +import { describe, expect, it, vi } from 'vitest' +import { + EXPECTED_PHONE_ROWS, + PHONE, + PTY_ID, + firstSnapshot, + paintedRows, + setupPhoneSubscribe, + subscribePhone +} from './mobile-phone-subscribe-test-fixture' + +describe('phone subscribe to a tab mounted on demand at desktop size', () => { + it('serves the mount-wait snapshot from the model on the phone grid', async () => { + const { runtime, handle, requestMount } = setupPhoneSubscribe({ + paneMounted: false, + mount: 'ready' + }) + + const snapshot = await firstSnapshot(runtime, handle) + + expect(requestMount).toHaveBeenCalledTimes(1) + expect({ cols: snapshot.cols, rows: snapshot.rows }).toEqual(PHONE) + expect((await paintedRows(snapshot)).slice(0, 4)).toEqual(EXPECTED_PHONE_ROWS) + const resubscribed = await firstSnapshot(runtime, handle) + expect({ cols: resubscribed.cols, rows: resubscribed.rows }).toEqual(PHONE) + expect((await paintedRows(resubscribed)).slice(0, 4)).toEqual(EXPECTED_PHONE_ROWS) + }) + + it('publishes a late mount-ready recovery on the phone grid', async () => { + const { runtime, handle, finishMount } = setupPhoneSubscribe({ + paneMounted: false, + mount: 'late' + }) + const subscription = subscribePhone(runtime, handle) + // The bounded initial response gives up on the mount before it settles. + await vi.waitFor(() => expect(subscription.snapshots().length).toBe(1), { timeout: 5_000 }) + + finishMount() + await vi.waitFor(() => expect(subscription.snapshots().length).toBe(2)) + const recovery = subscription.snapshots()[1] + await subscription.close() + + expect(recovery).toMatchObject({ kind: 'resized', reason: 'renderer-mount-ready', ...PHONE }) + expect((await paintedRows(recovery)).slice(0, 4)).toEqual(EXPECTED_PHONE_ROWS) + expect(runtime.hasHeadlessTerminalState(PTY_ID)).toBe(true) + }, 10_000) +}) diff --git a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts index b3749ec6dba..eac30736dcd 100644 --- a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts +++ b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts @@ -109,11 +109,11 @@ export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLiveP oscLinks?: TerminalOscLinkRange[] }, trailingOutput: { data: string; seq: number }[] = [] - ): void { + ): Promise { if (!snapshot.data) { - return + return Promise.resolve() } - // Why: a redraw byte can create a suffix-only model before the renderer settles; replace it with the exact snapshot already sent mobile. + // Why: a redraw byte can create a suffix-only model before the renderer settles; replace it with the renderer's full screen. this.providerSnapshotPreferredPtys.add(ptyId) this.disposeHeadlessTerminal(ptyId) this.seedHeadlessTerminal( @@ -122,11 +122,17 @@ export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLiveP { cols: snapshot.cols, rows: snapshot.rows }, { cwd: snapshot.cwd, oscLinks: snapshot.oscLinks } ) + // Why: a hidden pane answers at its own size; land the model on the grid later bytes paint. + const ptyGrid = this.getTerminalSize(ptyId) + if (ptyGrid) { + this.resizeHeadlessTerminal(ptyId, ptyGrid.cols, ptyGrid.rows) + } for (const chunk of trailingOutput) { this.trackHeadlessTerminalData(ptyId, chunk.data, chunk.seq) } // The seed's write chain owns subsequent live bytes; suppress on-data hydration from replacing this known-good seed. this.headlessHydrationState.set(ptyId, 'done') + return this.headlessTerminals.get(ptyId)?.writeChain ?? Promise.resolve() } waitForRendererTerminalSerializer( diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts index da5761dffcf..43b3095dff8 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts @@ -8,6 +8,7 @@ import { sendSnapshotFrames, serializeStableMobileRendererSnapshot } from './terminal-snapshot-publication' +import { seedModelFromRendererScreen } from './terminal-renderer-screen-model-seed' import { updateViewportForClient } from './terminal-viewport-update' import type { LegacyBinarySubscriptionState, @@ -47,27 +48,42 @@ export function activateLegacyBinarySubscription( if (recovery.seq !== runtime.getPtyOutputSequence(ptyId)) { return } - runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(ptyId, recovery) + const published = await seedModelFromRendererScreen( + runtime, + ptyId, + recovery, + [], + mobileSnapshotByteBudget(params.snapshotByteBudget, state.streamId, recoveryFrame) + ) + // Why: bytes that landed while the model was rebuilt already went live, so a later reset would repeat them. + if ( + state.closed || + !published?.data.length || + published.seq !== recovery.seq || + recovery.seq !== runtime.getPtyOutputSequence(ptyId) + ) { + return + } // Why: shipped mobile clients apply resized snapshots in place, so a blank xterm recovers without resubscribe. const recoveryStats = sendSnapshotFrames(state.sendFrame, { ...recoveryFrame, - cols: recovery.cols, - rows: recovery.rows, + cols: published.cols, + rows: published.rows, displayMode: state.displayMode, - source: recovery.source, + source: published.source, truncated: false, - truncatedByByteBudget: recovery.truncatedByByteBudget, - data: recovery.data + truncatedByByteBudget: published.truncatedByByteBudget, + data: published.data }) - state.lastResizeCols = recovery.cols + state.lastResizeCols = published.cols console.log('[mobile-terminal-stream] recovery snapshot', { terminal: params.terminal, streamId: state.streamId, reason: 'renderer-mount-ready', bytes: recoveryStats.bytes, chunks: recoveryStats.chunks, - scrollbackRows: recovery.scrollbackRows, - truncatedByByteBudget: recovery.truncatedByByteBudget === true + scrollbackRows: published.scrollbackRows, + truncatedByByteBudget: published.truncatedByByteBudget === true }) }) .catch(() => {}) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts index c34ed56a810..40b4d232c66 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts @@ -14,6 +14,7 @@ import type { LegacyBinarySubscriptionState, TerminalSubscriptionArgs } from './terminal-legacy-subscription-types' +import { seedModelFromRendererScreen } from './terminal-renderer-screen-model-seed' const MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS = 3_000 @@ -145,17 +146,17 @@ export async function publishLegacyBinaryInitialSnapshot( stableRendererSnapshot?.data.length && (typeof stableRendererSnapshot.seq === 'number' || state.pendingOutput.length === 0) ) { - serialized = stableRendererSnapshot - const trailingOutput = state.pendingOutput.flatMap((item) => { - const output = getOutputAfterSnapshotSeq(item, stableRendererSnapshot.seq) - const seq = item.meta?.seq - return output && typeof seq === 'number' ? [{ data: output.data, seq }] : [] - }) - runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery( + const fromModel = await seedModelFromRendererScreen( + runtime, ptyId, stableRendererSnapshot, - trailingOutput + state.pendingOutput, + mobileSnapshotByteBudget(params.snapshotByteBudget, state.streamId, scrollbackFrame) ) + if (state.closed) { + return + } + serialized = fromModel ?? stableRendererSnapshot } } let initialOutputOverflowed = false diff --git a/src/main/runtime/rpc/methods/terminal/terminal-renderer-screen-model-seed.ts b/src/main/runtime/rpc/methods/terminal/terminal-renderer-screen-model-seed.ts new file mode 100644 index 00000000000..3fd1f986737 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-renderer-screen-model-seed.ts @@ -0,0 +1,37 @@ +import type { OrcaRuntimeService } from '../../../orca-runtime' +import { + serializeBudgetedMobileSnapshot, + type MobileSnapshotByteBudget +} from './terminal-snapshot-publication' +import { getOutputAfterSnapshotSeq } from './terminal-stream-replay' +import type { SerializedSnapshot, TerminalOutputChunk } from './terminal-stream-types' + +type RendererScreen = NonNullable + +/** + * Rebuilds the host model from a renderer screen, replaying the output after its seq, and returns + * the model's snapshot. A hidden pane answers at its own size; the model sits on the PTY grid. + */ +export async function seedModelFromRendererScreen( + runtime: Pick< + OrcaRuntimeService, + 'replaceHeadlessTerminalFromRendererSnapshotForRecovery' | 'serializeTerminalBuffer' + >, + ptyId: string, + screen: RendererScreen, + pendingOutput: readonly TerminalOutputChunk[], + snapshotByteBudget: MobileSnapshotByteBudget | undefined +): Promise { + const trailingOutput = pendingOutput.flatMap((item) => { + const output = getOutputAfterSnapshotSeq(item, screen.seq) + const seq = item.meta?.seq + return output && typeof seq === 'number' ? [{ data: output.data, seq }] : [] + }) + await runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery( + ptyId, + screen, + trailingOutput + ) + // Why mobile: only a phone subscribe adopts a renderer screen. + return serializeBudgetedMobileSnapshot(runtime, ptyId, true, snapshotByteBudget) +} diff --git a/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts b/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts index 7371c54472c..f268cb97b03 100644 --- a/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts @@ -296,6 +296,7 @@ describe('terminal subscribe mount replay', () => { let generation = 0 let headlessPresent = false const requestRendererTerminalTabMount = vi.fn(() => true) + let model = { data: 'suffix-only redraw', cols: 80, rows: 24, seq: 1 } const runtime = asRuntime({ getRuntimeId: () => 'test-runtime', subscribeToPtyExit: vi.fn(() => vi.fn()), @@ -311,7 +312,12 @@ describe('terminal subscribe mount replay', () => { getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 0), getRendererTerminalSerializerGeneration: vi.fn(() => generation), getPtyOutputSequence: vi.fn(() => 0), - replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + // The recovery seed replaces the suffix-only model with the mounted screen. + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn( + async (_ptyId: string, snapshot: { data: string }) => { + model = { ...model, data: snapshot.data } + } + ), waitForRendererTerminalSerializer: vi.fn(async (_ptyId, afterGeneration) => { return generation > afterGeneration }), @@ -320,12 +326,7 @@ describe('terminal subscribe mount replay', () => { 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 - }), + serializeTerminalBuffer: vi.fn(async () => model), // Baseline race: the pre-PTY mount settles between the attachment answer and the wait, so the // wait must still count that settle against the pre-mount generation. serializeRendererTerminalBuffer: vi diff --git a/src/main/runtime/rpc/terminal-subscribe-mounted-pane.test.ts b/src/main/runtime/rpc/terminal-subscribe-mounted-pane.test.ts index 466cf4b3901..9052dea6c3a 100644 --- a/src/main/runtime/rpc/terminal-subscribe-mounted-pane.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-mounted-pane.test.ts @@ -50,6 +50,8 @@ function subscribeMobile(pane: PaneDouble) { const binaryFrames: Uint8Array[] = [] const registry = createSubscriptionRegistryDouble() let emitData: (data: string, meta: { seq: number; rawLength: number }) => void = () => {} + // The host model a recovery seed builds: the adopted screen plus the output it replays. + let model: { data: string; cols: number; rows: number; seq?: number } | null = null const runtime = { getRuntimeId: () => 'test-runtime', subscribeToPtyExit: vi.fn(() => vi.fn()), @@ -60,7 +62,20 @@ function subscribeMobile(pane: PaneDouble) { getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 1), getRendererTerminalSerializerGeneration: vi.fn(() => 1), getPtyOutputSequence: vi.fn(pane.outputSequence ?? (() => 4)), - replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn( + async ( + _ptyId: string, + snapshot: { data: string; seq?: number }, + trailing: { data: string; seq: number }[] = [] + ) => { + model = { + data: snapshot.data + trailing.map((chunk) => chunk.data).join(''), + cols: 80, + rows: 24, + seq: trailing.at(-1)?.seq ?? snapshot.seq + } + } + ), waitForRendererTerminalSerializer: vi.fn(pane.waitForRendererTerminalSerializer), handleMobileSubscribe: vi.fn().mockResolvedValue(true), handleMobileUnsubscribe: vi.fn(), @@ -72,6 +87,9 @@ function subscribeMobile(pane: PaneDouble) { readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), // The restored provider snapshot wins the preference order over the live renderer. serializeTerminalBuffer: vi.fn(async () => { + if (model) { + return model + } if (pane.pendingOutput) { emitData(pane.pendingOutput, { seq: pane.pendingOutputSeq ?? 3, @@ -171,7 +189,8 @@ describe('terminal subscribe for a pane the desktop already has mounted', () => }) it('adopts a renderer-ordered screen even while output is pending', async () => { - // The screen's seq (4) is an exact seam inside the pending chunk (offsets 3..5), so only `z` replays. + // The screen's seq (4) is an exact seam inside the pending chunk (offsets 3..5), so only `z` + // replays into the model, and the model's seq (5) keeps it from replaying to the phone again. const subscription = subscribeMobile({ rendererScreen: () => 'ordered desktop prompt $ ', pendingOutput: 'Xz', @@ -185,7 +204,7 @@ describe('terminal subscribe for a pane the desktop already has mounted', () => await vi.advanceTimersByTimeAsync(100) expect(subscription.runtime.requestRendererTerminalTabMount).not.toHaveBeenCalled() - expect(subscription.snapshotText()).toContain('ordered desktop prompt $ ') + expect(subscription.snapshotText()).toContain('ordered desktop prompt $ z') expect(subscription.snapshotText()).not.toContain('restored provider history') expect( subscription.runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery @@ -194,7 +213,7 @@ describe('terminal subscribe for a pane the desktop already has mounted', () => expect.objectContaining({ data: 'ordered desktop prompt $ ', seq: 4 }), [{ data: 'z', seq: 5 }] ) - expect(subscription.outputText()).toBe('z') + expect(subscription.outputText()).toBe('') await subscription.close() })