Files
orca/src/main/runtime/remote-desktop-driver.test.ts
T
6e3ebf534c fix(terminal): arbitrate active desktop viewport ownership (#8252)
* 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>

* fix(terminal): address remote-desktop PTY width review findings

Resolves four CodeRabbit findings on the remote-desktop viewer width
ownership change, plus a round-trip race surfaced while fixing the leak:

- Leak: the one-shot terminal.updateViewport RPC has no disconnect hook,
  so it must never create a width floor (nothing releases it, pinning the
  host at a stale width). Add refreshRemoteDesktopViewer, which only
  refreshes floors the client already owns via its stream subscription
  (matched by clientId), mirroring the mobile updateMobileViewport
  no-op-without-subscription invariant. The one-shot handler now refreshes
  instead of registering.
- Round-trip replay: with the one-shot fallback now refresh-only, a resize
  landing during the subscribe round-trip (connected, stream not yet
  current) was dropped. The transport now replays the latest viewport over
  the stream once it becomes current.
- Reclaim: keep the host reclaim target unless the reclaim resize actually
  landed (result.ok), so a failed reclaim can retry against true host
  geometry instead of a stale remote width.
- Snapshot drain: drain pendingRemoteDesktopViewport in the
  sendRequestedSnapshot finally block; a viewer resize parked during a
  SnapshotRequest buffering window was otherwise dropped until the next
  resize.
- Key scope: scope the multiplex width-floor key by connectionId so two
  connections reusing the same client-local streamId can't overwrite or
  release each other's floor.

Tests: refresh-never-creates-floor, reclaim-target-retained-on-failed-resize,
SnapshotRequest parked-resize drain, connection-scoped key assertions, and a
round-trip replay race test (verified to fail without the flush).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(terminal): arbitrate active desktop viewport ownership

Co-authored-by: Orca <help@stably.ai>

* test(terminal): expect viewport claim capability

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): preserve input across ownership teardown

Co-authored-by: Orca <help@stably.ai>

* test(terminal): keep subscribe buffer coverage within lint limit

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: shady <shady2k@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Orca <help@stably.ai>
2026-07-11 03:02:29 -07:00

518 lines
23 KiB
TypeScript

/**
* 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(mobileAutoRestoreFitMs: number | null = 5_000) {
const runtime = new OrcaRuntimeService({
...store,
getSettings: () => ({ ...store.getSettings(), mobileAutoRestoreFitMs })
})
const ptySizes = new Map<string, { cols: number; rows: number }>([
['pty-1', { cols: 150, rows: 40 }]
])
const resizeCalls: { ptyId: string; cols: number; rows: number }[] = []
const driverEvents: { ptyId: string; driver: { kind: string; clientId?: string } }[] = []
const fitOverrideEvents: { ptyId: string; mode: string; cols: number; rows: number }[] = []
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
resize: (ptyId, cols, rows) => {
resizeCalls.push({ 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: (ptyId, mode, cols, rows) => {
fitOverrideEvents.push({ ptyId, mode, cols, rows })
},
terminalDriverChanged: (ptyId, driver) => {
driverEvents.push({ ptyId, driver: { ...driver } })
}
})
return {
runtime,
driverEvents,
fitOverrideEvents,
resizeCalls
}
}
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 latest active desktop viewer', 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 })
// Activity on the narrower viewer transfers ownership to it.
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 30)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 30 })
// Later activity on the wide viewer transfers ownership back.
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 140, 50)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 140, rows: 50 })
// A passive peer leaving cannot disturb the active owner.
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, fitOverrideEvents } = 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)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 100, rows: 40 })
expect(fitOverrideEvents.at(-1)).toMatchObject({
ptyId: 'pty-1',
mode: 'remote-desktop-fit',
cols: 100,
rows: 40
})
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 })
})
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('does not let passive attachment or hydration steal host ownership', async () => {
const { runtime, resizeCalls } = createRuntime()
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24, false)
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 90, 30, false)
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 })
expect(resizeCalls).toHaveLength(0)
})
it('lets host activity automatically reclaim from a remote owner', async () => {
const { runtime } = createRuntime()
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24)
await runtime.claimRemoteDesktopHost('pty-1', 132, 42)
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 132, rows: 42 })
expect(runtime.getRemoteDesktopFitHold('pty-1', 'sub-A')).toMatchObject({
mode: 'remote-desktop-fit',
cols: 132,
rows: 42
})
})
it('does not emit resize churn for repeated activity from the current owner', async () => {
const { runtime, resizeCalls, fitOverrideEvents } = createRuntime()
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30)
resizeCalls.splice(0)
fitOverrideEvents.splice(0)
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30)
expect(resizeCalls).toHaveLength(0)
expect(fitOverrideEvents).toHaveLength(0)
})
it('reclaims the host width when the last viewer detaches', async () => {
const { runtime } = createRuntime()
// 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 })
// While the viewer owns width, the blocked host fit records the host's
// latest reclaim geometry.
runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 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('does not retain a remote reclaim target when only a phone suppresses host resize', async () => {
const { runtime } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
// pty:resize is suppressed for mobile too, but this measurement must not
// seed the separate remote-viewer cache when no desktop viewer exists.
runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 35)
runtime.onClientDisconnected('phone-A')
await vi.advanceTimersByTimeAsync(10_000)
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24)
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 })
})
it('restores and consumes the host target when the last viewer leaves during phone-fit', async () => {
const { runtime } = createRuntime()
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30)
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
// The host pane can change after remote ownership ends while phone-fit
// remains active; its trusted measurement becomes the deferred target.
runtime.recordRendererGeometry('pty-1', 140, 38)
runtime.onClientDisconnected('phone-A')
await vi.advanceTimersByTimeAsync(10_000)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 140, rows: 38 })
// A later viewer must capture the new host geometry, not reuse the prior
// session's already-consumed 140-column reclaim target.
runtime.onExternalPtyResize('pty-1', 130, 36)
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 24)
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B')
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 130, rows: 36 })
})
it('preserves indefinite phone-fit after the last desktop viewer leaves', async () => {
const { runtime } = createRuntime(null)
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30)
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
runtime.onClientDisconnected('phone-A')
await vi.advanceTimersByTimeAsync(10_000)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 45, rows: 20 })
expect(runtime.getTerminalFitOverride('pty-1')).toMatchObject({ mode: 'mobile-fit' })
await expect(runtime.reclaimTerminalForDesktop('pty-1')).resolves.toBe(true)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 })
expect(runtime.getTerminalFitOverride('pty-1')).toBeNull()
})
it('does not let an older host reclaim consume a newer viewer cycle target', async () => {
const { runtime } = createRuntime()
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24)
// Do not await the reclaim before the next viewer joins. The serialized
// host resize can finish after sub-B has established a newer viewer cycle.
const firstReclaim = runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
const secondAttach = runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 90, 30)
await Promise.all([firstReclaim, secondAttach])
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B')
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 })
})
it('does not coalesce queued claims from different desktop owners', async () => {
const { runtime } = createRuntime()
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30)
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 24, false)
const layoutQueues = Reflect.get(runtime, 'layoutQueues') as Map<
string,
{ running: Promise<unknown>; pending: { target: { ownerSubscriptionKey?: string } }[] }
>
layoutQueues.set('pty-1', { running: new Promise(() => {}), pending: [] })
void runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 90, 28)
void runtime.claimRemoteDesktopViewer('pty-1', 'sub-B')
expect(
layoutQueues.get('pty-1')?.pending.map(({ target }) => target.ownerSubscriptionKey)
).toEqual(['sub-A', 'sub-B'])
layoutQueues.delete('pty-1')
})
it('makes a host claim join a pending disconnect reclaim', async () => {
const { runtime } = createRuntime()
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24)
const layoutQueues = Reflect.get(runtime, 'layoutQueues') as Map<
string,
{ running: Promise<unknown>; pending: { waiters: unknown[] }[] }
>
layoutQueues.set('pty-1', { running: new Promise(() => {}), pending: [] })
void runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
void runtime.claimRemoteDesktopHost('pty-1', 150, 40)
expect(layoutQueues.get('pty-1')?.pending).toHaveLength(1)
expect(layoutQueues.get('pty-1')?.pending[0]?.waiters).toHaveLength(2)
layoutQueues.delete('pty-1')
})
it('removes same-PTY viewer floors with one bounded reclaim', async () => {
const { runtime, resizeCalls } = createRuntime()
const subscriptionKeys: string[] = []
for (let index = 0; index < 100; index += 1) {
const key = `sub-${index}`
subscriptionKeys.push(key)
await runtime.updateRemoteDesktopViewer('pty-1', key, `viewer-${index}`, 100, 30)
}
resizeCalls.splice(0)
await runtime.unregisterRemoteDesktopViewers('pty-1', subscriptionKeys)
expect(resizeCalls).toEqual([{ ptyId: 'pty-1', cols: 150, rows: 40 }])
})
it('applies the latest viewer width when the host takes back from a phone', async () => {
const { runtime } = createRuntime()
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30)
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24)
await expect(runtime.reclaimTerminalForDesktop('pty-1')).resolves.toBe(true)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 24 })
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true)
})
it('refresh never creates a floor (one-shot viewport cannot leak host suppression)', async () => {
const { runtime } = createRuntime()
// A one-shot terminal.updateViewport (refresh) from a viewer with no stream
// floor must NOT register one — that floor would leak (nothing releases a
// one-shot RPC's state), pinning the host at a stale width forever.
const created = await runtime.refreshRemoteDesktopViewer('pty-1', 'viewer-A', 90, 30)
expect(created).toBe(false)
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 })
// Once the client's STREAM registers the floor (and owns its cleanup), a
// refresh updates that same floor by clientId.
await runtime.updateRemoteDesktopViewer('pty-1', 'stream:1', 'viewer-A', 100, 40)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 100, rows: 40 })
const refreshed = await runtime.refreshRemoteDesktopViewer('pty-1', 'viewer-A', 70, 25)
expect(refreshed).toBe(true)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 70, rows: 25 })
// A refresh for a DIFFERENT client that owns no floor is a no-op.
const other = await runtime.refreshRemoteDesktopViewer('pty-1', 'viewer-Z', 60, 20)
expect(other).toBe(false)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 70, rows: 25 })
// The stream cleanup releases the sole floor — the refresh left no orphan,
// so the host reclaims (suppression drops).
await runtime.unregisterRemoteDesktopViewer('pty-1', 'stream:1')
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false)
})
it('captures host geometry when a passive stream is first claimed by terminal.send', async () => {
const { runtime } = createRuntime()
await runtime.updateRemoteDesktopViewer('pty-1', 'stream:1', 'viewer-A', 100, 30, false)
await runtime.refreshRemoteDesktopViewer('pty-1', 'viewer-A', 80, 24, true)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 24 })
await runtime.unregisterRemoteDesktopViewer('pty-1', 'stream:1')
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 })
})
it('keeps the host reclaim target when the reclaim resize fails', async () => {
const { runtime } = createRuntime()
const ptySizes = new Map<string, { cols: number; rows: number }>([
['pty-1', { cols: 150, rows: 40 }]
])
let resizeSucceeds = true
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
resize: (ptyId, cols, rows) => {
if (!resizeSucceeds) {
return false
}
ptySizes.set(ptyId, { cols, rows })
return true
},
getSize: (ptyId) => ptySizes.get(ptyId) ?? null
})
// A viewer drives the PTY to 80; while it owns width, the host reports its
// own 120-wide reclaim geometry.
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 40)
runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 40)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 40 })
// The last viewer leaves but the reclaim resize fails: the PTY is stuck at
// 80, so the recorded host target (120) MUST be retained for a later retry.
resizeSucceeds = false
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 40 })
// A retry once resize works restores the TRUE host width (120), not the
// stale viewer width (80) that a dropped target would have resolved to.
resizeSucceeds = true
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 40)
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B')
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 120, rows: 40 })
})
it('supersedes a failed reclaim target when the host later resizes successfully', async () => {
const { runtime } = createRuntime()
const ptySizes = new Map<string, { cols: number; rows: number }>([
['pty-1', { cols: 150, rows: 40 }]
])
let resizeSucceeds = true
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
resize: (ptyId, cols, rows) => {
if (!resizeSucceeds) {
return false
}
ptySizes.set(ptyId, { cols, rows })
return true
},
getSize: (ptyId) => ptySizes.get(ptyId) ?? null
})
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 40)
runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 40)
resizeSucceeds = false
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
// pty:resize has already resized the provider before this runtime mirror
// hook runs; that successful host geometry supersedes the retained 120.
resizeSucceeds = true
ptySizes.set('pty-1', { cols: 140, rows: 42 })
runtime.onExternalPtyResize('pty-1', 140, 42)
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 30)
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B')
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 140, rows: 42 })
})
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)
})
})