diff --git a/src/main/runtime/remote-desktop-headless-park.test.ts b/src/main/runtime/remote-desktop-headless-park.test.ts new file mode 100644 index 00000000000..ec6d497f1c2 --- /dev/null +++ b/src/main/runtime/remote-desktop-headless-park.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { publishHeadlessRuntimeGraph } from './headless-runtime-graph' + +// A parked paired pane closes its stream, the PTY's last viewer. On a windowless host the PTY must +// keep that viewer's grid, while other viewers and phones can still take it (#15997). + +const PTY = 'pty-1' +const SPAWN_GRID = { cols: 120, rows: 40 } + +function createRuntime({ headless }: { headless: boolean }) { + const store = { + getRepos: () => [], + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getSettings: () => ({ workspaceDir: '/tmp/workspaces', mobileAutoRestoreFitMs: 5_000 }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub covers the settings and repo reads the viewer floor reaches; the rest of Store is unreached. + const runtime = new OrcaRuntimeService(store as never) + const sizes = new Map([[PTY, { ...SPAWN_GRID }]]) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resize and getSize are the only controller members the layout queue drives here. + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: (ptyId: string, cols: number, rows: number) => { + sizes.set(ptyId, { cols, rows }) + return true + }, + getSize: (ptyId: string) => sizes.get(ptyId) ?? null + } as never) + if (headless) { + publishHeadlessRuntimeGraph(runtime) + } else { + runtime.attachWindow(1) + } + return { runtime, size: () => sizes.get(PTY) } +} + +describe('remote desktop viewers on a parked terminal', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('keeps the parked owner grid on a windowless host', async () => { + const { runtime, size } = createRuntime({ headless: true }) + await runtime.updateRemoteDesktopViewer(PTY, 'multiplex:a:1', 'laptop', 99, 67) + + await runtime.unregisterRemoteDesktopViewer(PTY, 'multiplex:a:1') + + expect(size()).toEqual({ cols: 99, rows: 67 }) + }) + + it('lets a phone attaching after the park take the grid', async () => { + const { runtime, size } = createRuntime({ headless: true }) + await runtime.updateRemoteDesktopViewer(PTY, 'multiplex:a:1', 'laptop', 99, 67) + await runtime.unregisterRemoteDesktopViewer(PTY, 'multiplex:a:1') + + await runtime.handleMobileSubscribe(PTY, 'phone', { cols: 45, rows: 20 }) + + expect(runtime.getDriver(PTY)).toEqual({ kind: 'mobile', clientId: 'phone' }) + expect(size()).toEqual({ cols: 45, rows: 20 }) + }) + + it('returns to the parked grid, not the spawn grid, after that phone leaves', async () => { + const { runtime, size } = createRuntime({ headless: true }) + await runtime.updateRemoteDesktopViewer(PTY, 'multiplex:a:1', 'laptop', 99, 67) + await runtime.unregisterRemoteDesktopViewer(PTY, 'multiplex:a:1') + await runtime.handleMobileSubscribe(PTY, 'phone', { cols: 45, rows: 20 }) + + runtime.handleMobileUnsubscribe(PTY, 'phone') + await vi.advanceTimersByTimeAsync(10_000) + + expect(runtime.getDriver(PTY).kind).not.toBe('mobile') + expect(size()).toEqual({ cols: 99, rows: 67 }) + }) + + it('restores the parked desktop grid when the park happened while a phone was fitting', async () => { + const { runtime, size } = createRuntime({ headless: true }) + await runtime.updateRemoteDesktopViewer(PTY, 'multiplex:a:1', 'laptop', 99, 67) + await runtime.handleMobileSubscribe(PTY, 'phone', { cols: 45, rows: 20 }) + await runtime.unregisterRemoteDesktopViewer(PTY, 'multiplex:a:1') + + runtime.handleMobileUnsubscribe(PTY, 'phone') + await vi.advanceTimersByTimeAsync(10_000) + + expect(runtime.getDriver(PTY).kind).not.toBe('mobile') + expect(size()).toEqual({ cols: 99, rows: 67 }) + }) + + it('lets a different desktop viewer that claims after the park take the grid', async () => { + const { runtime, size } = createRuntime({ headless: true }) + await runtime.updateRemoteDesktopViewer(PTY, 'multiplex:a:1', 'laptop', 99, 67) + await runtime.unregisterRemoteDesktopViewer(PTY, 'multiplex:a:1') + + await runtime.updateRemoteDesktopViewer(PTY, 'multiplex:b:1', 'mini', 80, 30, false) + expect(size()).toEqual({ cols: 99, rows: 67 }) + await runtime.claimRemoteDesktopViewer(PTY, 'multiplex:b:1') + + expect(size()).toEqual({ cols: 80, rows: 30 }) + }) + + it('still gives a host with a window its grid back', async () => { + const { runtime, size } = createRuntime({ headless: false }) + await runtime.updateRemoteDesktopViewer(PTY, 'multiplex:a:1', 'laptop', 99, 67) + + await runtime.unregisterRemoteDesktopViewer(PTY, 'multiplex:a:1') + + expect(size()).toEqual(SPAWN_GRID) + }) +}) diff --git a/src/main/runtime/remote-desktop-terminal-floor-reconnect.test.ts b/src/main/runtime/remote-desktop-terminal-floor-reconnect.test.ts index 5070230c32f..116a2926d13 100644 --- a/src/main/runtime/remote-desktop-terminal-floor-reconnect.test.ts +++ b/src/main/runtime/remote-desktop-terminal-floor-reconnect.test.ts @@ -5,13 +5,14 @@ const PTY = 'pty-1' // Spawn-size host geometry the floor reclaims to when no remote viewer owns the PTY. const HOST = { cols: 120, rows: 40 } -function createFloor() { +function createFloor({ hostShowsTerminals = true }: { hostShowsTerminals?: boolean } = {}) { let size = { ...HOST } const layouts: { kind: string; cols: number; rows: number }[] = [] const floor = new RemoteDesktopTerminalFloor({ isMobileDriven: () => false, getTerminalSize: () => size, resolveHostTarget: () => HOST, + hostShowsTerminals: () => hostShowsTerminals, applyLayout: async (_ptyId, target) => { layouts.push({ kind: target.kind, cols: target.cols, rows: target.rows }) size = { cols: target.cols, rows: target.rows } @@ -99,3 +100,50 @@ describe('remote desktop floor across a client reconnect', () => { expect(size()).toEqual({ cols: 132, rows: 42 }) }) }) + +describe('remote desktop floor on a windowless host', () => { + it('keeps the last owner grid when that owner leaves', async () => { + const { floor, layouts, size } = createFloor({ hostShowsTerminals: false }) + await floor.updateViewer(PTY, 'multiplex:old:1', 'pane-A', 159, 69) + layouts.splice(0) + + await floor.unregisterViewers(PTY, ['multiplex:old:1']) + + expect(layouts).toEqual([]) + expect(size()).toEqual({ cols: 159, rows: 69 }) + expect(floor.isResizeDriven(PTY)).toBe(false) + }) + + it('resumes the parked owner at its grid without a resize', async () => { + const { floor, layouts, size } = createFloor({ hostShowsTerminals: false }) + await floor.updateViewer(PTY, 'multiplex:old:1', 'pane-A', 159, 69) + await floor.unregisterViewers(PTY, ['multiplex:old:1']) + layouts.splice(0) + + await floor.updateViewer(PTY, 'multiplex:new:1', 'pane-A', 159, 69, false) + + expect(floor.isViewerOwner(PTY, 'multiplex:new:1')).toBe(true) + expect(size()).toEqual({ cols: 159, rows: 69 }) + expect(layouts.every((layout) => layout.cols === 159 && layout.rows === 69)).toBe(true) + }) + + it('still hands the seat to an active peer when the owner leaves', async () => { + const { floor, size } = createFloor({ hostShowsTerminals: false }) + await floor.updateViewer(PTY, 'multiplex:peer:1', 'pane-B', 100, 30) + await floor.updateViewer(PTY, 'multiplex:old:1', 'pane-A', 159, 69) + + await floor.unregisterViewers(PTY, ['multiplex:old:1']) + + expect(floor.isViewerOwner(PTY, 'multiplex:peer:1')).toBe(true) + expect(size()).toEqual({ cols: 100, rows: 30 }) + }) + + it('still gives a host with a window its grid back', async () => { + const { floor, size } = createFloor() + await floor.updateViewer(PTY, 'multiplex:old:1', 'pane-A', 159, 69) + + await floor.unregisterViewers(PTY, ['multiplex:old:1']) + + expect(size()).toEqual(HOST) + }) +}) diff --git a/src/main/runtime/remote-desktop-terminal-floor.ts b/src/main/runtime/remote-desktop-terminal-floor.ts index 1988c9b0b26..88a7819eaa7 100644 --- a/src/main/runtime/remote-desktop-terminal-floor.ts +++ b/src/main/runtime/remote-desktop-terminal-floor.ts @@ -10,6 +10,8 @@ export type RemoteDesktopTerminalFloorDependencies = { isMobileDriven: (ptyId: string) => boolean getTerminalSize: (ptyId: string) => Viewport | null resolveHostTarget: (ptyId: string) => Viewport + /** False on a headless host (orca serve, orcad), which has no pane to give a PTY's grid back to. */ + hostShowsTerminals: () => boolean applyLayout: (ptyId: string, target: LayoutTarget) => Promise<{ ok: boolean }> } @@ -202,9 +204,12 @@ export class RemoteDesktopTerminalFloor { } let changed = false let removedOwnerClientId: string | null = null + let removedOwnerViewport: Viewport | null = null for (const subscriptionKey of subscriptionKeys) { if (this.owners.get(ptyId) === subscriptionKey) { - removedOwnerClientId = viewers.get(subscriptionKey)?.clientId ?? null + const owner = viewers.get(subscriptionKey) + removedOwnerClientId = owner?.clientId ?? null + removedOwnerViewport = owner ? { cols: owner.cols, rows: owner.rows } : null } changed = viewers.delete(subscriptionKey) || changed } @@ -236,7 +241,20 @@ export class RemoteDesktopTerminalFloor { } } this.bumpRevision(ptyId) - return removedOwnerClientId !== null ? this.applyLayout(ptyId) : Promise.resolve(true) + if (removedOwnerClientId === null) { + return Promise.resolve(true) + } + if (!this.owners.has(ptyId) && !this.dependencies.hostShowsTerminals()) { + // Why: reclaiming would restore the spawn grid nobody sees, so the next reveal replays a + // snapshot at a foreign grid and TUIs redraw twice (#15997). The orphan seat resumes it. + if (removedOwnerViewport) { + // Why the viewer's grid, not the PTY's: a phone may be fitting the PTY, and its later + // restore must return to the parked desktop grid rather than the spawn or phone grid. + this.hostReclaimTargets.set(ptyId, removedOwnerViewport) + } + return Promise.resolve(true) + } + return this.applyLayout(ptyId) } refreshViewer( diff --git a/src/main/runtime/terminal/driver/terminal-driver-resources.ts b/src/main/runtime/terminal/driver/terminal-driver-resources.ts index 4670a269fa0..7912a2132cb 100644 --- a/src/main/runtime/terminal/driver/terminal-driver-resources.ts +++ b/src/main/runtime/terminal/driver/terminal-driver-resources.ts @@ -30,6 +30,7 @@ import { getRuntimeBrowserPageRegistry } from '../../runtime-browser-page-regist import { RuntimeBrowserDriverController } from '../../runtime-browser-driver-controller' import { RemoteDesktopTerminalFloor } from '../../remote-desktop-terminal-floor' +import { HEADLESS_RUNTIME_WINDOW_ID } from '../../../../shared/runtime-types' import type { StatsCollector } from '../../../stats/collector' import { RuntimeRemoteFetchController } from '../../runtime-remote-fetch-controller' import { RuntimeWorktreeBaseReconciliation } from '../../runtime-worktree-base-reconciliation' @@ -206,6 +207,7 @@ export abstract class OrcaRuntimeWithTerminalDrivers extends OrcaRuntimeWithFitO isMobileDriven: (ptyId) => this.getDriver(ptyId).kind === 'mobile', getTerminalSize: (ptyId) => this.getTerminalSize(ptyId) ?? null, resolveHostTarget: (ptyId) => this.resolveDesktopRestoreTarget(ptyId), + hostShowsTerminals: () => this.authoritativeWindowId !== HEADLESS_RUNTIME_WINDOW_ID, applyLayout: async (ptyId, target) => { this.freshSubscribeGuard.add(ptyId) try { diff --git a/tests/e2e/headless-paired-remote-terminal-viewer-size.spec.ts b/tests/e2e/headless-paired-remote-terminal-viewer-size.spec.ts new file mode 100644 index 00000000000..cb10ffab936 --- /dev/null +++ b/tests/e2e/headless-paired-remote-terminal-viewer-size.spec.ts @@ -0,0 +1,144 @@ +import type { Page } from '@stablyai/playwright-test' +import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id' +import { expect, test } from './helpers/orca-app' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' +import { launchPairedWebClient, type PairedWebClient } from './helpers/paired-electron-client' +import { callColdActivationRuntime } from './helpers/paired-terminal-cold-activation-observation' +import { createPairedTerminalParkingFixture } from './helpers/paired-terminal-parking-fixture' +import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal' + +// The most recent TERMINAL_WORKTREE_HOT_RETAIN_LIMIT worktrees stay warm, so park needs more. +const WARM_WORKTREE_COUNT = 4 + +let probeCount = 0 + +/** Asks the PTY's own process for its grid; the token ties the reply to this request. */ +async function readHostPtySize( + page: Page, + terminal: string, + readReportedSize: (token: string) => string | null +): Promise { + const token = `probe${++probeCount}` + await callColdActivationRuntime(page, 'terminal.send', { + terminal, + text: `SIZE:${token}`, + enter: true, + client: { id: 'viewer-size-e2e', type: 'desktop' } + }) + let size: string | null = null + await expect.poll(() => (size = readReportedSize(token)), { timeout: 10_000 }).not.toBeNull() + return size +} + +// Parking closes the client's stream, the last viewer of the PTY. A windowless host has no pane +// to hand the size back to, so the PTY must keep the viewer's grid instead of its spawn grid, +// or every reveal replays a snapshot at a foreign grid (#15997). +test('a headless host keeps a parked terminal at its last viewer grid', async ({ + testRepoPath +}) => { + test.setTimeout(240_000) + const host = await launchHeadlessPairedRuntimeHost() + const fixture = createPairedTerminalParkingFixture() + let client: PairedWebClient | null = null + try { + const added = await host.client.call<{ repo: { id: string } }>('repo.add', { + path: testRepoPath, + kind: 'git' + }) + client = await launchPairedWebClient(host.app, host.offer, { terminalParkingDelayMs: 100 }) + const page = client.page + await expect + .poll(() => page.evaluate(() => window.__store?.getState().allWorktrees()[0]?.id ?? null), { + timeout: 30_000 + }) + .not.toBeNull() + const fallbackWorktreeId = await page.evaluate( + () => window.__store?.getState().allWorktrees()[0]?.id ?? '' + ) + await page.evaluate(async () => { + await window.__store?.getState().updateSettings({ + terminalHiddenViewParking: true, + terminalHiddenWorktreeRetentionBudget: false + }) + }) + const marker = `VIEWER_SIZE_${Date.now()}` + const worktrees: { worktreeId: string; terminal: string; tabId: string }[] = [] + for (let index = 0; index <= WARM_WORKTREE_COUNT; index += 1) { + const created = await callColdActivationRuntime<{ + startupTerminal?: { handle?: string; tabId?: string } + worktree: { id: string } + }>(page, 'worktree.create', { + repo: added.result.repo.id, + name: `viewer-size-${Date.now()}-${index}`, + setupDecision: 'skip', + activate: false, + noParent: true, + startupCommand: fixture.command(`${marker}_${index}`) + }) + const handle = created.startupTerminal?.handle + const hostTabId = created.startupTerminal?.tabId + if (!handle || !hostTabId) { + throw new Error(`Viewer-size startup terminal ${index} was not created`) + } + worktrees.push({ + worktreeId: created.worktree.id, + terminal: handle, + tabId: toWebTerminalSurfaceTabId(hostTabId) + }) + } + const openTab = async (entry: { worktreeId: string; tabId: string }): Promise => { + await page.evaluate((id) => { + const state = window.__store?.getState() + state?.setActiveView('terminal') + state?.setActiveWorktree(id) + }, entry.worktreeId) + const tab = page.locator(`[data-testid="sortable-tab"][data-tab-id="${entry.tabId}"]`) + await expect(tab).toBeVisible({ timeout: 30_000 }) + await tab.click() + await waitForActivePanePtyId(page, 30_000) + } + const target = worktrees[0] + await openTab(target) + await expect + .poll(() => getTerminalContent(page), { timeout: 30_000 }) + .toContain(`READY:${marker}_0`) + const paneGrid = await page.evaluate((id) => { + const terminal = window.__paneManagers?.get(id)?.getActivePane()?.terminal + return terminal ? `${terminal.cols}x${terminal.rows}` : null + }, target.tabId) + const readTargetSize = (): Promise => + readHostPtySize(page, target.terminal, (token) => + fixture.readReportedSize(`${marker}_0`, token) + ) + await expect + .poll(readTargetSize, { timeout: 30_000, intervals: [500, 1_000, 2_000] }) + .toBe(paneGrid) + + for (const warm of worktrees.slice(1)) { + await openTab(warm) + } + await page.evaluate( + (id) => window.__store?.getState().setActiveWorktree(id), + fallbackWorktreeId + ) + await expect + .poll( + () => + page.evaluate( + (id) => + !window.__paneManagers?.has(id) && + (window.__terminalParkingDebug?.parkedTabIds() ?? []).includes(id), + target.tabId + ), + { timeout: 30_000 } + ) + .toBe(true) + // Give a reclaim, if any, time to land before sampling the parked PTY. + await page.waitForTimeout(1_000) + expect(await readTargetSize()).toBe(paneGrid) + } finally { + await client?.dispose() + await host.dispose() + fixture.dispose() + } +}) diff --git a/tests/e2e/helpers/paired-terminal-parking-fixture.ts b/tests/e2e/helpers/paired-terminal-parking-fixture.ts index 2a0e792e435..3c986c4ad75 100644 --- a/tests/e2e/helpers/paired-terminal-parking-fixture.ts +++ b/tests/e2e/helpers/paired-terminal-parking-fixture.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { quotePosixShell } from '../../../src/shared/wsl-login-shell-command' @@ -18,6 +18,8 @@ function fixtureCommand(fixturePath: string, marker: string): string { export function createPairedTerminalParkingFixture(): { command: (marker: string) => string + /** PTY grid the fixture saw at `SIZE:`, read off disk so a parked pane needs no client. */ + readReportedSize: (marker: string, token: string) => string | null dispose: () => void } { const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-retention-memory-')) @@ -25,6 +27,7 @@ export function createPairedTerminalParkingFixture(): { writeFileSync( fixturePath, [ + "import { writeFileSync } from 'node:fs'", 'const marker = process.argv[2]', 'process.stdout.write(`READY:${marker}\\r\\n`)', 'process.stdin.setRawMode?.(true)', @@ -65,6 +68,11 @@ export function createPairedTerminalParkingFixture(): { ' process.stdout.write(`FILLED:${marker}\\r\\n`)', ' continue', ' }', + " if (command.startsWith('SIZE:')) {", + " const token = command.slice('SIZE:'.length)", + ' writeFileSync(`${process.argv[1]}.${marker}.${token}.size`, `${process.stdout.columns}x${process.stdout.rows}`)', + ' continue', + ' }', ' process.stdout.write(`LIVE:${command}\\r\\n`)', ' }', '})', @@ -73,6 +81,10 @@ export function createPairedTerminalParkingFixture(): { ) return { command: (marker) => fixtureCommand(fixturePath, marker), + readReportedSize: (marker, token) => { + const sizePath = `${fixturePath}.${marker}.${token}.size` + return existsSync(sizePath) ? readFileSync(sizePath, 'utf8') : null + }, dispose: () => rmSync(scratch, { recursive: true, force: true }) } }