From 99240ab101e3feff9b5bf84f40eeb95505c1ca9e Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 23 Aug 2026 15:21:36 -0400 Subject: [PATCH] fix(terminal): serialize desktop viewport ownership --- config/reliability-gates.jsonc | 114 +++++- .../ipc/pty-applied-size-reporting.test.ts | 89 +++++ ...ty-runtime-ssh-binding-persistence.test.ts | 3 + .../pty-serializer-settlement-mapping.test.ts | 1 + src/main/ipc/pty.ts | 53 ++- src/main/runtime/orca-runtime.ts | 12 +- .../runtime/remote-desktop-driver.test.ts | 37 ++ src/main/runtime/rpc/methods/terminal.ts | 135 +++++-- ...-desktop-input-claim-serialization.test.ts | 178 ++++++++++ ...esktop-stream-detach-serialization.test.ts | 237 +++++++++++++ ...l-multiplex-desktop-resize-routing.test.ts | 2 +- .../rpc/terminal-multiplex-test-harness.ts | 1 + src/preload/api/pty-api.ts | 2 +- src/preload/index.ts | 4 +- ...ty-connection-cold-restore-repaint.test.ts | 2 + ...ion-hidden-snapshot-resize-signals.test.ts | 2 + .../pty-connection-reattach-binding.test.ts | 2 + ...y-connection-terminal-input-gating.test.ts | 4 + .../pty-connection-test-environment.ts | 9 + .../pty-connection-test-pane-fixtures.ts | 1 + ...-connection-visibility-resume-size.test.ts | 2 + .../pty-connection/foreground-output-scan.ts | 8 +- .../pty-connection/pty-input-forward.ts | 17 +- .../pty-connection/pty-input-recovery.ts | 10 +- .../pty-connection/pty-resize-geometry.ts | 8 +- .../pty-input-write-queue.test.ts | 17 + .../terminal-pane/pty-input-write-queue.ts | 4 +- .../pty-transport-input-write.test.ts | 10 +- .../components/terminal-pane/pty-transport.ts | 2 +- .../remote-desktop-viewport-claim.test.ts | 23 +- .../remote-desktop-viewport-claim.ts | 21 ++ src/shared/terminal-focus-report.ts | 15 + .../host-terminal-runtime-stub.ts | 1 + .../terminal-viewport-ownership-fixture.ts | 145 +++----- .../helpers/terminal-viewport-wire-probe.ts | 139 ++++++++ ...untime-rejected-input-remount.unit.test.ts | 1 + ...-terminal-viewport-ownership-audit.spec.ts | 328 +++++++++++++++++- 37 files changed, 1441 insertions(+), 198 deletions(-) create mode 100644 src/main/runtime/rpc/terminal-desktop-input-claim-serialization.test.ts create mode 100644 src/main/runtime/rpc/terminal-desktop-stream-detach-serialization.test.ts create mode 100644 src/shared/terminal-focus-report.ts create mode 100644 tests/e2e/helpers/terminal-viewport-wire-probe.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 12b937b187f..afeb4add1c3 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -278,7 +278,7 @@ "platform": "macos", "command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "result": "passed", - "durationSeconds": 20.6, + "durationSeconds": 21.3, "summary": "After a fresh build exposed an unrelated mirrored terminal entering the broad backing-state diagnostic, the exact built candidate passed with that oracle scoped to the four seeded browser/simulator tabs; rendered active-tab and backing execution-host assertions remained green." } ], @@ -5526,8 +5526,8 @@ "platforms": ["macos", "linux", "windows"], "providers": ["local", "daemon", "ssh", "remote-runtime"], "coveredPlatforms": ["macos"], - "coveredProviders": [], - "coverageNotes": "Local macOS evidence on main@1282f5c2d, including #7192's runtime-mirror geometry authority slice. Deterministic provider-contract coverage now includes settled window-wake reassertion and SSH relay applied-size readback. Live shell-visible SSH/remote geometry and Windows ConPTY readback remain non-blocking gaps.", + "coveredProviders": ["remote-runtime"], + "coverageNotes": "Local macOS evidence on main@1282f5c2d, including #7192's runtime-mirror geometry authority slice. Deterministic provider-contract coverage includes settled window-wake reassertion and SSH relay applied-size readback. A headed host plus two paired desktop clients now covers remote-runtime viewport ownership and a capability-less legacy client. Live shell-visible SSH geometry and Windows ConPTY readback remain non-blocking gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6644", "https://github.com/stablyai/orca/pull/6649", @@ -5537,20 +5537,31 @@ "https://github.com/stablyai/orca/pull/6939", "https://github.com/stablyai/orca/pull/7192" ], - "invariant": "A visible desktop-owned terminal cannot trust 0x0, stale requested size, or renderer-only size; xterm, fit/proposed size, applied PTY size, shell-visible size, and the runtime mirror's parse dimensions must converge or enter explicit degraded state, and mirror resize reflow must stay ordered with queued output writes.", - "oracle": "The current executable slice uses deterministic frame schedulers and fake providers to force 0x0 first fit, delayed layout settle, dropped resize/readback drift, hidden-to-visible activation, and window wake. It asserts the renderer forwards a usable size, pty:getSize reports applied rather than merely requested size where available, visibility resume reasserts real drift without hot listSessions, one settled wake produces one geometry-only readback, and SSH relay readback is authoritative with bounded fallback. Shell-visible stty/echo-wrap convergence remains a live-gate follow-up.", + "invariant": "A visible desktop-owned terminal cannot trust 0x0, stale requested size, or renderer-only size; xterm, fit/proposed size, applied PTY size, shell-visible size, and the runtime mirror's parse dimensions must converge or enter explicit degraded state, and mirror resize reflow must stay ordered with queued output writes. Across desktops, the most recently active visible owner controls geometry: passive attach, unfocused layout, and automatic terminal protocol traffic cannot transfer ownership; input claims before bytes; detach and failed reclaim preserve a deterministic restoration target.", + "oracle": "The executable slices force 0x0 first fit, delayed layout settle, dropped resize/readback drift, hidden-to-visible activation, window wake, delayed fit publication, adjacent focus reports, two competing desktop viewers, failed reclaim, stream detach, and capability-less legacy traffic. They assert usable renderer geometry, applied provider size, ordered runtime mirrors, atomic automatic-input classification, passive non-ownership, input-before-bytes transfer, host reclaim, detach fallback, and intentional legacy resize-as-activity. Shell-visible stty/echo-wrap convergence remains a live-gate follow-up.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler-output-streaming.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler-output-streaming.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/runtime/remote-desktop-driver.test.ts src/main/runtime/rpc/terminal-desktop-input-claim-serialization.test.ts src/main/runtime/rpc/terminal-desktop-stream-detach-serialization.test.ts", + "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 -g 'current clients'", + "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 -g 'host input reclaims'", + "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 -g 'legacy clients'" ], "testFiles": [ "src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts", "src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts", "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts", "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", + "src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts", + "src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts", "src/main/ipc/pty-applied-size-reporting.test.ts", "src/main/providers/ssh-pty-provider.test.ts", + "src/main/runtime/remote-desktop-driver.test.ts", + "src/main/runtime/rpc/terminal-desktop-input-claim-serialization.test.ts", + "src/main/runtime/rpc/terminal-desktop-stream-detach-serialization.test.ts", "src/main/runtime/orca-runtime.test.ts", - "src/relay/pty-handler-output-streaming.test.ts" + "src/relay/pty-handler-output-streaming.test.ts", + "tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts" ], "assertionRefs": [ { @@ -5588,9 +5599,38 @@ "assertions": [ "accepted desktop resizes fan out to the runtime after provider resize", "rejected desktop resizes do not fan out to the runtime", + "automatic terminal query replies do not reclaim host viewport ownership", + "single and transport-batched xterm focus reports do not reclaim host viewport ownership", "provider-owned null remains unverified instead of falling back to requested size" ] }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts", + "assertions": [ + "an acknowledged reclaim rejection does not arm parser probes or remount a live bound pane" + ] + }, + { + "file": "src/main/runtime/remote-desktop-driver.test.ts", + "assertions": [ + "failed host restoration retains remote geometry suppression until input retry succeeds", + "host input restores cached host geometry before bytes", + "current-owner input applies geometry recorded by an earlier passive relayout" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-desktop-input-claim-serialization.test.ts", + "assertions": [ + "multiplex and single desktop input revalidate runtime ownership before bytes", + "terminal query replies and transport-batched xterm focus reports do not transfer desktop viewport ownership" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-desktop-stream-detach-serialization.test.ts", + "assertions": [ + "slot and connection cleanup drop queued current and legacy viewport work before restoration" + ] + }, { "file": "src/main/providers/ssh-pty-provider.test.ts", "assertions": [ @@ -5611,6 +5651,18 @@ "relay readback reports the grid actually applied by node-pty", "missing relay PTYs return an unverified null size" ] + }, + { + "file": "tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts", + "assertions": [ + "passive attach and unfocused layout publish no desktop viewport claim", + "passive terminal preview subscription does not resize or publish a claim", + "focused input claims before bytes while fit state is delayed", + "host input reclaims cached host geometry before bytes while renderer fit state is delayed", + "host input and later desktop input transfer ownership in order", + "desktop detach falls back to the remaining active viewer, then the host", + "capability-less legacy clients retain resize-as-activity behavior" + ] } ], "evidenceRuns": [ @@ -5620,8 +5672,44 @@ "platform": "macos", "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler-output-streaming.test.ts", "result": "passed", - "durationSeconds": 18.54, - "summary": "8 test files passed, 1,272 tests passed on the PR branch." + "durationSeconds": 11.49, + "summary": "8 test files passed, 1,282 tests passed and 1 skipped on the PR branch. One prior combined run timed out in an unrelated legacy-worker orchestration fixture; the exact test passed alone in 84 ms and this rerun passed." + }, + { + "date": "2026-08-23", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/runtime/remote-desktop-driver.test.ts src/main/runtime/rpc/terminal-desktop-input-claim-serialization.test.ts src/main/runtime/rpc/terminal-desktop-stream-detach-serialization.test.ts", + "result": "passed", + "durationSeconds": 4.6, + "summary": "7 focused ownership-boundary files passed, 109 tests passed." + }, + { + "date": "2026-08-23", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 -g 'current clients'", + "result": "passed", + "durationSeconds": 22.7, + "summary": "Current-client ownership passed twice consecutively with a headed host and two real paired Electron clients after the payload-aware wire oracle excluded pure focus reports." + }, + { + "date": "2026-08-23", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 -g 'host input reclaims'", + "result": "passed", + "durationSeconds": 20.2, + "summary": "Host input reclaimed cached host geometry before bytes while host fit events and renderer viewport claims were suppressed." + }, + { + "date": "2026-08-23", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 -g 'legacy clients'", + "result": "passed", + "durationSeconds": 20.2, + "summary": "The capability-less legacy arm passed in a separate fresh Playwright process." } ], "runtimeBudget": { @@ -5630,11 +5718,11 @@ }, "flakeHistory": { "status": "unknown", - "evidence": "Deterministic Vitest slices passed locally; no CI soak history yet." + "evidence": "Deterministic Vitest slices and all three paired Electron arms passed locally. A wire assertion initially counted a pure focus-report Input as activity; recording raw Input payloads made the oracle semantic, after which current/current passed twice consecutively. Repeated Playwright processes also exposed an unrelated seeded-worktree loader failure before the test body; no CI soak history yet." }, "redGreenEvidence": { "status": "partial", - "evidence": "#7192 proved the mirror slice red before its fix (snapshot stayed 80x24, queued write parsed at the wrong width). The renderer 0x0/settle slices do not have recorded red runs." + "evidence": "#7192 proved the mirror slice red before its fix (snapshot stayed 80x24, queued write parsed at the wrong width). STA-5050's paired oracle failed with [14,8] for unfocused generic resize when that guard was disabled, failed with input [7] at the prior owner's 57x41 grid instead of [14,8,7] at 20x26 when pre-fit claiming was disabled, and put HOST_PRE_FIT_INPUT bytes at the remote 20x26 grid instead of cached host 107x52 when host reclaim was disabled. Disabling runtime input revalidation failed both multiplex and single-stream tests; disabling query-reply classification or the focus-report predicate invoked ownership reclaim; exact-only focus classification failed on a transport-batched ESC[O ESC[I payload; disabling retained-target suppression exposed the failed restoration to layout resize; disabling stream-liveness gates failed all four detach cases. The renderer 0x0/settle slices do not have recorded red runs." }, "performanceBudget": { "required": true, @@ -5648,7 +5736,9 @@ "Local and Docker-backed SSH Electron wake specs exist but remain manual/non-blocking pending runtime and flake history.", "The deterministic gate proves the SSH provider/relay readback contract, not live shell-visible convergence across every remote path.", "Does not yet prove Windows ConPTY geometry/readback.", - "Current command uses deterministic fake providers for the main/renderer contracts, not a real remote PTY." + "A passive current-owner relayout can temporarily leave xterm and PTY geometry divergent until the next genuine input revalidates and applies the recorded geometry; no bytes run at the stale geometry.", + "If provider resize persistently fails, genuine input is rejected until ownership geometry can be established; delivering or indefinitely queueing those bytes would violate the ordering invariant or risk delayed interactive input.", + "The Vitest command uses deterministic fake providers; the paired macOS Electron arm covers a real remote-runtime PTY." ], "demotionRule": "Cannot promote without deterministic oracle and runtime history." }, diff --git a/src/main/ipc/pty-applied-size-reporting.test.ts b/src/main/ipc/pty-applied-size-reporting.test.ts index 5607c7cab61..2a633a49709 100644 --- a/src/main/ipc/pty-applied-size-reporting.test.ts +++ b/src/main/ipc/pty-applied-size-reporting.test.ts @@ -278,6 +278,95 @@ describe('registerPtyHandlers', () => { expect(write).not.toHaveBeenCalled() }) + it('retries failed implicit reclaim before later host input', async () => { + const write = setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 } }) + const retry = Promise.withResolvers() + const claimRemoteDesktopHostForInput = vi + .fn<() => Promise | null>() + .mockResolvedValueOnce(false) + .mockReturnValueOnce(retry.promise) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'idle' })), + claimRemoteDesktopHostForInput, + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: 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 + const writeEvent = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:write') + + writeEvent?.[1](mainWindowIpcEvent, { id, data: 'first' }) + expect(claimRemoteDesktopHostForInput).toHaveBeenCalledWith(id) + expect(write).not.toHaveBeenCalled() + await Promise.resolve() + await Promise.resolve() + + writeEvent?.[1](mainWindowIpcEvent, { id, data: 'second' }) + expect(claimRemoteDesktopHostForInput).toHaveBeenCalledTimes(2) + expect(write).not.toHaveBeenCalled() + + retry.resolve(true) + await vi.waitFor(() => expect(write).toHaveBeenCalledWith(id, 'second')) + expect(write).not.toHaveBeenCalledWith(id, 'first') + }) + it('writes terminal query replies without reclaiming host viewport ownership', async () => { + const write = setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 } }) + const claimRemoteDesktopHostForInput = vi.fn().mockResolvedValue(true) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'idle' })), + claimRemoteDesktopHostForInput, + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: 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 + const writeEvent = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:write') + + writeEvent?.[1](mainWindowIpcEvent, { id, data: '\u001b[3;1R', inputKind: 'query-reply' }) + + expect(claimRemoteDesktopHostForInput).not.toHaveBeenCalled() + expect(write).toHaveBeenCalledWith(id, '\u001b[3;1R') + }) + it('writes terminal focus reports without reclaiming host viewport ownership', async () => { + const write = setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 } }) + const claimRemoteDesktopHostForInput = vi.fn().mockResolvedValue(true) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'idle' })), + claimRemoteDesktopHostForInput, + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: 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 + const writeEvent = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:write') + + writeEvent?.[1](mainWindowIpcEvent, { id, data: '\u001b[I' }) + writeEvent?.[1](mainWindowIpcEvent, { id, data: '\u001b[O' }) + writeEvent?.[1](mainWindowIpcEvent, { id, data: '\u001b[O\u001b[I' }) + + expect(claimRemoteDesktopHostForInput).not.toHaveBeenCalled() + expect(write).toHaveBeenNthCalledWith(1, id, '\u001b[I') + expect(write).toHaveBeenNthCalledWith(2, id, '\u001b[O') + expect(write).toHaveBeenNthCalledWith(3, id, '\u001b[O\u001b[I') + }) it('does not populate the remote reclaim cache when only a phone drives', async () => { const resizeSpy = vi.fn() setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 }, resize: resizeSpy }) diff --git a/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts b/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts index 7a522c164e6..fddd33464dc 100644 --- a/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts +++ b/src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts @@ -271,6 +271,7 @@ describe('registerPtyHandlers', () => { registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), + claimRemoteDesktopHostForInput: vi.fn(() => null), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -385,6 +386,7 @@ describe('registerPtyHandlers', () => { registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), + claimRemoteDesktopHostForInput: vi.fn(() => null), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -481,6 +483,7 @@ describe('registerPtyHandlers', () => { registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), + claimRemoteDesktopHostForInput: vi.fn(() => null), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() diff --git a/src/main/ipc/pty-serializer-settlement-mapping.test.ts b/src/main/ipc/pty-serializer-settlement-mapping.test.ts index a203af2743e..388ee02088f 100644 --- a/src/main/ipc/pty-serializer-settlement-mapping.test.ts +++ b/src/main/ipc/pty-serializer-settlement-mapping.test.ts @@ -121,6 +121,7 @@ describe('registerPtyHandlers', () => { registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), + claimRemoteDesktopHostForInput: vi.fn(() => null), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index cac4538be37..87b094f1486 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -26,6 +26,7 @@ import type { PtyRendererDeliveryStateReport } from '../../shared/pty-renderer-delivery-health' import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction' +import { isTerminalFocusReport } from '../../shared/terminal-focus-report' import { INITIAL_MODE_2031_REPLY_SCAN_STATE, scanMode2031ReplyDecision, @@ -7305,7 +7306,7 @@ export function registerPtyHandlers( } } - type PtyWritePayload = { id: string; data: string } + type PtyWritePayload = { id: string; data: string; inputKind?: 'query-reply' } type PtyViewportClaimPayload = { id: string; cols: number; rows: number } const isPtyWritePayload = (value: unknown): value is PtyWritePayload => @@ -7313,7 +7314,9 @@ export function registerPtyHandlers( value !== null && typeof (value as { id?: unknown }).id === 'string' && (value as { id: string }).id.length > 0 && - typeof (value as { data?: unknown }).data === 'string' + typeof (value as { data?: unknown }).data === 'string' && + ((value as { inputKind?: unknown }).inputKind === undefined || + (value as { inputKind?: unknown }).inputKind === 'query-reply') const isPtyViewportClaimPayload = (value: unknown): value is PtyViewportClaimPayload => typeof value === 'object' && @@ -7388,6 +7391,37 @@ export function registerPtyHandlers( const hostViewportClaimTails = new Map>() + const trackHostViewportClaim = (id: string, claim: Promise): Promise => { + const tracked = claim.catch(() => false) + hostViewportClaimTails.set(id, tracked) + void tracked.then(() => { + if (hostViewportClaimTails.get(id) === tracked) { + hostViewportClaimTails.delete(id) + } + }) + return tracked + } + + const getHostInputViewportClaim = (id: string): Promise | null => { + const pending = hostViewportClaimTails.get(id) + if (pending || runtime?.getDriver(id).kind === 'mobile') { + return pending ?? null + } + // Runtime owns both active viewers and retained failed-reclaim targets. + const claim = runtime?.claimRemoteDesktopHostForInput(id) ?? null + return claim ? trackHostViewportClaim(id, claim) : null + } + + const getHostWriteViewportClaim = (args: PtyWritePayload): Promise | null => { + if (args.inputKind === 'query-reply') { + return null + } + if (isTerminalFocusReport(args.data)) { + return hostViewportClaimTails.get(args.id) ?? null + } + return getHostInputViewportClaim(args.id) + } + ipc.on('pty:write', (event, args: unknown) => { if ( !isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) || @@ -7395,7 +7429,7 @@ export function registerPtyHandlers( ) { return } - const claimTail = hostViewportClaimTails.get(args.id) + const claimTail = getHostWriteViewportClaim(args) if (claimTail) { void claimTail.then((claimed) => (claimed ? writePtyInput(args) : false)) return @@ -7409,7 +7443,7 @@ export function registerPtyHandlers( ) { return false } - const claimTail = hostViewportClaimTails.get(args.id) + const claimTail = getHostWriteViewportClaim(args) return claimTail ? claimTail.then((claimed) => (claimed ? writePtyInputAccepted(args) : false)) : writePtyInputAccepted(args) @@ -7426,20 +7460,15 @@ export function registerPtyHandlers( } const prior = hostViewportClaimTails.get(args.id) // Why: two panes can mirror one PTY — never let a later no-op claim replace the in-flight resize that the following host input must await. - const claim = ( + trackHostViewportClaim( + args.id, prior ? prior.then( () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows), () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) ) : runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) - ).catch(() => false) - hostViewportClaimTails.set(args.id, claim) - void claim.then(() => { - if (hostViewportClaimTails.get(args.id) === claim) { - hostViewportClaimTails.delete(args.id) - } - }) + ) }) // Why: resize is fire-and-forget — ipc.on (not .handle) halves IPC traffic by skipping the empty acknowledgement reply. diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index aac73f5e7f4..24ad996ab0c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -15450,7 +15450,7 @@ 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 an active remote desktop viewer owns the PTY. Input is deliberately NOT gated + // OR remote desktop ownership/reclaim still reserves 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. An active remote viewer needs @@ -15467,7 +15467,7 @@ export class OrcaRuntimeService { } isRemoteDesktopResizeDriven(ptyId: string): boolean { - return this.remoteDesktopOwners.has(ptyId) + return this.hasRemoteDesktopLayoutState(ptyId) } isRemoteDesktopViewerOwner(ptyId: string, subscriptionKey: string): boolean { @@ -15652,6 +15652,14 @@ export class OrcaRuntimeService { return this.applyRemoteDesktopLayout(ptyId) } + claimRemoteDesktopHostForInput(ptyId: string): Promise | null { + if (!this.hasRemoteDesktopLayoutState(ptyId)) { + return null + } + const target = this.resolveRemoteDesktopHostReclaimTarget(ptyId) + return this.claimRemoteDesktopHost(ptyId, target.cols, target.rows) + } + unregisterRemoteDesktopViewer(ptyId: string, subscriptionKey: string): Promise { return this.unregisterRemoteDesktopViewers(ptyId, [subscriptionKey]) } diff --git a/src/main/runtime/remote-desktop-driver.test.ts b/src/main/runtime/remote-desktop-driver.test.ts index bf2370ee924..b2c44271039 100644 --- a/src/main/runtime/remote-desktop-driver.test.ts +++ b/src/main/runtime/remote-desktop-driver.test.ts @@ -241,6 +241,32 @@ describe('remote desktop viewer width driver', () => { }) }) + it('reclaims from the cached host grid when input precedes renderer fit state', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24) + + await runtime.claimRemoteDesktopHostForInput('pty-1') + + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(runtime.claimRemoteDesktopHostForInput('pty-1')).toBeNull() + }) + + it('retries a retained host target after input reclaim fails', async () => { + const { runtime, setResizeSucceeds } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24) + setResizeSucceeds(false) + + await expect(runtime.claimRemoteDesktopHostForInput('pty-1')).resolves.toBe(false) + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 24 }) + + setResizeSucceeds(true) + await expect(runtime.claimRemoteDesktopHostForInput('pty-1')).resolves.toBe(true) + expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + 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) @@ -253,6 +279,17 @@ describe('remote desktop viewer width driver', () => { expect(fitOverrideEvents).toHaveLength(0) }) + it('applies passively recorded owner geometry before later input', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24) + + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30, false) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 24 }) + + await expect(runtime.claimRemoteDesktopViewer('pty-1', 'sub-A')).resolves.toBe(true) + expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 100, rows: 30 }) + }) + 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. diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 5dee92a7676..8699773459a 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -38,6 +38,7 @@ import { } from '../terminal-stream-byte-length' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' +import { isTerminalFocusReport } from '../../../../shared/terminal-focus-report' import { EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE, scanTerminalReplyQuerySequences, @@ -864,6 +865,25 @@ async function updateViewportForClient( return { updated, applied: updated } } +function enqueueRemoteDesktopInputClaim( + runtime: OrcaRuntimeService, + ptyId: string, + subscriptionKey: string, + priorClaimTail: Promise, + registered: boolean, + isLive: () => boolean +): Promise { + // Why: host reclaim can outrun its renderer notification; runtime ownership must be current before bytes. + return priorClaimTail + .then((priorClaimed) => { + if (!isLive()) { + return false + } + return registered ? runtime.claimRemoteDesktopViewer(ptyId, subscriptionKey) : priorClaimed + }) + .catch(() => false) +} + const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) @@ -2229,10 +2249,30 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { return } - // Mobile already has the higher-priority floor, so a rejected desktop claim must not suppress later phone input. - const inputClaimTail = stream.isMobile ? Promise.resolve(true) : stream.desktopClaimTail + const transfersDesktopOwnership = + !stream.isMobile && !isTerminalFocusReport(text) && !isTerminalQueryReply(text) + const inputClaimTail = stream.isMobile + ? Promise.resolve(true) + : transfersDesktopOwnership + ? enqueueRemoteDesktopInputClaim( + runtime, + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.desktopClaimTail, + stream.registeredRemoteDesktopDriver, + () => !closed && streams.get(stream.streamId) === stream + ) + : stream.desktopClaimTail + if (transfersDesktopOwnership) { + stream.desktopClaimTail = inputClaimTail + } void inputClaimTail.then(async (claimed) => { - if (!claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { + if ( + closed || + streams.get(stream.streamId) !== stream || + !claimed || + isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client) + ) { return } const outcome = await sendTerminalStreamInput(runtime, { @@ -2278,6 +2318,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } stream.desktopClaimTail = stream.desktopClaimTail .then(async (priorClaimed) => { + if (closed || streams.get(stream.streamId) !== stream) { + return false + } const result = await updateViewportForClient( runtime, stream.ptyId, @@ -2311,17 +2354,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ stream.registeredRemoteDesktopDriver = true stream.desktopClaimTail = stream.desktopClaimTail .then( - () => - runtime.updateRemoteDesktopViewer( - stream.ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client!.id, - cols, - rows, - true - ), - () => - runtime.updateRemoteDesktopViewer( + () => { + if (closed || streams.get(stream.streamId) !== stream) { + return false + } + return runtime.updateRemoteDesktopViewer( stream.ptyId, stream.remoteDesktopSubscriptionKey, stream.client!.id, @@ -2329,6 +2366,20 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ rows, true ) + }, + () => { + if (closed || streams.get(stream.streamId) !== stream) { + return false + } + return runtime.updateRemoteDesktopViewer( + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client!.id, + cols, + rows, + true + ) + } ) .catch(() => false) return @@ -3256,8 +3307,27 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { return } - void desktopClaimTail.then(async (claimed) => { - if (!claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + const transfersDesktopOwnership = + !isMobile && !isTerminalFocusReport(text) && !isTerminalQueryReply(text) + const inputClaimTail = transfersDesktopOwnership + ? enqueueRemoteDesktopInputClaim( + runtime, + ptyId, + remoteDesktopSubscriptionKey, + desktopClaimTail, + registeredRemoteDesktopDriver, + () => !closed + ) + : desktopClaimTail + if (transfersDesktopOwnership) { + desktopClaimTail = inputClaimTail + } + void inputClaimTail.then(async (claimed) => { + if ( + closed || + !claimed || + isTerminalInputLockedForClient(runtime, ptyId, params.client) + ) { return } const outcome = await sendTerminalStreamInput(runtime, { @@ -3294,6 +3364,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } desktopClaimTail = desktopClaimTail .then(async (priorClaimed) => { + if (closed) { + return false + } const result = await updateViewportForClient( runtime, ptyId, @@ -3332,17 +3405,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ registeredRemoteDesktopDriver = true desktopClaimTail = desktopClaimTail .then( - () => - runtime.updateRemoteDesktopViewer( - ptyId, - remoteDesktopSubscriptionKey, - clientId, - cols, - rows, - true - ), - () => - runtime.updateRemoteDesktopViewer( + () => { + if (closed) { + return false + } + return runtime.updateRemoteDesktopViewer( ptyId, remoteDesktopSubscriptionKey, clientId, @@ -3350,6 +3417,20 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ rows, true ) + }, + () => { + if (closed) { + return false + } + return runtime.updateRemoteDesktopViewer( + ptyId, + remoteDesktopSubscriptionKey, + clientId, + cols, + rows, + true + ) + } ) .catch(() => false) } diff --git a/src/main/runtime/rpc/terminal-desktop-input-claim-serialization.test.ts b/src/main/runtime/rpc/terminal-desktop-input-claim-serialization.test.ts new file mode 100644 index 00000000000..1d78ef99a77 --- /dev/null +++ b/src/main/runtime/rpc/terminal-desktop-input-claim-serialization.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + encodeTerminalStreamFrame, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' +import { + makeRequest, + sendDesktopMultiplexSubscribe, + startDesktopMultiplexSubscribe, + stubRuntime +} from './terminal-multiplex-test-harness' +import { createSubscriptionRegistryDouble } from './subscription-registry-test-double' + +function inputFrame(streamId: number, seq = 2, text = 'input') { + return decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId, + seq, + payload: encodeTerminalStreamText(text) + }) + )! +} + +describe('desktop input claim serialization', () => { + it('multiplex revalidates runtime ownership before remote input bytes', async () => { + let owner = 'host' + let releaseClaim = (): void => {} + const order: string[] = [] + const claimRemoteDesktopViewer = vi.fn( + (_ptyId: string, subscriptionKey: string) => + new Promise((resolve) => { + releaseClaim = () => { + owner = subscriptionKey + order.push(`claim:${subscriptionKey}`) + resolve(true) + } + }) + ) + const sendTerminal = vi.fn().mockImplementation(async () => { + order.push(`input:${owner}`) + return { accepted: true } + }) + const harness = startDesktopMultiplexSubscribe({ claimRemoteDesktopViewer, sendTerminal }) + + sendDesktopMultiplexSubscribe(harness.handlers) + await vi.waitFor(() => + expect( + harness.messages.some((message) => JSON.parse(message).result?.type === 'subscribed') + ).toBe(true) + ) + harness.handlers.get(7)!(inputFrame(7)) + + await vi.waitFor(() => expect(claimRemoteDesktopViewer).toHaveBeenCalledTimes(1)) + expect(sendTerminal).not.toHaveBeenCalled() + expect(owner).toBe('host') + + releaseClaim() + await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledTimes(1)) + expect(order).toEqual([ + 'claim:multiplex:conn-desktop-first-paint:7', + 'input:multiplex:conn-desktop-first-paint:7' + ]) + + claimRemoteDesktopViewer.mockClear() + sendTerminal.mockClear() + harness.handlers.get(7)!(inputFrame(7, 3, '\u001b[O\u001b[I')) + await Promise.resolve() + expect(claimRemoteDesktopViewer).not.toHaveBeenCalled() + await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledTimes(1)) + sendTerminal.mockClear() + harness.handlers.get(7)!(inputFrame(7, 4, '\u001b[3;1R')) + await Promise.resolve() + expect(claimRemoteDesktopViewer).not.toHaveBeenCalled() + await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledTimes(1)) + + harness.runtime.cleanupSubscription('terminal-multiplex:conn-desktop-first-paint') + await harness.dispatchPromise + }) + + it('single stream revalidates runtime ownership before remote input bytes', async () => { + let owner = 'host' + let releaseClaim = (): void => {} + const order: string[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const registry = createSubscriptionRegistryDouble() + const claimRemoteDesktopViewer = vi.fn( + (_ptyId: string, subscriptionKey: string) => + new Promise((resolve) => { + releaseClaim = () => { + owner = subscriptionKey + order.push(`claim:${subscriptionKey}`) + resolve(true) + } + }) + ) + const sendTerminal = vi.fn().mockImplementation(async () => { + order.push(`input:${owner}`) + return { accepted: true } + }) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snapshot', cols: 120, rows: 40 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup), + cleanupSubscription: vi.fn(registry.cleanupSubscription), + waitForTerminal: vi.fn(() => new Promise(() => {})), + claimRemoteDesktopViewer, + sendTerminal + }) + const messages: string[] = [] + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 }, + capabilities: { terminalBinaryStream: 1, ackOutput: 1, desktopViewportClaims: 1 } + }), + (message) => messages.push(message), + { + connectionId: 'conn-single-input', + sendBinary: vi.fn(), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((message) => JSON.parse(message).result?.type === 'subscribed')).toBe( + true + ) + ) + const [streamId, handler] = Array.from(handlers.entries())[0]! + handler(inputFrame(streamId)) + + await vi.waitFor(() => expect(claimRemoteDesktopViewer).toHaveBeenCalledTimes(1)) + expect(sendTerminal).not.toHaveBeenCalled() + expect(owner).toBe('host') + + releaseClaim() + await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledTimes(1)) + expect(order).toEqual(['claim:stream:1', 'input:stream:1']) + + claimRemoteDesktopViewer.mockClear() + sendTerminal.mockClear() + handler(inputFrame(streamId, 3, '\u001b[O\u001b[I')) + await Promise.resolve() + expect(claimRemoteDesktopViewer).not.toHaveBeenCalled() + await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledTimes(1)) + sendTerminal.mockClear() + handler(inputFrame(streamId, 4, '\u001b[3;1R')) + await Promise.resolve() + expect(claimRemoteDesktopViewer).not.toHaveBeenCalled() + await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledTimes(1)) + + runtime.cleanupSubscription('terminal-1:desktop-1') + await dispatchPromise + }) +}) diff --git a/src/main/runtime/rpc/terminal-desktop-stream-detach-serialization.test.ts b/src/main/runtime/rpc/terminal-desktop-stream-detach-serialization.test.ts new file mode 100644 index 00000000000..3a400299a39 --- /dev/null +++ b/src/main/runtime/rpc/terminal-desktop-stream-detach-serialization.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' +import { + makeRequest, + sendDesktopMultiplexSubscribe, + startDesktopMultiplexSubscribe, + stubRuntime +} from './terminal-multiplex-test-harness' +import { createSubscriptionRegistryDouble } from './subscription-registry-test-double' + +const CLIENT_CASES: { + name: string + capabilities: Record + opcode: TerminalStreamOpcode +}[] = [ + { + name: 'current client claims', + capabilities: { ackOutput: 1 as const, desktopViewportClaims: 1 as const }, + opcode: TerminalStreamOpcode.ClaimViewport + }, + { + name: 'legacy client resize claims', + capabilities: { ackOutput: 1 as const }, + opcode: TerminalStreamOpcode.Resize + } +] + +const MULTIPLEX_CASES = CLIENT_CASES.flatMap((client) => + (['slot unsubscribe', 'connection cleanup'] as const).map((detach) => ({ ...client, detach })) +) + +function encodedFrame( + opcode: TerminalStreamOpcode, + streamId: number, + seq: number, + payload: Uint8Array +) { + return decodeTerminalStreamFrame(encodeTerminalStreamFrame({ opcode, streamId, seq, payload }))! +} + +async function flushPromiseChain(): Promise { + for (let index = 0; index < 10; index += 1) { + await Promise.resolve() + } +} + +describe('desktop viewport stream detach serialization', () => { + it.each(MULTIPLEX_CASES)( + 'multiplex $detach drops queued $name and input before detach restoration', + async ({ capabilities, detach, opcode }) => { + let owner = 'host' + let releaseClaim = (): void => {} + const updateRemoteDesktopViewer = vi.fn( + async (_ptyId: string, subscriptionKey: string): Promise => { + owner = subscriptionKey + return true + } + ) + const unregisterRemoteDesktopViewer = vi.fn( + async (_ptyId: string, _subscriptionKey: string): Promise => { + owner = 'host' + return true + } + ) + const unregisterRemoteDesktopViewers = vi.fn( + async (_ptyId: string, _subscriptionKeys: Iterable): Promise => { + owner = 'host' + return true + } + ) + const harness = startDesktopMultiplexSubscribe({ + updateRemoteDesktopViewer, + unregisterRemoteDesktopViewer, + unregisterRemoteDesktopViewers, + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }) + }) + + sendDesktopMultiplexSubscribe(harness.handlers, capabilities) + await vi.waitFor(() => + expect( + harness.messages.some((message) => JSON.parse(message).result?.type === 'subscribed') + ).toBe(true) + ) + owner = 'host' + updateRemoteDesktopViewer.mockImplementationOnce( + (_ptyId: string, subscriptionKey: string) => { + owner = subscriptionKey + return new Promise((resolve) => { + releaseClaim = () => resolve(true) + }) + } + ) + + const handler = harness.handlers.get(7)! + handler(encodedFrame(opcode, 7, 2, encodeTerminalStreamJson({ cols: 96, rows: 32 }))) + await vi.waitFor(() => expect(updateRemoteDesktopViewer).toHaveBeenCalledTimes(2)) + handler(encodedFrame(opcode, 7, 3, encodeTerminalStreamJson({ cols: 88, rows: 28 }))) + handler(encodedFrame(TerminalStreamOpcode.Input, 7, 4, encodeTerminalStreamText('late'))) + if (detach === 'slot unsubscribe') { + handler(encodedFrame(TerminalStreamOpcode.Unsubscribe, 7, 5, new Uint8Array())) + } else { + harness.registry.cleanupSubscription('terminal-multiplex:conn-desktop-first-paint') + } + + if (detach === 'slot unsubscribe') { + expect(unregisterRemoteDesktopViewer).toHaveBeenCalledWith( + 'pty-1', + 'multiplex:conn-desktop-first-paint:7' + ) + } else { + expect(unregisterRemoteDesktopViewers).toHaveBeenCalledWith('pty-1', [ + 'multiplex:conn-desktop-first-paint:7' + ]) + } + expect(owner).toBe('host') + releaseClaim() + await flushPromiseChain() + + expect(updateRemoteDesktopViewer).toHaveBeenCalledTimes(2) + expect(harness.runtime.sendTerminal).not.toHaveBeenCalled() + expect(owner).toBe('host') + + if (detach === 'slot unsubscribe') { + harness.registry.cleanupSubscription('terminal-multiplex:conn-desktop-first-paint') + } + await harness.dispatchPromise + } + ) + + it.each(CLIENT_CASES)( + 'single stream drops queued $name and input before detach restoration', + async ({ capabilities, opcode }) => { + let owner = 'host' + let releaseClaim = (): void => {} + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const registry = createSubscriptionRegistryDouble() + const updateRemoteDesktopViewer = vi.fn( + async (_ptyId: string, subscriptionKey: string): Promise => { + owner = subscriptionKey + return true + } + ) + const unregisterRemoteDesktopViewer = vi.fn( + async (_ptyId: string, _subscriptionKey: string): Promise => { + owner = 'host' + return true + } + ) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValue({ data: 'snapshot', cols: 120, rows: 40 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup), + cleanupSubscription: vi.fn(registry.cleanupSubscription), + waitForTerminal: vi.fn(() => new Promise(() => {})), + updateRemoteDesktopViewer, + unregisterRemoteDesktopViewer, + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }) + }) + const messages: string[] = [] + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 }, + capabilities: { terminalBinaryStream: 1, ...capabilities } + }), + (message) => messages.push(message), + { + connectionId: 'conn-single', + sendBinary: vi.fn(), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((message) => JSON.parse(message).result?.type === 'subscribed')).toBe( + true + ) + ) + const [streamId, handler] = Array.from(handlers.entries())[0]! + owner = 'host' + updateRemoteDesktopViewer.mockImplementationOnce( + (_ptyId: string, subscriptionKey: string) => { + owner = subscriptionKey + return new Promise((resolve) => { + releaseClaim = () => resolve(true) + }) + } + ) + + handler(encodedFrame(opcode, streamId, 2, encodeTerminalStreamJson({ cols: 96, rows: 32 }))) + await vi.waitFor(() => expect(updateRemoteDesktopViewer).toHaveBeenCalledTimes(2)) + handler(encodedFrame(opcode, streamId, 3, encodeTerminalStreamJson({ cols: 88, rows: 28 }))) + handler( + encodedFrame(TerminalStreamOpcode.Input, streamId, 4, encodeTerminalStreamText('late')) + ) + runtime.cleanupSubscription('terminal-1:desktop-1') + + expect(unregisterRemoteDesktopViewer).toHaveBeenCalledWith('pty-1', expect.any(String)) + expect(owner).toBe('host') + releaseClaim() + await flushPromiseChain() + + expect(updateRemoteDesktopViewer).toHaveBeenCalledTimes(2) + expect(runtime.sendTerminal).not.toHaveBeenCalled() + expect(owner).toBe('host') + await dispatchPromise + } + ) +}) diff --git a/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts b/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts index 45ff2d77b8c..9a46091f1c1 100644 --- a/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts @@ -213,7 +213,7 @@ describe('terminal multiplex RPC', () => { }) ) const sentAfterSuccessfulClaim = vi.mocked(runtime.sendTerminal).mock.calls.length - vi.mocked(runtime.updateRemoteDesktopViewer).mockResolvedValueOnce(false) + vi.mocked(runtime.claimRemoteDesktopViewer).mockResolvedValueOnce(false) for (const [opcode, seq, payload] of [ [TerminalStreamOpcode.ClaimViewport, 3, encodeTerminalStreamJson({ cols: 88, rows: 28 })], [TerminalStreamOpcode.Resize, 4, encodeTerminalStreamJson({ cols: 88, rows: 28 })], diff --git a/src/main/runtime/rpc/terminal-multiplex-test-harness.ts b/src/main/runtime/rpc/terminal-multiplex-test-harness.ts index 13368bd680d..d49c2404562 100644 --- a/src/main/runtime/rpc/terminal-multiplex-test-harness.ts +++ b/src/main/runtime/rpc/terminal-multiplex-test-harness.ts @@ -33,6 +33,7 @@ export function stubRuntime(overrides: Partial = {}): OrcaRu resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), requestRendererTerminalTabMount: vi.fn().mockReturnValue(true), updateRemoteDesktopViewer: vi.fn().mockResolvedValue(true), + claimRemoteDesktopViewer: vi.fn().mockResolvedValue(true), unregisterRemoteDesktopViewer: vi.fn().mockResolvedValue(true), unregisterRemoteDesktopViewers: vi.fn().mockResolvedValue(true), isPtyResizeDrivenRemotely: vi.fn().mockReturnValue(false), diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index d69d906047d..ddf27d070e0 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -66,7 +66,7 @@ export type PtyApi = { startupCwdFallback?: { kind: 'worktree'; cwd: string } agentResumeUnavailable?: true }> - write: (id: string, data: string) => void + write: (id: string, data: string, inputKind?: 'query-reply') => void writeAccepted: (id: string, data: string) => Promise onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void resize: (id: string, cols: number, rows: number) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index b8a00c6d647..a2263c1f0e1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1049,8 +1049,8 @@ const api = { agentResumeUnavailable?: true }> => ipcRenderer.invoke('pty:spawn', opts), - write: (id: string, data: string): void => { - ipcRenderer.send('pty:write', { id, data }) + write: (id: string, data: string, inputKind?: 'query-reply'): void => { + ipcRenderer.send('pty:write', { id, data, inputKind }) }, writeAccepted: (id: string, data: string): Promise => ipcRenderer.invoke('pty:writeAccepted', { id, data }), diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts index a1c71fdbce4..32c56fe0cca 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.test.ts @@ -23,6 +23,7 @@ import { buildPaneConnectionDeps } from './pty-connection-test-deps' import { createInitialStoreState } from './pty-connection-test-store-fixtures' import type { StoreState } from './pty-connection-test-store-state' import { + installFocusedTerminalTestDocument, installTerminalTestGlobals, restoreTerminalTestGlobals } from './pty-connection-test-environment' @@ -147,6 +148,7 @@ describe('connectPanePty', () => { storeSubscribers = [] mockStoreState = createInitialStoreState(() => mockStoreState) installTerminalTestGlobals() + installFocusedTerminalTestDocument() }) afterEach(async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.test.ts index f5745fbdc37..38f06278f3e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-resize-signals.test.ts @@ -12,6 +12,7 @@ import { buildPaneConnectionDeps } from './pty-connection-test-deps' import { createInitialStoreState } from './pty-connection-test-store-fixtures' import type { StoreState } from './pty-connection-test-store-state' import { + installFocusedTerminalTestDocument, installTerminalTestGlobals, restoreTerminalTestGlobals } from './pty-connection-test-environment' @@ -136,6 +137,7 @@ describe('connectPanePty', () => { storeSubscribers = [] mockStoreState = createInitialStoreState(() => mockStoreState) installTerminalTestGlobals() + installFocusedTerminalTestDocument() }) afterEach(async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts index 047ab764814..6668f06a273 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts @@ -15,6 +15,7 @@ import { buildPaneConnectionDeps } from './pty-connection-test-deps' import { createInitialStoreState } from './pty-connection-test-store-fixtures' import type { StoreState } from './pty-connection-test-store-state' import { + installFocusedTerminalTestDocument, installTerminalTestGlobals, restoreTerminalTestGlobals } from './pty-connection-test-environment' @@ -139,6 +140,7 @@ describe('connectPanePty', () => { storeSubscribers = [] mockStoreState = createInitialStoreState(() => mockStoreState) installTerminalTestGlobals() + installFocusedTerminalTestDocument() }) afterEach(async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts index 7853d305145..47a367920ea 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts @@ -874,7 +874,10 @@ describe('connectPanePty', () => { const { connectPanePty } = await import('./pty-connection') const { WRITE_PIPELINE_STALL_CHECK_MS } = await import('@/lib/pane-manager/terminal-write-pipeline-health') + const remountTerminalTabForRecovery = vi.fn<(tabId: string) => boolean>(() => true) + mockStoreState = { ...mockStoreState, remountTerminalTabForRecovery } as StoreState const transport = createMockTransport('pty-rejected') + transport.isConnected = vi.fn(() => true) transport.sendInputAccepted = vi.fn().mockResolvedValue(false) transportFactoryQueue.push(transport) const pane = createPane(1) @@ -890,6 +893,7 @@ describe('connectPanePty', () => { expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03') expect(pane.terminal.write).not.toHaveBeenCalledWith('', expect.any(Function)) + expect(remountTerminalTabForRecovery).not.toHaveBeenCalled() binding.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts index 6e9f24f3efe..feb121f79a0 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts @@ -94,6 +94,15 @@ export function installTerminalTestGlobals(): void { globalThis.cancelAnimationFrame = vi.fn() } +export function installFocusedTerminalTestDocument(): void { + const target = new EventTarget() + Object.defineProperties(target, { + visibilityState: { value: 'visible' }, + hasFocus: { value: vi.fn(() => true) } + }) + ;(globalThis as { document?: Document }).document = target as Document +} + export async function restoreTerminalTestGlobals(): Promise { // Drain deferred confirmation work before the next test replaces its store mock. await drainFakeTimerWork() diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts index acca657bfd7..30b9b62a484 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts @@ -37,6 +37,7 @@ export type MockTransport = { claimViewport: ReturnType setOutputPaused?: ReturnType resize: ReturnType + isConnected?: ReturnType getPtyId: ReturnType getConnectionId: ReturnType serializeBuffer?: ReturnType diff --git a/src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.test.ts index 2432e380069..d15bae9a1a7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-visibility-resume-size.test.ts @@ -13,6 +13,7 @@ import { buildPaneConnectionDeps } from './pty-connection-test-deps' import { createInitialStoreState } from './pty-connection-test-store-fixtures' import type { StoreState } from './pty-connection-test-store-state' import { + installFocusedTerminalTestDocument, installTerminalTestGlobals, restoreTerminalTestGlobals } from './pty-connection-test-environment' @@ -137,6 +138,7 @@ describe('connectPanePty', () => { storeSubscribers = [] mockStoreState = createInitialStoreState(() => mockStoreState) installTerminalTestGlobals() + installFocusedTerminalTestDocument() }) afterEach(async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-scan.ts b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-scan.ts index 8d8d53eebb6..96bc2418510 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-scan.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/foreground-output-scan.ts @@ -4,6 +4,12 @@ import { consumeForegroundImmediateBudget, createForegroundImmediateBudget } from './foreground-output-budgets' +import { + TERMINAL_FOCUS_IN_SEQUENCE, + TERMINAL_FOCUS_OUT_SEQUENCE +} from '../../../../../shared/terminal-focus-report' + +export { TERMINAL_FOCUS_IN_SEQUENCE, TERMINAL_FOCUS_OUT_SEQUENCE } export const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256 export const SYNCHRONIZED_OUTPUT_START_SEQUENCE = '\x1b[?2026h' @@ -11,8 +17,6 @@ export const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l' export const SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS = SYNCHRONIZED_OUTPUT_START_SEQUENCE.length - 1 export const CURSOR_SHOW_SEQUENCE = '\x1b[?25h' export const CURSOR_HIDE_SEQUENCE = '\x1b[?25l' -export const TERMINAL_FOCUS_IN_SEQUENCE = '\x1b[I' -export const TERMINAL_FOCUS_OUT_SEQUENCE = '\x1b[O' export const FOCUS_REPORTING_DISABLE_SEQUENCE = '\x1b[?1004l' export const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250 export const SHIFT_ENTER_RECONFIRM_IDLE_MS = 350 diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts index 2b29ec0f140..dba63502c1a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts @@ -20,7 +20,10 @@ import { FOREGROUND_GRID_DRIFT_CHECK_MIN_MS } from './foreground-output-budgets' import { TERMINAL_FOCUS_IN_SEQUENCE, TERMINAL_FOCUS_OUT_SEQUENCE } from './foreground-output-scan' import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' import { isCodexPaneStale } from './codex-pane-stale' -import { isRemoteDesktopViewportClaimEligible } from '../remote-desktop-viewport-claim' +import { + getRemoteDesktopViewportClaimDocumentState, + isRemoteDesktopViewportClaimEligible +} from '../remote-desktop-viewport-claim' import type { ConnectPanePtySession } from './connect-pane-pty-session' @@ -213,14 +216,10 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { if (queuePanePtyResizeIfHeld(session.pane.container, cols, rows)) { return } - const currentPtyId = session.transport.getPtyId() - const claim = - !isRemoteRuntimePtyId(currentPtyId) || - isRemoteDesktopViewportClaimEligible({ - paneVisible: session.deps.isVisibleRef.current, - documentVisible: document.visibilityState !== 'hidden', - documentFocused: document.hasFocus() - }) + const claim = isRemoteDesktopViewportClaimEligible({ + paneVisible: session.deps.isVisibleRef.current, + ...getRemoteDesktopViewportClaimDocumentState() + }) session.transport.resize(cols, rows, { claim }) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts index ba00c1d13c2..6c7f3ae6c98 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts @@ -20,6 +20,7 @@ import { import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' import { TRANSPORT_CONNECT_SETTLE_GRACE_MS } from './pty-connect-limits' +import { shouldClaimDesktopViewportForUserActivity } from '../remote-desktop-viewport-claim' import type { ConnectPanePtySession } from './connect-pane-pty-session' @@ -192,7 +193,14 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void { session.claimViewportForUserActivity = (): void => { const currentPtyId = session.transport.getPtyId() - if (!currentPtyId || getFitOverrideForPty(currentPtyId)?.mode !== 'remote-desktop-fit') { + if ( + !currentPtyId || + !shouldClaimDesktopViewportForUserActivity({ + initialRemoteFitPending: + isRemoteRuntimePtyId(currentPtyId) && session.pendingVisibleRemoteViewportClaim, + holdMode: getFitOverrideForPty(currentPtyId)?.mode ?? null + }) + ) { return } let proposed: { cols: number; rows: number } | undefined diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts index 62dcb4b7a83..46a6eb14ecc 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts @@ -4,7 +4,10 @@ import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observe import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' import { reconcilePtySizeAcrossFrames } from '../pty-size-reconcile' -import { shouldClaimRemoteDesktopViewport } from '../remote-desktop-viewport-claim' +import { + getRemoteDesktopViewportClaimDocumentState, + shouldClaimRemoteDesktopViewport +} from '../remote-desktop-viewport-claim' import { deferTerminalGeometryMutationDuringRebuild } from '@/lib/pane-manager/terminal-scroll-intent-rebuild' import { waitForStableStartupGrid } from '../terminal-startup-grid-settle' @@ -78,8 +81,7 @@ export function installPtyResizeGeometry(session: ConnectPanePtySession): void { current: proposed, paneGeometryChanged, paneVisible: session.deps.isVisibleRef.current, - documentVisible: document.visibilityState !== 'hidden', - documentFocused: document.hasFocus() + ...getRemoteDesktopViewportClaimDocumentState() }) ) { // Why: a focused, visible layout change is genuine activity; release diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts index b63ab58acec..bc45b19821f 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts @@ -204,6 +204,23 @@ describe('pty input write queue', () => { expect(writes.map((write) => write.data)).toEqual(replies) }) + it('classifies query replies for host ownership without classifying user input', async () => { + const writes: { data: string; inputKind?: 'query-reply' }[] = [] + const queue = createPtyInputWriteQueue({ + isWritable: () => true, + write: (_id, data, inputKind) => writes.push({ data, inputKind }) + }) + + queue.enqueueQueryReply('pty-1', '\u001b[1;1R') + queue.enqueue('pty-1', 'x') + await queue.waitForDrain() + + expect(writes).toEqual([ + { data: '\u001b[1;1R', inputKind: 'query-reply' }, + { data: 'x', inputKind: undefined } + ]) + }) + it('does not coalesce a color-scheme reply with a following keystroke', async () => { const { writes, queue } = createRecordingQueue() const reply = mode2031SequenceFor('dark') diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts index 9596cb7a732..54cdb81dbd7 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts @@ -34,7 +34,7 @@ export type PtyInputWriteQueue = { export type PtyInputWriteQueueDeps = { isWritable: (id: string) => boolean - write: (id: string, data: string) => void + write: (id: string, data: string, inputKind?: 'query-reply') => void yieldBetweenWrites?: () => Promise onDrainFailure?: (id: string) => void } @@ -215,7 +215,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput removePending(next) continue } - deps.write(next.id, chunk.value) + deps.write(next.id, chunk.value, next.replyOnly ? 'query-reply' : undefined) const following = next.chunks.next() if (following.done) { removePending(next) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts index b6d0f589ba3..6ed5998cb99 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts @@ -84,12 +84,12 @@ describe('createIpcPtyTransport', () => { expect(transport.sendInput(`${chunk}tail`)).toBe(true) expect(window.api.pty.write).toHaveBeenCalledTimes(1) - expect(window.api.pty.write).toHaveBeenNthCalledWith(1, 'pty-1', chunk) + expect(window.api.pty.write).toHaveBeenNthCalledWith(1, 'pty-1', chunk, undefined) await vi.runOnlyPendingTimersAsync() expect(window.api.pty.write).toHaveBeenCalledTimes(2) - expect(window.api.pty.write).toHaveBeenNthCalledWith(2, 'pty-1', 'tail') + expect(window.api.pty.write).toHaveBeenNthCalledWith(2, 'pty-1', 'tail', undefined) } finally { vi.useRealTimers() } @@ -114,11 +114,11 @@ describe('createIpcPtyTransport', () => { await vi.runAllTimersAsync() expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([ - ['pty-1', first], - ['pty-1', ordinary], + ['pty-1', first, 'query-reply'], + ['pty-1', ordinary, undefined], ...replies .slice(-PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES) - .map((reply) => ['pty-1', reply]) + .map((reply) => ['pty-1', reply, 'query-reply']) ]) } finally { vi.useRealTimers() diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index f713ee6c2f9..633a5ddc831 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -591,7 +591,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra let storedCallbacks: Parameters[0]['callbacks'] = {} const inputWriteQueue = createPtyInputWriteQueue({ isWritable: (id) => connected && ptyId === id, - write: (id, data) => window.api.pty.write(id, data), + write: (id, data, inputKind) => window.api.pty.write(id, data, inputKind), // Guard like the registered writeUnavailable handler: a rebind during the async drain // must not tell the new pane that its write failed. onDrainFailure: (id) => { diff --git a/src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts b/src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts index 2e788f68192..33fe8dbed83 100644 --- a/src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { isRemoteDesktopViewportClaimEligible, - shouldClaimRemoteDesktopViewport + shouldClaimRemoteDesktopViewport, + shouldClaimDesktopViewportForUserActivity } from './remote-desktop-viewport-claim' describe('isRemoteDesktopViewportClaimEligible', () => { @@ -81,3 +82,23 @@ describe('shouldClaimRemoteDesktopViewport', () => { ).toBe(true) }) }) + +describe('shouldClaimDesktopViewportForUserActivity', () => { + it.each([ + { initialRemoteFitPending: true, holdMode: null, expected: true }, + { initialRemoteFitPending: false, holdMode: null, expected: false }, + { initialRemoteFitPending: false, holdMode: 'remote-desktop-fit' as const, expected: true }, + { initialRemoteFitPending: true, holdMode: 'mobile-fit' as const, expected: false } + ])('handles connection-local and authoritative fit state: %o', (input) => { + expect(shouldClaimDesktopViewportForUserActivity(input)).toBe(input.expected) + }) + + it('preserves known host reclaim', () => { + expect( + shouldClaimDesktopViewportForUserActivity({ + initialRemoteFitPending: false, + holdMode: 'remote-desktop-fit' + }) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.ts b/src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.ts index 5832b125ddb..847ad40a5e4 100644 --- a/src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.ts +++ b/src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.ts @@ -10,6 +10,27 @@ export function isRemoteDesktopViewportClaimEligible(args: { return args.paneVisible && args.documentVisible && args.documentFocused } +export function getRemoteDesktopViewportClaimDocumentState(): { + documentVisible: boolean + documentFocused: boolean +} { + const available = typeof document !== 'undefined' + return { + documentVisible: available && document.visibilityState !== 'hidden', + documentFocused: available && typeof document.hasFocus === 'function' && document.hasFocus() + } +} + +export function shouldClaimDesktopViewportForUserActivity(args: { + initialRemoteFitPending: boolean + holdMode: FitHoldMode | null +}): boolean { + return ( + args.holdMode === 'remote-desktop-fit' || + (args.initialRemoteFitPending && args.holdMode === null) + ) +} + export function shouldClaimRemoteDesktopViewport(args: { holdMode: FitHoldMode prior: TerminalGrid | null diff --git a/src/shared/terminal-focus-report.ts b/src/shared/terminal-focus-report.ts new file mode 100644 index 00000000000..6e234e0a446 --- /dev/null +++ b/src/shared/terminal-focus-report.ts @@ -0,0 +1,15 @@ +export const TERMINAL_FOCUS_IN_SEQUENCE = '\u001b[I' +export const TERMINAL_FOCUS_OUT_SEQUENCE = '\u001b[O' + +export function isTerminalFocusReport(data: string): boolean { + if (!data) { + return false + } + for (let offset = 0; offset < data.length; offset += TERMINAL_FOCUS_IN_SEQUENCE.length) { + const report = data.slice(offset, offset + TERMINAL_FOCUS_IN_SEQUENCE.length) + if (report !== TERMINAL_FOCUS_IN_SEQUENCE && report !== TERMINAL_FOCUS_OUT_SEQUENCE) { + return false + } + } + return true +} diff --git a/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts b/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts index 1fc471b0de7..0ab158ff4ab 100644 --- a/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts +++ b/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts @@ -101,6 +101,7 @@ export function createHostTerminalRuntimeStub( registerRemoteTerminalViewSubscriber: () => () => {}, requestRendererTerminalTabMount: () => true, updateRemoteDesktopViewer: async () => true, + claimRemoteDesktopViewer: async () => true, unregisterRemoteDesktopViewer: async () => true, unregisterRemoteDesktopViewers: async () => true, isPtyResizeDrivenRemotely: () => false, diff --git a/tests/e2e/helpers/terminal-viewport-ownership-fixture.ts b/tests/e2e/helpers/terminal-viewport-ownership-fixture.ts index 1afe9824137..041d0581823 100644 --- a/tests/e2e/helpers/terminal-viewport-ownership-fixture.ts +++ b/tests/e2e/helpers/terminal-viewport-ownership-fixture.ts @@ -15,20 +15,6 @@ export type TerminalViewportTarget = { webTabId: string } -export type TerminalWireFrame = { - direction: 'in' | 'out' - opcode?: number - payload?: unknown - held?: boolean -} - -type WireProbeState = { - cleanup: () => void - held: { channel: string; args: unknown[] }[] - release: () => void - trace: TerminalWireFrame[] -} - export function createViewportFixture(): { command: string dispose: () => void @@ -169,6 +155,48 @@ export async function openTerminalTab( await page.waitForFunction((id) => window.__paneManagers?.has(id) ?? false, tabId, { timeout: 60_000 }) + await page.waitForFunction( + (id) => { + const state = window.__store?.getState() + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + const screen = pane?.container.querySelector('.xterm-screen') + if (state?.activeTabId !== id || !pane?.container.isConnected || !screen) { + return false + } + const style = getComputedStyle(pane.container) + const rect = screen.getBoundingClientRect() + return ( + style.display !== 'none' && + style.visibility !== 'hidden' && + rect.width > 0 && + rect.height > 0 + ) + }, + tabId, + { timeout: 60_000 } + ) +} + +export async function waitForTerminalText(page: Page, tabId: string, text: string): Promise { + await page.waitForFunction( + ({ tabId, text }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + return false + } + const buffer = pane.terminal.buffer.active + for (let index = 0; index < buffer.length; index += 1) { + if (buffer.getLine(index)?.translateToString(true).includes(text)) { + return true + } + } + return false + }, + { tabId, text }, + { timeout: 30_000 } + ) } export async function readPaneGrid( @@ -219,95 +247,6 @@ export async function configureElectronWindow( ) } -export async function installTerminalWireProbe( - app: ElectronApplication, - options: { holdFitEvents?: boolean; legacyViewportClient?: boolean } = {} -): Promise { - await app.evaluate(({ BrowserWindow, ipcMain }, probeOptions) => { - const target = globalThis as typeof globalThis & { __sta5050WireProbe?: WireProbeState } - target.__sta5050WireProbe?.cleanup() - const trace: TerminalWireFrame[] = [] - const listener = (_event: unknown, message: { bytes?: Uint8Array }) => { - const bytes = message.bytes - if (!bytes || bytes.byteLength < 16) { - return - } - const opcode = bytes[2] - if (probeOptions.legacyViewportClient && opcode === 9) { - const text = new TextDecoder().decode(bytes.slice(16)) - const key = 'desktopViewportClaims' - const offset = text.indexOf(key) - if (offset !== -1) { - bytes[16 + offset + key.length - 1] = 'x'.charCodeAt(0) - } - } - if (probeOptions.legacyViewportClient && opcode === 14) { - bytes[2] = 8 - } - let payload: unknown - try { - payload = JSON.parse(new TextDecoder().decode(bytes.slice(16))) - } catch { - payload = undefined - } - trace.push({ direction: 'out', opcode: bytes[2], payload }) - } - ipcMain.prependListener('runtimeEnvironments:subscriptionBinary', listener) - const window = BrowserWindow.getAllWindows()[0] - if (!window) { - throw new Error('Electron window unavailable') - } - const originalSend = window.webContents.send.bind(window.webContents) - const held: { channel: string; args: unknown[] }[] = [] - window.webContents.send = ((channel: string, ...args: unknown[]) => { - const isFit = - channel === 'runtimeEnvironments:subscriptionEvent' && - JSON.stringify(args).includes('fit-override-changed') - trace.push({ direction: 'in', held: isFit && probeOptions.holdFitEvents, payload: args }) - if (isFit && probeOptions.holdFitEvents) { - held.push({ channel, args }) - return - } - originalSend(channel, ...args) - }) as typeof window.webContents.send - const release = () => { - for (const event of held.splice(0)) { - originalSend(event.channel, ...event.args) - } - } - const cleanup = () => { - release() - ipcMain.removeListener('runtimeEnvironments:subscriptionBinary', listener) - window.webContents.send = originalSend - delete target.__sta5050WireProbe - } - target.__sta5050WireProbe = { cleanup, held, release, trace } - }, options) -} - -export async function readTerminalWireProbe( - app: ElectronApplication -): Promise { - return app.evaluate(() => { - const target = globalThis as typeof globalThis & { __sta5050WireProbe?: WireProbeState } - return target.__sta5050WireProbe?.trace ?? [] - }) -} - -export async function releaseTerminalFitEvents(app: ElectronApplication): Promise { - await app.evaluate(() => { - const target = globalThis as typeof globalThis & { __sta5050WireProbe?: WireProbeState } - target.__sta5050WireProbe?.release() - }) -} - -export async function disposeTerminalWireProbe(app: ElectronApplication): Promise { - await app.evaluate(() => { - const target = globalThis as typeof globalThis & { __sta5050WireProbe?: WireProbeState } - target.__sta5050WireProbe?.cleanup() - }) -} - export function lastFixtureGrid(text: string): { cols: number; rows: number } | null { const matches = [...text.matchAll(/(?:READY|SIZE|LINE:[^:]+):(\d+)x(\d+)/g)] const match = matches.at(-1) diff --git a/tests/e2e/helpers/terminal-viewport-wire-probe.ts b/tests/e2e/helpers/terminal-viewport-wire-probe.ts new file mode 100644 index 00000000000..a80f0082d17 --- /dev/null +++ b/tests/e2e/helpers/terminal-viewport-wire-probe.ts @@ -0,0 +1,139 @@ +import type { ElectronApplication } from '@stablyai/playwright-test' + +export type TerminalWireFrame = { + direction: 'in' | 'out' + opcode?: number + originalOpcode?: number + payload?: unknown + held?: boolean + streamId?: number +} + +type WireProbeState = { + cleanup: () => void + held: { + args: unknown[] + channel: string + send: (channel: string, ...args: unknown[]) => void + }[] + release: () => void + trace: TerminalWireFrame[] +} + +export async function installTerminalWireProbe( + app: ElectronApplication, + options: { + holdFitEvents?: boolean + holdHostFitEvents?: boolean + dropHostViewportClaims?: boolean + legacyViewportClient?: boolean + } = {} +): Promise { + await app.evaluate(({ BrowserWindow, ipcMain }, probeOptions) => { + const target = globalThis as typeof globalThis & { __sta5050WireProbe?: WireProbeState } + target.__sta5050WireProbe?.cleanup() + const windows = BrowserWindow.getAllWindows() + if (windows.length === 0) { + throw new Error('Electron window unavailable') + } + const trace: TerminalWireFrame[] = [] + const listener = (_event: unknown, message: { bytes?: Uint8Array }) => { + const bytes = message.bytes + if (!bytes || bytes.byteLength < 16) { + return + } + const streamId = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32( + 4, + true + ) + const opcode = bytes[2] + if (probeOptions.legacyViewportClient && opcode === 9) { + const text = new TextDecoder().decode(bytes.slice(16)) + const key = 'desktopViewportClaims' + const offset = text.indexOf(key) + if (offset !== -1) { + bytes[16 + offset + key.length - 1] = 'x'.charCodeAt(0) + } + } + if (probeOptions.legacyViewportClient && opcode === 14) { + // Why: reconstruct the wire shape of an intentionally old client without running an obsolete desktop build. + bytes[2] = 8 + } + let payload: unknown + try { + payload = JSON.parse(new TextDecoder().decode(bytes.slice(16))) + } catch { + payload = bytes[2] === 7 ? new TextDecoder().decode(bytes.slice(16)) : undefined + } + trace.push({ direction: 'out', opcode: bytes[2], originalOpcode: opcode, payload, streamId }) + } + ipcMain.prependListener('runtimeEnvironments:subscriptionBinary', listener) + const originalEmit = ipcMain.emit + ipcMain.emit = ((channel: string, ...args: unknown[]) => { + if (probeOptions.dropHostViewportClaims && channel === 'pty:claimViewport') { + trace.push({ direction: 'out', opcode: 14, payload: args.at(-1) }) + return true + } + return originalEmit.call(ipcMain, channel, ...args) + }) as typeof ipcMain.emit + const held: WireProbeState['held'] = [] + const sendPrototype = Object.getPrototypeOf(windows[0].webContents) as { + send: (channel: string, ...args: unknown[]) => void + } + const originalSend = sendPrototype.send + sendPrototype.send = function (this: unknown, channel: string, ...args: unknown[]) { + const send = (nextChannel: string, ...nextArgs: unknown[]) => + originalSend.call(this, nextChannel, ...nextArgs) + const isRemoteFit = + channel === 'runtimeEnvironments:subscriptionEvent' && + JSON.stringify(args).includes('fit-override-changed') + const isHostFit = channel === 'runtime:terminalFitOverrideChanged' + const shouldHold = + (isRemoteFit && probeOptions.holdFitEvents) || (isHostFit && probeOptions.holdHostFitEvents) + if (isRemoteFit || isHostFit) { + trace.push({ direction: 'in', held: Boolean(shouldHold), payload: args }) + } + if (shouldHold) { + held.push({ channel, args, send }) + return + } + send(channel, ...args) + } + const release = () => { + for (const event of held.splice(0)) { + event.send(event.channel, ...event.args) + } + } + const cleanup = () => { + release() + ipcMain.removeListener('runtimeEnvironments:subscriptionBinary', listener) + ipcMain.emit = originalEmit + sendPrototype.send = originalSend + delete target.__sta5050WireProbe + } + target.__sta5050WireProbe = { cleanup, held, release, trace } + }, options) +} + +export async function readTerminalWireProbe( + app: ElectronApplication +): Promise { + return app.evaluate(() => { + const target = globalThis as typeof globalThis & { __sta5050WireProbe?: WireProbeState } + return target.__sta5050WireProbe?.trace ?? [] + }) +} + +export async function releaseTerminalFitEvents(app: ElectronApplication): Promise { + await app.evaluate(() => { + const target = globalThis as typeof globalThis & { __sta5050WireProbe?: WireProbeState } + target.__sta5050WireProbe?.release() + }) +} + +export async function disposeTerminalWireProbe(app: ElectronApplication): Promise { + await app.evaluate(() => { + const target = globalThis as typeof globalThis & { __sta5050WireProbe?: WireProbeState } + target.__sta5050WireProbe?.cleanup() + }) +} diff --git a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts index fae82b45d44..4701b3076ba 100644 --- a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts +++ b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts @@ -74,6 +74,7 @@ function startHost(): { resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), requestRendererTerminalTabMount: vi.fn().mockReturnValue(true), updateRemoteDesktopViewer: vi.fn().mockResolvedValue(true), + claimRemoteDesktopViewer: vi.fn().mockResolvedValue(true), unregisterRemoteDesktopViewer: vi.fn().mockResolvedValue(true), unregisterRemoteDesktopViewers: vi.fn().mockResolvedValue(true), isPtyResizeDrivenRemotely: vi.fn().mockReturnValue(false), diff --git a/tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts b/tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts index 6f1b9e2297f..02ee024aa7f 100644 --- a/tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts +++ b/tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts @@ -1,4 +1,5 @@ import type { Page, TestInfo } from '@stablyai/playwright-test' +import { isTerminalFocusReport } from '../../src/shared/terminal-focus-report' import { expect, test } from './helpers/orca-app' import { @@ -12,16 +13,19 @@ import { configureElectronWindow, createViewportFixture, createViewportTerminal, - disposeTerminalWireProbe, fitTerminalPane, - installTerminalWireProbe, lastFixtureGrid, openTerminalTab, readPaneGrid, - readTerminalWireProbe, - releaseTerminalFitEvents, - type TerminalViewportTarget + type TerminalViewportTarget, + waitForTerminalText } from './helpers/terminal-viewport-ownership-fixture' +import { + disposeTerminalWireProbe, + installTerminalWireProbe, + readTerminalWireProbe, + releaseTerminalFitEvents +} from './helpers/terminal-viewport-wire-probe' const fixture = createViewportFixture() @@ -50,6 +54,20 @@ async function focusApp(client: PairedElectronClient): Promise { await expect.poll(() => client.page.evaluate(() => document.hasFocus())).toBe(true) } +async function focusAppWithoutResize(client: PairedElectronClient): Promise { + await client.app.evaluate(({ app, BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + if (!window) { + throw new Error('Electron window unavailable') + } + window.show() + app.focus({ steal: true }) + window.focus() + }) + await client.page.bringToFront() + await expect.poll(() => client.page.evaluate(() => document.hasFocus())).toBe(true) +} + async function forceDocumentUnfocused(page: Page): Promise { await page.evaluate(() => { Object.defineProperty(document, 'hasFocus', { @@ -60,6 +78,12 @@ async function forceDocumentUnfocused(page: Page): Promise { await expect.poll(() => page.evaluate(() => document.hasFocus())).toBe(false) } +async function restoreDocumentFocus(page: Page): Promise { + await page.evaluate(() => { + Reflect.deleteProperty(document, 'hasFocus') + }) +} + async function typeMarker(page: Page, marker: string): Promise { await focusActiveTerminalInput(page) await page.evaluate((value) => { @@ -103,6 +127,48 @@ function clampGrid(grid: { cols: number; rows: number }): { cols: number; rows: } } +function fixtureLineGrid(text: string, marker: string): { cols: number; rows: number } | null { + const match = text.match(new RegExp(`LINE:${marker}:(\\d+)x(\\d+)`)) + return match ? { cols: Number(match[1]), rows: Number(match[2]) } : null +} + +function activityOpcodes( + frames: Awaited>, + streamId?: number +): number[] { + return frames.flatMap((frame) => + frame.direction === 'out' && + (streamId === undefined || frame.streamId === streamId) && + frame.opcode && + [7, 8, 14].includes(frame.opcode) && + !( + frame.opcode === 7 && + typeof frame.payload === 'string' && + isTerminalFocusReport(frame.payload) + ) + ? [frame.opcode] + : [] + ) +} + +function terminalStreamId( + frames: Awaited>, + terminal: string +): number { + for (const frame of frames) { + const payload = frame.payload as { streamId?: unknown; terminal?: unknown } | undefined + if ( + frame.direction === 'out' && + frame.opcode === 9 && + payload?.terminal === terminal && + typeof payload.streamId === 'number' + ) { + return payload.streamId + } + } + throw new Error(`terminal stream unavailable for ${terminal}`) +} + async function runtimeTail(page: Page, terminal: string): Promise { const result = await callLocal<{ terminal: { tail: string[] } }>(page, 'terminal.read', { terminal @@ -158,12 +224,38 @@ test('current clients keep viewport ownership with real activity @headful', asyn await waitForSink(target, 'READY:') const hostGrid = await readPaneGrid(orcaPage, target.hostTabId) await configureElectronWindow(clientA.app, 1120, 720, true) + await installTerminalWireProbe(clientA.app) await openTerminalTab(clientA.page, worktreeId, target.webTabId) + await expect + .poll(() => readTerminalWireProbe(clientA!.app), { timeout: 30_000 }) + .toEqual(expect.arrayContaining([expect.objectContaining({ direction: 'out', opcode: 9 })])) + const clientAStreamId = terminalStreamId( + await readTerminalWireProbe(clientA.app), + target.terminal + ) await focusApp(clientA) await typeMarker(clientA.page, 'A_OWNER') await waitForSink(target, 'LINE:A_OWNER:') const clientAGrid = await readPaneGrid(clientA.page, target.webTabId) await waitForGrid(target, clientAGrid) + const previewWireStart = (await readTerminalWireProbe(clientA.app)).length + const passivePreview = await orcaPage.evaluate(async (hostTabId) => { + const ptyId = window.__store?.getState().ptyIdsByTabId[hostTabId]?.[0] + if (!ptyId) { + throw new Error(`host PTY unavailable for ${hostTabId}`) + } + const connection = await window.api.terminalPreview.connect(ptyId, { scrollbackRows: 24 }) + await window.api.terminalPreview.unsubscribe(ptyId) + return { + ptyId, + snapshotCols: connection.snapshot?.cols, + snapshotRows: connection.snapshot?.rows + } + }, target.hostTabId) + const passivePreviewFrames = (await readTerminalWireProbe(clientA.app)).slice(previewWireStart) + const passivePreviewGrid = lastFixtureGrid(fixture.readSink(target.sinkPath)) + expect(activityOpcodes(passivePreviewFrames, clientAStreamId)).toEqual([]) + expect(passivePreviewGrid).toEqual(clientAGrid) clientB = await launchPairedElectronClient( await createRuntimeDesktopPairingOffer(orcaPage), @@ -176,6 +268,7 @@ test('current clients keep viewport ownership with real activity @headful', asyn await installTerminalWireProbe(clientB.app, { holdFitEvents: true }) await focusApp(clientA) await openTerminalTab(clientB.page, worktreeId, target.webTabId) + await waitForTerminalText(clientB.page, target.webTabId, 'READY:') await expect .poll(() => readTerminalWireProbe(clientB!.app)) .toEqual( @@ -189,6 +282,10 @@ test('current clients keep viewport ownership with real activity @headful', asyn }) ]) ) + const clientBStreamId = terminalStreamId( + await readTerminalWireProbe(clientB.app), + target.terminal + ) await configureElectronWindow(clientB.app, 760, 520, false) await focusApp(clientA) await forceDocumentUnfocused(clientB.page) @@ -199,76 +296,175 @@ test('current clients keep viewport ownership with real activity @headful', asyn })) expect(unfocusedVisible).toEqual({ focused: false, visibility: 'visible' }) + const passiveResizeStart = (await readTerminalWireProbe(clientB.app)).length await configureElectronWindow(clientB.app, 700, 480, false) await focusApp(clientA) await expect.poll(() => clientB!.page.evaluate(() => document.hasFocus())).toBe(false) await fitTerminalPane(clientB.page, target.webTabId) await expect - .poll(() => readTerminalWireProbe(clientB!.app), { timeout: 30_000 }) + .poll( + () => + readTerminalWireProbe(clientB!.app).then((frames) => frames.slice(passiveResizeStart)), + { + timeout: 30_000 + } + ) .toEqual(expect.arrayContaining([expect.objectContaining({ direction: 'out', opcode: 8 })])) const clientBGrid = clampGrid(await readPaneGrid(clientB.page, target.webTabId)) - await new Promise((resolve) => setTimeout(resolve, 500)) + await typeMarker(clientA.page, 'A_PASSIVE_BARRIER') + await waitForSink(target, 'LINE:A_PASSIVE_BARRIER:') const raceGrid = lastFixtureGrid(fixture.readSink(target.sinkPath)) const runtimeRaceTail = await runtimeTail(orcaPage, target.terminal) + const passiveResizeFrames = (await readTerminalWireProbe(clientB.app)).slice(passiveResizeStart) + expect(activityOpcodes(passiveResizeFrames, clientBStreamId)).not.toContain(14) + expect(raceGrid).toEqual(clientAGrid) + + await restoreDocumentFocus(clientB.page) + await focusAppWithoutResize(clientB) + const clientBInputGrid = clampGrid(await readPaneGrid(clientB.page, target.webTabId)) + expect(clientBInputGrid).not.toEqual(clientAGrid) + expect(clientBInputGrid).not.toEqual(hostGrid) + expect(clientAGrid).not.toEqual(hostGrid) + const clientBInputStart = (await readTerminalWireProbe(clientB.app)).length + expect(lastFixtureGrid(fixture.readSink(target.sinkPath))).toEqual(clientAGrid) + await typeMarker(clientB.page, 'B_PRE_FIT_INPUT') + await waitForSink(target, 'LINE:B_PRE_FIT_INPUT:') + const clientBInputFrames = (await readTerminalWireProbe(clientB.app)).slice(clientBInputStart) + expect({ + lineGrid: fixtureLineGrid(fixture.readSink(target.sinkPath), 'B_PRE_FIT_INPUT'), + opcodes: activityOpcodes(clientBInputFrames, clientBStreamId).slice(-3) + }).toEqual({ lineGrid: clientBInputGrid, opcodes: [14, 8, 7] }) + await waitForGrid(target, clientBInputGrid) await releaseTerminalFitEvents(clientB.app) await focusApp(clientA) + const clientAInputStart = (await readTerminalWireProbe(clientA.app)).length + expect(lastFixtureGrid(fixture.readSink(target.sinkPath))).toEqual(clientBInputGrid) await typeMarker(clientA.page, 'A_RECLAIM') await waitForSink(target, 'LINE:A_RECLAIM:') await waitForGrid(target, clientAGrid) + const clientAInputFrames = (await readTerminalWireProbe(clientA.app)).slice(clientAInputStart) + expect(activityOpcodes(clientAInputFrames, clientAStreamId).slice(-3)).toEqual([14, 8, 7]) + expect(fixtureLineGrid(fixture.readSink(target.sinkPath), 'A_RECLAIM')).toEqual(clientAGrid) + await expect + .poll( + () => + readTerminalWireProbe(clientA!.app).then((frames) => + frames + .slice(clientAInputStart) + .some( + (frame) => + frame.direction === 'in' && + JSON.stringify(frame.payload).includes('"mode":"desktop-fit"') + ) + ), + { timeout: 30_000 } + ) + .toBe(true) + await clientA.page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + ) + const clientASteadyInputStart = (await readTerminalWireProbe(clientA.app)).length + await typeMarker(clientA.page, 'A_STEADY_INPUT') + await waitForSink(target, 'LINE:A_STEADY_INPUT:') + const clientASteadyInputFrames = (await readTerminalWireProbe(clientA.app)).slice( + clientASteadyInputStart + ) + expect(activityOpcodes(clientASteadyInputFrames, clientAStreamId)).toEqual([7]) + expect(fixtureLineGrid(fixture.readSink(target.sinkPath), 'A_STEADY_INPUT')).toEqual( + clientAGrid + ) const claimsBeforeBackgroundResize = (await readTerminalWireProbe(clientB.app)).filter( - (frame) => frame.opcode === 14 + (frame) => frame.streamId === clientBStreamId && frame.opcode === 14 ).length + await forceDocumentUnfocused(clientB.page) + const backgroundResizeStart = (await readTerminalWireProbe(clientB.app)).length await configureElectronWindow(clientB.app, 660, 460, false) await focusApp(clientA) await expect.poll(() => clientB!.page.evaluate(() => document.hasFocus())).toBe(false) - await new Promise((resolve) => setTimeout(resolve, 500)) + await fitTerminalPane(clientB.page, target.webTabId) + await expect + .poll( + () => + readTerminalWireProbe(clientB!.app).then((frames) => frames.slice(backgroundResizeStart)), + { timeout: 30_000 } + ) + .toEqual(expect.arrayContaining([expect.objectContaining({ direction: 'out', opcode: 8 })])) + const backgroundClientBGrid = clampGrid(await readPaneGrid(clientB.page, target.webTabId)) + await typeMarker(clientA.page, 'A_BACKGROUND_BARRIER') + await waitForSink(target, 'LINE:A_BACKGROUND_BARRIER:') const finalWireTrace = await readTerminalWireProbe(clientB.app) - const claimsAfterBackgroundResize = finalWireTrace.filter((frame) => frame.opcode === 14).length + const claimsAfterBackgroundResize = finalWireTrace.filter( + (frame) => frame.streamId === clientBStreamId && frame.opcode === 14 + ).length const backgroundGrid = lastFixtureGrid(fixture.readSink(target.sinkPath)) await configureElectronWindow(electronApp, 1500, 900, true) await orcaPage.bringToFront() + expect(lastFixtureGrid(fixture.readSink(target.sinkPath))).toEqual(clientAGrid) await typeMarker(orcaPage, 'HOST_RECLAIM') await waitForSink(target, 'LINE:HOST_RECLAIM:') + expect(fixtureLineGrid(fixture.readSink(target.sinkPath), 'HOST_RECLAIM')).toEqual(hostGrid) await waitForGrid(target, hostGrid) await focusApp(clientA) + expect(lastFixtureGrid(fixture.readSink(target.sinkPath))).toEqual(hostGrid) await typeMarker(clientA.page, 'A_DETACH_OWNER') await waitForSink(target, 'LINE:A_DETACH_OWNER:') await waitForGrid(target, clientAGrid) await screenshotTopology(testInfo, orcaPage, [clientA, clientB]) + const versions = await captureVersions(orcaPage, [clientA, clientB]) await disposeTerminalWireProbe(clientB.app) await clientA.dispose() createdClients.splice(createdClients.indexOf(clientA), 1) clientA = null - await new Promise((resolve) => setTimeout(resolve, 500)) + await waitForGrid(target, backgroundClientBGrid) + const fallbackGrid = lastFixtureGrid(fixture.readSink(target.sinkPath)) + await clientB.dispose() + createdClients.splice(createdClients.indexOf(clientB), 1) + clientB = null + await waitForGrid(target, hostGrid) const detachGrid = lastFixtureGrid(fixture.readSink(target.sinkPath)) const evidence = { + backgroundClientBGrid, backgroundGrid, claimsAfterBackgroundResize, claimsBeforeBackgroundResize, + clientAInputFrames, + clientASteadyInputFrames, clientAGrid, clientBGrid, + clientBInputFrames, + clientBInputGrid, detachGrid, + fallbackGrid, hostGrid, passiveAttachGrid, + passivePreview, + passivePreviewGrid, raceGrid, runtimeRaceTailHasGrid: runtimeRaceTail.includes( `SIZE:${clientBGrid.cols}x${clientBGrid.rows}` ), unfocusedVisible, - versions: await captureVersions(orcaPage, [clientB]), - wireFrames: finalWireTrace.filter((frame) => frame.opcode === 8 || frame.opcode === 14) + versions, + wireFrames: finalWireTrace.filter( + (frame) => frame.streamId === clientBStreamId && (frame.opcode === 8 || frame.opcode === 14) + ) } console.log(`[sta5050-current] ${JSON.stringify(evidence)}`) expect(evidence).toMatchObject({ backgroundGrid: clientAGrid, claimsAfterBackgroundResize: claimsBeforeBackgroundResize, detachGrid: hostGrid, + fallbackGrid: backgroundClientBGrid, passiveAttachGrid: clientAGrid, + passivePreviewGrid: clientAGrid, raceGrid: clientAGrid, runtimeRaceTailHasGrid: false, unfocusedVisible: { focused: false, visibility: 'visible' } @@ -277,6 +473,9 @@ test('current clients keep viewport ownership with real activity @headful', asyn if (clientB) { await disposeTerminalWireProbe(clientB.app).catch(() => undefined) } + if (clientA) { + await disposeTerminalWireProbe(clientA.app).catch(() => undefined) + } for (const client of createdClients.toReversed()) { await client.dispose().catch(() => undefined) } @@ -288,6 +487,81 @@ test('current clients keep viewport ownership with real activity @headful', asyn } }) +test('host input reclaims before delayed renderer fit state @headful', async ({ + electronApp, + orcaPage +}, testInfo) => { + test.setTimeout(180_000) + const worktreeId = await orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId) + if (!worktreeId) { + throw new Error('headed host has no active worktree') + } + let target: TerminalViewportTarget | null = null + let client: PairedElectronClient | null = null + try { + client = await launchPairedElectronClient( + await createRuntimeDesktopPairingOffer(orcaPage), + testInfo, + 'STA-5050 remote owner' + ) + await waitForWorktree(client, worktreeId) + target = await createViewportTerminal(client.page, client.environmentId, worktreeId, fixture) + await configureElectronWindow(electronApp, 1500, 900, true) + await openTerminalTab(orcaPage, worktreeId, target.hostTabId) + await waitForSink(target, 'READY:') + const hostGrid = await readPaneGrid(orcaPage, target.hostTabId) + await configureElectronWindow(client.app, 700, 480, false) + await installTerminalWireProbe(electronApp, { + dropHostViewportClaims: true, + holdHostFitEvents: true + }) + await configureElectronWindow(electronApp, 1500, 900, true) + await orcaPage.bringToFront() + await openTerminalTab(client.page, worktreeId, target.webTabId) + await waitForTerminalText(client.page, target.webTabId, 'READY:') + await focusAppWithoutResize(client) + await fitTerminalPane(client.page, target.webTabId) + const clientGrid = clampGrid(await readPaneGrid(client.page, target.webTabId)) + await waitForGrid(target, clientGrid) + await typeMarker(client.page, 'REMOTE_OWNER') + await waitForSink(target, 'LINE:REMOTE_OWNER:') + expect(fixtureLineGrid(fixture.readSink(target.sinkPath), 'REMOTE_OWNER')).toEqual(clientGrid) + expect(clientGrid).not.toEqual(hostGrid) + await expect + .poll(() => readTerminalWireProbe(electronApp), { timeout: 30_000 }) + .toEqual(expect.arrayContaining([expect.objectContaining({ direction: 'in', held: true })])) + + await forceDocumentUnfocused(orcaPage) + const hostLayoutStart = (await readTerminalWireProbe(electronApp)).length + await fitTerminalPane(orcaPage, target.hostTabId) + await typeMarker(client.page, 'REMOTE_HOST_LAYOUT_BARRIER') + await waitForSink(target, 'LINE:REMOTE_HOST_LAYOUT_BARRIER:') + expect( + fixtureLineGrid(fixture.readSink(target.sinkPath), 'REMOTE_HOST_LAYOUT_BARRIER') + ).toEqual(clientGrid) + const hostLayoutFrames = (await readTerminalWireProbe(electronApp)).slice(hostLayoutStart) + expect(activityOpcodes(hostLayoutFrames)).not.toContain(14) + + await restoreDocumentFocus(orcaPage) + await configureElectronWindow(electronApp, 1500, 900, true) + await orcaPage.bringToFront() + await typeMarker(orcaPage, 'HOST_PRE_FIT_INPUT') + await waitForSink(target, 'LINE:HOST_PRE_FIT_INPUT:') + expect(fixtureLineGrid(fixture.readSink(target.sinkPath), 'HOST_PRE_FIT_INPUT')).toEqual( + hostGrid + ) + await waitForGrid(target, hostGrid) + } finally { + await disposeTerminalWireProbe(electronApp).catch(() => undefined) + await client?.dispose().catch(() => undefined) + if (target) { + await callLocal(orcaPage, 'terminal.closeTab', { terminal: target.terminal }).catch( + () => undefined + ) + } + } +}) + test('legacy clients retain resize-as-activity compatibility @headful', async ({ orcaPage }, testInfo) => { @@ -323,15 +597,41 @@ test('legacy clients retain resize-as-activity compatibility @headful', async ({ await installTerminalWireProbe(legacy.app, { legacyViewportClient: true }) await focusApp(active) await openTerminalTab(legacy.page, worktreeId, target.webTabId) + await waitForGrid(target, clampGrid(await readPaneGrid(legacy.page, target.webTabId))) + const legacyStreamId = terminalStreamId( + await readTerminalWireProbe(legacy.app), + target.terminal + ) + const legacyResizeStart = (await readTerminalWireProbe(legacy.app)).length + await configureElectronWindow(legacy.app, 660, 460, false) + await fitTerminalPane(legacy.page, target.webTabId) const legacyGrid = clampGrid(await readPaneGrid(legacy.page, target.webTabId)) + await expect + .poll( + () => readTerminalWireProbe(legacy!.app).then((frames) => frames.slice(legacyResizeStart)), + { timeout: 30_000 } + ) + .toEqual(expect.arrayContaining([expect.objectContaining({ direction: 'out', opcode: 8 })])) await waitForGrid(target, legacyGrid) const trace = await readTerminalWireProbe(legacy.app) - const outgoingFrames = trace.filter((frame) => frame.direction === 'out') + const outgoingFrames = trace.filter((frame) => { + if (frame.direction !== 'out') { + return false + } + const payload = frame.payload as { terminal?: unknown } | undefined + return ( + frame.streamId === legacyStreamId || + (frame.opcode === 9 && payload?.terminal === target!.terminal) + ) + }) console.log( `[sta5050-legacy] ${JSON.stringify({ activeGrid, legacyGrid, trace: outgoingFrames })}` ) expect(lastFixtureGrid(fixture.readSink(target.sinkPath))).toEqual(legacyGrid) expect(outgoingFrames.some((frame) => frame.opcode === 14)).toBe(false) + expect(outgoingFrames.some((frame) => frame.originalOpcode === 14 && frame.opcode === 8)).toBe( + true + ) expect(JSON.stringify(outgoingFrames)).not.toContain('desktopViewportClaims') } finally { if (legacy) {