fix(terminal): let remote desktop viewers own the shared PTY width

A remote (relay/shared-control) desktop viewer resizes the host source PTY to
its own width, but was never registered as a width owner. So the host's own fit
cascade (window resize, split drag, tab reveal, "+"-new-tab re-render) freely
resized the viewed PTY back to the host-local width with no signal to the
viewer. The viewer kept its narrower grid while the host streamed wider
alt-screen frames -> cell-layout garble ("porridge") until a manual resize.

The mobile "presence lock" already solves this shape for phones by suppressing
the host's pty:resize while the phone drives. This does the same for remote
desktop viewers WITHOUT joining the mobile driver state machine (a viewer needs
only resize suppression, not input lock / phone-fit / driver banners), and it
routes every PTY geometry change through the existing enqueueLayout/applyLayout
serialization path rather than resizing the PTY ad hoc.

Design:
- Registry `remoteDesktopViewers: Map<ptyId, Map<subscriptionKey, viewport>>`,
  keyed per SUBSCRIPTION so duplicate streams of one client cannot release each
  other. isPtyResizeDrivenRemotely() = mobile driver OR any viewer present;
  pty:resize (host fit cascade) bails on it. INPUT is never locked (shared
  control: host and viewers can both type), unlike mobile.
- New internal layout target { kind: 'remote-desktop' } in applyLayout: clears
  terminalFitOverrides like 'desktop', resizes only when dims changed, emits no
  fit-override notifications, and does not call onExternalPtyResize (so a
  viewer's width never pollutes desktop restore state). enqueueLayout remains
  the sole serialized writer of PTY dimensions.
- Smallest client wins: applyRemoteDesktopLayout() sizes the PTY to the SMALLEST
  attached viewer, so viewers with different screen sizes never overflow the
  narrowest grid.
- No snapshot/replay race: a viewport that arrives while the initial scrollback
  snapshot is being serialized is buffered and applied as ONE serialized resize
  before serialization, so a mid-repaint alt screen is never baked into the
  snapshot.
- Reclaim on detach: when the last viewer leaves, applyRemoteDesktopLayout
  resizes the PTY back to the host's own width via enqueueLayout({kind:'desktop'}),
  so the host reflows to full width without a manual window jiggle. The host
  width is captured at the pty:resize suppression point (the host's own resize
  attempts) — a source a remote viewport never pollutes.
- Mobile coexistence: a phone outranks a viewer; when the phone leaves, the
  mobile-release paths call applyRemoteDesktopLayout so a surviving viewer keeps
  the PTY, else the host reclaims.

Verified live (two local desktop instances): correct render on connect, no
garble on viewer-window resize (synchronous), and host reclaims full width on
disconnect. Unit tests cover the registry lifecycle, smallest-client-wins,
reclaim-via-layout, and mobile coexistence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
shady
2026-07-11 04:40:39 -04:00
committed by Jinwoo-H
co-authored by Claude Opus 4.8 Codex
parent 20ebe858af
commit 34f132afae
6 changed files with 600 additions and 55 deletions
+48
View File
@@ -4170,10 +4170,12 @@ describe('registerPtyHandlers', () => {
createPreAllocatedTerminalHandle: vi.fn(() => null),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
isPtyResizeDrivenRemotely: vi.fn(() => false),
isResizeSuppressed: vi.fn(() => false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(),
recordRemoteDesktopHostReclaimTarget: vi.fn(),
onExternalPtyResize: vi.fn()
}
handlers.clear()
@@ -4202,10 +4204,12 @@ describe('registerPtyHandlers', () => {
createPreAllocatedTerminalHandle: vi.fn(() => null),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
isPtyResizeDrivenRemotely: vi.fn(() => false),
isResizeSuppressed: vi.fn(() => false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(),
recordRemoteDesktopHostReclaimTarget: vi.fn(),
onExternalPtyResize: vi.fn()
}
handlers.clear()
@@ -4217,6 +4221,39 @@ describe('registerPtyHandlers', () => {
expect(runtime.onExternalPtyResize).not.toHaveBeenCalled()
})
it('suppresses the host fit cascade while a remote viewer drives the width', async () => {
const resizeSpy = vi.fn()
setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 }, resize: resizeSpy })
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => null),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'idle' })),
// The whole point of the fix: a PTY with a remote viewer reports true,
// even though the presence-lock driver state stays idle/desktop.
isPtyResizeDrivenRemotely: vi.fn(() => true),
isResizeSuppressed: vi.fn(() => false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(),
recordRemoteDesktopHostReclaimTarget: vi.fn(),
onExternalPtyResize: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} })
const id = (spawn as { id: string }).id
resizeSpy.mockClear()
// Host's own safeFit tries to widen the viewed PTY back to its window.
resizeListener()(mainWindowIpcEvent, { id, cols: 125, rows: 48 })
// It must not reach the PTY while the viewer owns the width.
expect(resizeSpy).not.toHaveBeenCalled()
expect(runtime.recordRemoteDesktopHostReclaimTarget).toHaveBeenCalledWith(id, 125, 48)
expect(runtime.onExternalPtyResize).not.toHaveBeenCalled()
})
})
it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => {
@@ -4289,6 +4326,7 @@ describe('registerPtyHandlers', () => {
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
isPtyResizeDrivenRemotely: vi.fn(() => false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
@@ -4368,6 +4406,7 @@ describe('registerPtyHandlers', () => {
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
isPtyResizeDrivenRemotely: vi.fn(() => false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
@@ -4399,6 +4438,7 @@ describe('registerPtyHandlers', () => {
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
isPtyResizeDrivenRemotely: vi.fn(() => false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
@@ -4436,6 +4476,7 @@ describe('registerPtyHandlers', () => {
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
isPtyResizeDrivenRemotely: vi.fn(() => false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
@@ -4533,6 +4574,7 @@ describe('registerPtyHandlers', () => {
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
isPtyResizeDrivenRemotely: vi.fn(() => false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
@@ -4585,6 +4627,7 @@ describe('registerPtyHandlers', () => {
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
isPtyResizeDrivenRemotely: vi.fn(() => false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
@@ -5073,6 +5116,7 @@ describe('registerPtyHandlers', () => {
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
isPtyResizeDrivenRemotely: vi.fn(() => false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
@@ -5330,6 +5374,7 @@ describe('registerPtyHandlers', () => {
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
isPtyResizeDrivenRemotely: vi.fn(() => false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
@@ -5439,6 +5484,7 @@ describe('registerPtyHandlers', () => {
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
isPtyResizeDrivenRemotely: vi.fn(() => false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
@@ -5533,6 +5579,7 @@ describe('registerPtyHandlers', () => {
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
isPtyResizeDrivenRemotely: vi.fn(() => false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
@@ -5637,6 +5684,7 @@ describe('registerPtyHandlers', () => {
registerPreAllocatedHandleForPty: vi.fn(),
registerPty: vi.fn(),
getDriver: vi.fn(() => ({ kind: 'host' })),
isPtyResizeDrivenRemotely: vi.fn(() => false),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
+12 -8
View File
@@ -4538,14 +4538,18 @@ export function registerPtyHandlers(
if (runtime?.isResizeSuppressed()) {
return
}
// Why: presence-lock defense-in-depth. While mobile is driving,
// desktop-side resizes (auto-fit on window resize, split drag) must
// not reach the PTY. The renderer guard checks the driver state too,
// but this is the load-bearing layer because the renderer mirror lags
// by one IPC hop. Note: BOTH guards apply — isResizeSuppressed handles
// the safeFit cascade after take-back; this driver check handles the
// ongoing locked state. See docs/mobile-presence-lock.md.
if (runtime?.getDriver(args.id).kind === 'mobile') {
// Why: presence-lock defense-in-depth. While a phone OR a remote desktop
// viewer drives the PTY width, the host's own desktop-side resizes
// (auto-fit on window resize, split drag, tab reveal, "+"-new-tab
// re-render) must not reach the PTY — otherwise they overwrite the remote
// viewer's grid and its alt-screen TUI garbles ("porridge"). The renderer
// guard checks the driver state too, but this is the load-bearing layer
// because the renderer mirror lags by one IPC hop. Note: BOTH guards apply
// — isResizeSuppressed handles the safeFit cascade after take-back; this
// driver check handles the ongoing locked state. See
// docs/mobile-presence-lock.md.
if (runtime?.isPtyResizeDrivenRemotely(args.id)) {
runtime.recordRemoteDesktopHostReclaimTarget(args.id, args.cols, args.rows)
return
}
const provider = tryGetProviderForPty(args.id)
+181 -34
View File
@@ -2062,6 +2062,7 @@ export type DriverState = RuntimeTerminalDriverState
export type PtyLayoutTarget =
| { kind: 'desktop'; cols: number; rows: number }
| { kind: 'phone'; cols: number; rows: number; ownerClientId: string }
| { kind: 'remote-desktop'; cols: number; rows: number }
// Why: authoritative layout state with monotonic seq. Bumped on every
// applyLayout success; emitted on mobile subscribe-stream events so clients
@@ -2329,6 +2330,15 @@ export class OrcaRuntimeService {
private currentDriver = new Map<string, DriverState>()
private currentBrowserDriver = new Map<string, RuntimeBrowserDriverState>()
// Why: remote (relay/shared-control) desktop viewers of a PTY are keyed by
// subscription, not client, because one client can open duplicate streams and
// each stream must release only the width floor it registered.
private remoteDesktopViewers = new Map<
string,
Map<string, { clientId: string; cols: number; rows: number }>
>()
private remoteDesktopHostReclaimTargets = new Map<string, { cols: number; rows: number }>()
// Why: resubscribe-grace window. When the last mobile subscriber for a
// PTY unsubscribes, we hold the driver=mobile{clientId} state and the
// inner-map record open for ~250ms. If the same (ptyId, clientId)
@@ -7851,7 +7861,11 @@ export class OrcaRuntimeService {
// at phone dims after the phone disconnects; the desktop banner's
// Restore button is the explicit return path. See
// docs/mobile-fit-hold.md.
if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) {
if (this.hasRemoteDesktopViewers(ptyId)) {
this.setDriver(ptyId, { kind: 'idle' })
void this.applyRemoteDesktopLayout(ptyId)
continue
} else if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) {
// Use the soft-leaver's snapshot baseline as a hint, falling
// through to resolveDesktopRestoreTarget for missing values.
const fallback = this.resolveDesktopRestoreTarget(ptyId)
@@ -7890,7 +7904,11 @@ export class OrcaRuntimeService {
for (const { ptyId, baseline } of ptysToRestore) {
const cur = this.layouts.get(ptyId)
// Why: Indefinite hold gate — see soft-leaver branch above.
if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) {
if (this.hasRemoteDesktopViewers(ptyId)) {
this.setDriver(ptyId, { kind: 'idle' })
void this.applyRemoteDesktopLayout(ptyId)
continue
} else if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) {
const fallback = this.resolveDesktopRestoreTarget(ptyId)
const cols = baseline?.cols ?? fallback.cols
const rows = baseline?.rows ?? fallback.rows
@@ -8019,6 +8037,8 @@ export class OrcaRuntimeService {
this.currentDriver.delete(ptyId)
this.notifier?.terminalDriverChanged(ptyId, { kind: 'idle' })
}
this.remoteDesktopViewers.delete(ptyId)
this.remoteDesktopHostReclaimTargets.delete(ptyId)
this.disposeHeadlessTerminal(ptyId)
this.agentDetector?.onExit(ptyId)
const pty = this.ptysById.get(ptyId)
@@ -8074,6 +8094,157 @@ export class OrcaRuntimeService {
}
}
// Why: the host's own fit cascade (window resize, split drag, tab reveal,
// "+"-new-tab re-render) must not resize a PTY whose width a remote client
// owns — that is the remote "porridge" bug. True while a phone (mobile driver)
// OR a remote desktop viewer holds the PTY. Input is deliberately NOT gated
// here (see the `writePtyInput` mobile-only checks): shared-control desktop
// viewers may still type alongside the host.
// Note: this is intentionally NOT a driver kind. A remote desktop viewer needs
// only resize suppression, not the mobile driver machinery (input lock,
// phone-fit, driver-change banners), so it lives in its own registry and does
// not perturb the presence-lock state machine. It also coexists with mobile:
// while a phone drives, the registry still suppresses host resize, and when
// the phone leaves the surviving viewer keeps the PTY suppressed.
isPtyResizeDrivenRemotely(ptyId: string): boolean {
if (this.getDriver(ptyId).kind === 'mobile') {
return true
}
return this.hasRemoteDesktopViewers(ptyId)
}
private hasRemoteDesktopViewers(ptyId: string): boolean {
const viewers = this.remoteDesktopViewers.get(ptyId)
return viewers !== undefined && viewers.size > 0
}
// Why: the smallest attached viewer wins (tmux model). A wider viewer showing
// a few unused columns is fine; a narrower viewer receiving host-width frames
// garbles, so the PTY must never exceed the narrowest grid.
private minRemoteDesktopViewport(
viewers: Map<string, { cols: number; rows: number }>
): { cols: number; rows: number } | null {
let cols = Infinity
let rows = Infinity
for (const viewport of viewers.values()) {
cols = Math.min(cols, viewport.cols)
rows = Math.min(rows, viewport.rows)
}
return Number.isFinite(cols) && Number.isFinite(rows) ? { cols, rows } : null
}
private resolveRemoteDesktopHostReclaimTarget(ptyId: string): { cols: number; rows: number } {
const target = this.remoteDesktopHostReclaimTargets.get(ptyId)
if (target) {
return target
}
const renderer = this.lastRendererSizes.get(ptyId)
if (renderer) {
return { cols: renderer.cols, rows: renderer.rows }
}
const size = this.getTerminalSize(ptyId)
if (size) {
return { cols: size.cols, rows: size.rows }
}
return { cols: 80, rows: 24 }
}
private ensureRemoteDesktopHostReclaimTarget(ptyId: string): void {
if (!this.remoteDesktopHostReclaimTargets.has(ptyId)) {
this.remoteDesktopHostReclaimTargets.set(
ptyId,
this.resolveRemoteDesktopHostReclaimTarget(ptyId)
)
}
}
recordRemoteDesktopHostReclaimTarget(ptyId: string, cols: number, rows: number): void {
if (cols <= 0 || rows <= 0) {
return
}
this.remoteDesktopHostReclaimTargets.set(ptyId, { cols, rows })
}
async applyRemoteDesktopLayout(ptyId: string): Promise<boolean> {
if (this.getDriver(ptyId).kind === 'mobile') {
return true
}
const viewers = this.remoteDesktopViewers.get(ptyId)
const target = viewers ? this.minRemoteDesktopViewport(viewers) : null
const reclaimingHost = !target
const layoutTarget: PtyLayoutTarget = target
? { kind: 'remote-desktop', cols: target.cols, rows: target.rows }
: { kind: 'desktop', ...this.resolveRemoteDesktopHostReclaimTarget(ptyId) }
this.freshSubscribeGuard.add(ptyId)
try {
const result = await this.enqueueLayout(ptyId, layoutTarget)
if (reclaimingHost) {
this.remoteDesktopHostReclaimTargets.delete(ptyId)
}
return result.ok
} finally {
this.freshSubscribeGuard.delete(ptyId)
}
}
// Why: a remote (relay/shared-control) desktop viewer reporting a viewport
// takes/refreshes the width floor so the host's fit cascade stops fighting it.
// The source PTY is sized to the SMALLEST attached viewer so no viewer ever
// receives frames wider than its grid.
async updateRemoteDesktopViewer(
ptyId: string,
subscriptionKey: string,
clientId: string,
cols: number,
rows: number
): Promise<boolean> {
const viewport = clampTerminalViewport(cols, rows)
this.ensureRemoteDesktopHostReclaimTarget(ptyId)
let viewers = this.remoteDesktopViewers.get(ptyId)
if (!viewers) {
viewers = new Map<string, { clientId: string; cols: number; rows: number }>()
this.remoteDesktopViewers.set(ptyId, viewers)
}
viewers.set(subscriptionKey, { clientId, cols: viewport.cols, rows: viewport.rows })
return this.applyRemoteDesktopLayout(ptyId)
}
unregisterRemoteDesktopViewer(ptyId: string, subscriptionKey: string): Promise<boolean> {
const viewers = this.remoteDesktopViewers.get(ptyId)
if (!viewers) {
return Promise.resolve(false)
}
viewers.delete(subscriptionKey)
if (viewers.size === 0) {
this.remoteDesktopViewers.delete(ptyId)
}
return this.applyRemoteDesktopLayout(ptyId)
}
async updateDesktopViewport(
ptyId: string,
viewport: { cols: number; rows: number }
): Promise<boolean> {
const { cols, rows } = clampTerminalViewport(viewport.cols, viewport.rows)
if (this.terminalFitOverrides.has(ptyId) || this.getDriver(ptyId).kind === 'mobile') {
this.recordRendererGeometry(ptyId, cols, rows)
return true
}
if (this.isResizeSuppressed()) {
return false
}
this.freshSubscribeGuard.add(ptyId)
try {
const result = await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows })
if (result.ok) {
this.refreshRendererGeometry(ptyId, cols, rows)
}
return result.ok
} finally {
this.freshSubscribeGuard.delete(ptyId)
}
}
markMobileActor(ptyId: string, clientId: string): void {
const inner = this.mobileSubscribers.get(ptyId)
const sub = inner?.get(clientId)
@@ -8174,37 +8345,6 @@ export class OrcaRuntimeService {
return { updated: true, applied: result.ok }
}
// Why: remote desktop clients do not have the local `pty:resize` IPC path.
// Their measured xterm size still has to resize the source PTY so TUIs
// reflow to the visible client dimensions.
async updateDesktopViewport(
ptyId: string,
viewport: { cols: number; rows: number }
): Promise<boolean> {
const { cols, rows } = clampTerminalViewport(viewport.cols, viewport.rows)
if (this.terminalFitOverrides.has(ptyId)) {
// Why: remote desktop panes do not have the local pty:reportGeometry
// IPC. While phone-fit holds the PTY, treat their viewport RPC as a
// measurement-only restore target, not a resize intent.
this.recordRendererGeometry(ptyId, cols, rows)
return true
}
if (this.isResizeSuppressed() || this.getDriver(ptyId).kind === 'mobile') {
return false
}
let resized = false
try {
resized = this.ptyController?.resize?.(ptyId, cols, rows) ?? false
} catch {
return false
}
if (!resized) {
return false
}
this.onExternalPtyResize(ptyId, cols, rows)
return true
}
// Why: invoked from `runtime:restoreTerminalFit` IPC (the desktop "Take
// back" / "Restore" button). Forces the PTY back to desktop dims and flips
// the driver to `desktop`, suppressing further mobile-driven dim changes
@@ -8602,7 +8742,7 @@ export class OrcaRuntimeService {
// only if that invariant is ever violated, repairing the renderer instead
// of stranding the held modal.
const overrideChanged = (prevFitOverride != null) !== (target.kind === 'phone')
if (modeChanged || overrideChanged) {
if (target.kind !== 'remote-desktop' && (modeChanged || overrideChanged)) {
// Why: phone→desktop arms the renderer-cascade suppress window
// before the collateral safeFit IPCs arrive. See "Renderer cascade
// suppression".
@@ -8938,6 +9078,9 @@ export class OrcaRuntimeService {
this.pendingSoftLeavers.delete(ptyId)
if (!this.mobileSubscribers.has(ptyId)) {
this.setDriver(ptyId, { kind: 'idle' })
if (this.hasRemoteDesktopViewers(ptyId)) {
void this.applyRemoteDesktopLayout(ptyId)
}
}
}, SOFT_LEAVE_GRACE_MS)
if (typeof softTimer.unref === 'function') {
@@ -8990,6 +9133,10 @@ export class OrcaRuntimeService {
if (this.isMobileSubscriberActive(ptyId)) {
return
}
if (this.hasRemoteDesktopViewers(ptyId)) {
void this.applyRemoteDesktopLayout(ptyId)
return
}
void this.enqueueLayout(ptyId, {
kind: 'desktop',
cols: restoreCols,
@@ -0,0 +1,209 @@
/**
* Tests for the remote-desktop viewer width driver.
*
* A remote (relay/shared-control) desktop viewer takes the PTY width floor so
* the host's own fit cascade stops resizing the viewed PTY out from under it
* (the remote alt-screen "porridge"). Mirrors the mobile presence lock but
* suppresses only RESIZE, never input. Covers:
* - idle → remote-desktop on register; release to idle on last unregister
* - multi-viewer: driver survives until the last viewer detaches
* - a live mobile driver outranks a remote-desktop viewer
* - isPtyResizeDrivenRemotely gates host resize for mobile AND remote-desktop
* - PTY exit clears the remote-desktop registry
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import type * as GitUsernameModule from '../git/git-username'
import { OrcaRuntimeService } from './orca-runtime'
vi.mock('../git/worktree', () => ({
listWorktrees: vi.fn().mockResolvedValue([]),
listWorktreesStrict: vi.fn().mockResolvedValue([])
}))
vi.mock('../hooks', () => ({
createSetupRunnerScript: vi.fn(),
getEffectiveHooks: vi.fn().mockReturnValue(null),
runHook: vi.fn().mockResolvedValue({ success: true, output: '' })
}))
vi.mock('../ipc/worktree-logic', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() }
})
vi.mock('../ipc/filesystem-auth', () => ({ invalidateAuthorizedRootsCache: vi.fn() }))
vi.mock('../git/repo', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'),
getBranchConflictKind: vi.fn().mockResolvedValue(null)
}
})
vi.mock('../git/git-username', async () => {
const actual = await vi.importActual<typeof GitUsernameModule>('../git/git-username')
return { ...actual, resolveLocalGitUsername: vi.fn(async () => '') }
})
const store = {
getRepo: () => ({
id: 'repo-1',
path: '/tmp/repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1
}),
getRepos: () => [store.getRepo()],
addRepo: () => {},
updateRepo: () => undefined as never,
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
getGitHubCache: () => ({ pr: {}, issue: {} }),
setWorktreeMeta: () => undefined as never,
removeWorktreeMeta: () => {},
getSettings: () => ({
workspaceDir: '/tmp/workspaces',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: 'none',
branchPrefixCustom: '',
mobileAutoRestoreFitMs: 5_000
})
}
function createRuntime() {
const runtime = new OrcaRuntimeService(store)
const ptySizes = new Map<string, { cols: number; rows: number }>([
['pty-1', { cols: 150, rows: 40 }]
])
const driverEvents: { ptyId: string; driver: { kind: string; clientId?: string } }[] = []
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
resize: (ptyId, cols, rows) => {
ptySizes.set(ptyId, { cols, rows })
return true
},
getSize: (ptyId) => ptySizes.get(ptyId) ?? null
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: (ptyId, driver) => {
driverEvents.push({ ptyId, driver: { ...driver } })
}
})
return { runtime, driverEvents }
}
describe('remote desktop viewer width driver', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('applying a viewport suppresses host resize without touching driver state', async () => {
const { runtime, driverEvents } = createRuntime()
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false)
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40)
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 100, rows: 40 })
// It is deliberately NOT a driver kind: the presence-lock state machine and
// its cross-layer driver-change notifications stay untouched.
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' })
expect(driverEvents).toHaveLength(0)
})
it('sizes the PTY to the SMALLEST attached viewer (smallest client wins)', async () => {
const { runtime } = createRuntime()
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 100, rows: 40 })
// A narrower viewer joins — the PTY shrinks so its wider frames never
// overflow the narrow grid.
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 30)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 30 })
// The wide viewer growing does not widen the PTY past the narrow viewer.
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 140, 50)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 30 })
// The narrow viewer leaves — the PTY re-fits to the survivor's width.
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B')
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 140, rows: 50 })
})
it('stops suppressing host resize only when the LAST viewer detaches', async () => {
const { runtime } = createRuntime()
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40)
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 40)
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true)
// First viewer leaves — another remote viewer still holds the floor.
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true)
// Last viewer leaves — the host reclaims its own width (next pty:resize applies).
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B')
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false)
})
it('coexists with a mobile driver and outlives it (host stays suppressed)', async () => {
const { runtime } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' })
// A desktop viewer registering must NOT disturb the mobile driver.
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40)
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' })
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true)
// When the phone leaves, the surviving viewer keeps host resize suppressed
// (the registry is independent of the mobile driver state).
runtime.onClientDisconnected('phone-A')
vi.advanceTimersByTime(10_000)
expect(runtime.getDriver('pty-1').kind).not.toBe('mobile')
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true)
})
it('isPtyResizeDrivenRemotely is false for idle and desktop drivers', async () => {
const { runtime } = createRuntime()
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false)
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40)
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false)
})
it('reclaims the host width when the last viewer detaches', async () => {
const { runtime } = createRuntime()
// The host renderer reports its own 120-wide geometry (pty:reportGeometry).
runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 40)
// The viewer drives the source PTY to its own 80-wide viewport.
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 40)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 40 })
// Detaching the last viewer must actively resize the PTY back to the host's
// OWN width (120), not the departed viewer's polluted 80.
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 120, rows: 40 })
})
it('PTY exit clears the remote-desktop registry', async () => {
const { runtime } = createRuntime()
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40)
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true)
runtime.onPtyExit('pty-1', 0)
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false)
// A fresh viewer on the same id re-establishes suppression cleanly (no stale set).
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 100, 40)
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true)
})
})
+132 -3
View File
@@ -88,6 +88,12 @@ type TerminalMultiplexStream = {
isMobile: boolean
ackOutput: boolean
ackInFlightBytes: number
// Why: whether THIS stream registered a remote-desktop width driver, so
// detach only unregisters what it registered — a passive (viewport-less)
// stream sharing a client id must not release another stream's width floor.
registeredRemoteDesktopDriver: boolean
remoteDesktopSubscriptionKey: string
pendingRemoteDesktopViewport: { cols: number; rows: number } | null
buffering: boolean
ackPendingOutput: TerminalOutputFrameChunk[]
ackPendingOutputBytes: number
@@ -631,6 +637,7 @@ async function sendMobileResizeRestream(
async function updateViewportForClient(
runtime: OrcaRuntimeService,
ptyId: string,
subscriptionKey: string,
client: TerminalViewportClient,
viewport: { cols: number; rows: number },
defaultType: 'mobile' | 'desktop'
@@ -639,7 +646,16 @@ async function updateViewportForClient(
if (type === 'mobile') {
return runtime.updateMobileViewport(ptyId, client.id, viewport)
}
const updated = await runtime.updateDesktopViewport(ptyId, viewport)
// Why: a remote desktop viewer's viewport drives the source PTY to the
// smallest attached viewer (tmux "smallest client wins") and registers it as
// a width owner so the host's fit cascade stops fighting it.
const updated = await runtime.updateRemoteDesktopViewer(
ptyId,
subscriptionKey,
client.id,
viewport.cols,
viewport.rows
)
return { updated, applied: updated }
}
@@ -1280,6 +1296,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const viewportUpdate = await updateViewportForClient(
runtime,
leaf.ptyId,
`viewport:${params.client.id}`,
params.client,
params.viewport,
'mobile'
@@ -1506,6 +1523,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
stream.exitWaiterAbort.abort()
if (stream.isMobile && stream.client?.id) {
runtime.handleMobileUnsubscribe(stream.ptyId, stream.client.id)
} else if (stream.registeredRemoteDesktopDriver && stream.client?.id) {
// Why: release the remote-desktop width floor so the host can reclaim
// its own width once the last remote viewer leaves — but only if THIS
// stream took it (a passive stream must not release a peer's floor).
runtime.unregisterRemoteDesktopViewer(stream.ptyId, stream.remoteDesktopSubscriptionKey)
}
if (emitEnd) {
emit({ type: 'end', streamId })
@@ -1567,9 +1589,20 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') {
return
}
// Why: a desktop viewport update registers this stream as a width
// owner (even if it had no initial viewport at subscribe), so detach
// must later release it.
if (!stream.isMobile && stream.client?.id) {
stream.registeredRemoteDesktopDriver = true
if (stream.buffering) {
stream.pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows }
return
}
}
void updateViewportForClient(
runtime,
stream.ptyId,
stream.remoteDesktopSubscriptionKey,
stream.client,
{ cols: viewport.cols, rows: viewport.rows },
stream.isMobile ? 'mobile' : 'desktop'
@@ -1730,6 +1763,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
isMobile,
ackOutput: request.capabilities?.ackOutput === 1,
ackInFlightBytes: 0,
registeredRemoteDesktopDriver: false,
remoteDesktopSubscriptionKey: `multiplex:${request.streamId}`,
pendingRemoteDesktopViewport: null,
buffering: true,
ackPendingOutput: [],
ackPendingOutputBytes: 0,
@@ -1788,12 +1824,29 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (isMobile && request.client?.id) {
await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport)
} else if (request.viewport && request.client) {
} else if (request.client?.id && request.viewport) {
// Why: a remote desktop viewer that reports a viewport is driving
// the PTY width, so the runtime registers it as a
// width owner (host fit cascade suppressed) and sizes the PTY to the
// smallest attached viewer. A viewport-less subscriber is a passive
// watcher and must NOT lock host resize.
stream.registeredRemoteDesktopDriver = true
stream.pendingRemoteDesktopViewport = request.viewport
}
if (
!isMobile &&
request.client?.id &&
stream.registeredRemoteDesktopDriver &&
stream.pendingRemoteDesktopViewport
) {
const viewport = stream.pendingRemoteDesktopViewport
stream.pendingRemoteDesktopViewport = null
await updateViewportForClient(
runtime,
ptyId,
stream.remoteDesktopSubscriptionKey,
request.client,
request.viewport,
viewport,
'desktop'
)
}
@@ -1906,6 +1959,23 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
stream.pendingOutputBytes = 0
stream.pendingOutputOverflowed = false
stream.outputBatcher.flush()
if (
!stream.isMobile &&
stream.client?.id &&
stream.registeredRemoteDesktopDriver &&
stream.pendingRemoteDesktopViewport
) {
const viewport = stream.pendingRemoteDesktopViewport
stream.pendingRemoteDesktopViewport = null
void updateViewportForClient(
runtime,
ptyId,
stream.remoteDesktopSubscriptionKey,
stream.client,
viewport,
'desktop'
).catch(() => {})
}
stream.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => {
stream.outputBatcher.flush()
@@ -2047,6 +2117,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const ptyId = leaf.ptyId
const clientId = params.client?.id
// Why: only unregister the width floor this subscription took (see the
// multiplex stream's registeredRemoteDesktopDriver note).
let registeredRemoteDesktopDriver = false
if (!useBinaryStream) {
const read = await runtime.readTerminal(params.terminal)
const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false)
@@ -2124,9 +2197,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
const streamId = nextTerminalStreamId++
const remoteDesktopSubscriptionKey = `stream:${streamId}`
let cursor = 0
let closed = false
let buffering = true
let pendingRemoteDesktopViewport: { cols: number; rows: number } | null = null
// Why: the cols the mobile client last rewrapped to; gate the
// resize re-stream so it only fires on an actual width change.
let lastResizeCols: number | undefined
@@ -2159,6 +2234,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
unregisterBinaryHandler()
if (isMobile && clientId) {
runtime.handleMobileUnsubscribe(ptyId, clientId)
} else if (registeredRemoteDesktopDriver && clientId) {
runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey)
}
emit({ type: 'end' })
resolveStream()
@@ -2227,9 +2304,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
) {
return
}
if (clientId) {
registeredRemoteDesktopDriver = true
if (buffering) {
pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows }
return
}
}
void updateViewportForClient(
runtime,
ptyId,
remoteDesktopSubscriptionKey,
params.client,
{ cols: viewport.cols, rows: viewport.rows },
'desktop'
@@ -2240,6 +2325,12 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
try {
if (isMobile && clientId) {
await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport)
} else if (clientId && params.viewport) {
// Why: legacy remote desktop viewers that report a viewport also take
// the width floor (host fit cascade suppressed) and size the PTY to
// the smallest attached viewer. Viewport-less watchers do not lock.
registeredRemoteDesktopDriver = true
pendingRemoteDesktopViewport = params.viewport
}
if (closed) {
return
@@ -2275,6 +2366,27 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
unsubscribeStreamData()
}
if (
clientId &&
params.client &&
registeredRemoteDesktopDriver &&
pendingRemoteDesktopViewport
) {
const viewport = pendingRemoteDesktopViewport
pendingRemoteDesktopViewport = null
await updateViewportForClient(
runtime,
ptyId,
remoteDesktopSubscriptionKey,
params.client,
viewport,
'desktop'
)
}
if (closed) {
return
}
let read = await runtime.readTerminal(params.terminal)
let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile)
if (closed) {
@@ -2405,6 +2517,23 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
pendingOutputBytes = 0
outputBatcher.flush()
if (
clientId &&
params.client &&
registeredRemoteDesktopDriver &&
pendingRemoteDesktopViewport
) {
const viewport = pendingRemoteDesktopViewport
pendingRemoteDesktopViewport = null
void updateViewportForClient(
runtime,
ptyId,
remoteDesktopSubscriptionKey,
params.client,
viewport,
'desktop'
).catch(() => {})
}
const sendResizedFrame = (event: {
cols: number
+18 -10
View File
@@ -26,6 +26,9 @@ function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeSe
// only stub the legacy resolveLeafForHandle still bind; tests that need a
// null/stale leaf override this explicitly.
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
updateRemoteDesktopViewer: vi.fn().mockResolvedValue(true),
unregisterRemoteDesktopViewer: vi.fn().mockResolvedValue(true),
isPtyResizeDrivenRemotely: vi.fn().mockReturnValue(false),
...overrides
} as OrcaRuntimeService
}
@@ -82,8 +85,7 @@ describe('terminal multiplex RPC', () => {
cleanup?.()
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
updateDesktopViewport: vi.fn().mockResolvedValue(true)
sendTerminal: vi.fn().mockResolvedValue({ accepted: true })
})
const dispatcher = new RpcDispatcher({
runtime,
@@ -143,10 +145,13 @@ describe('terminal multiplex RPC', () => {
})
])
)
expect(runtime.updateDesktopViewport).toHaveBeenCalledWith('pty-1', {
cols: 300,
rows: 150
})
expect(runtime.updateRemoteDesktopViewer).toHaveBeenCalledWith(
'pty-1',
'multiplex:5',
'desktop-1',
300,
150
)
expect(handlers.has(5)).toBe(true)
dataListenerRef.current?.('a')
@@ -189,10 +194,13 @@ describe('terminal multiplex RPC', () => {
)!
)
await vi.waitFor(() =>
expect(runtime.updateDesktopViewport).toHaveBeenLastCalledWith('pty-1', {
cols: 100,
rows: 30
})
expect(runtime.updateRemoteDesktopViewer).toHaveBeenLastCalledWith(
'pty-1',
'multiplex:5',
'desktop-1',
100,
30
)
)
const snapshotStartFrame = binaryFrames