fix(remote-terminal): a headless host keeps a parked terminal at its viewer's size (#29349)

* fix(remote-terminal): a headless host keeps a parked terminal at its viewer's grid

Parking a paired pane closes its stream, the PTY's last viewer. The floor then reclaimed the PTY to the host's geometry, which on a windowless orca serve/orcad host is the 120x40 spawn grid nobody sees. Every reveal then fetched a snapshot at 120x40 and reflowed it to the pane, and a TUI redrew twice. With no host pane to give the grid back to, the floor now keeps the last owner's grid; the parked owner's reserved seat resumes it without a resize.

* test(remote-terminal): a phone or another viewer still takes a parked terminal's grid on a headless host

* fix(remote-terminal): a phone leaving a parked headless terminal restores the parked grid

* fix(remote-terminal): keep the parked desktop grid as the restore baseline even while a phone fits

* test(remote-terminal): tie each PTY size probe to its own reply
This commit is contained in:
Neil
2026-10-15 23:14:11 -07:00
committed by GitHub
parent 00ab9bd694
commit fc534c6a19
6 changed files with 338 additions and 4 deletions
@@ -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)
})
})
@@ -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)
})
})
@@ -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(
@@ -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 {
@@ -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<string | null> {
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<void> => {
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<string | null> =>
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()
}
})
@@ -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:<token>`, 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 })
}
}