From 2209d55b9db8da1c56f3c8f0b22e83c9e2941473 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:11:57 -0700 Subject: [PATCH] fix(terminal): give the window and main's copy the daemon's ConPTY resize rule (#33584) * fix(terminal): give the window and main's copy the daemon's ConPTY resize rule Every PTY a Windows machine owns is ConPTY, WSL shells included, and ConPTY repaints its own grid after a resize. The window applied xterm's ConPTY model only to native (non-WSL) panes, and main's copy for reveals and phones never did, so growing either pulled history into ConPTY's grid. Decide windowsPty from the PTY's owner backend everywhere, keeping the native-only cursor, CJK and keyboard workarounds behind isLocalNativeWindowsConpty. * fix(terminal): keep main's copy of an SSH relay PTY off the ConPTY model before its record exists Main's copy is built on the first byte, and a restored or adopted relay PTY can stream before its record carries a connection id. --- src/main/daemon/session-output-pipeline.ts | 9 +---- .../headless-conpty-resize-model.test.ts | 40 +++++++++++++++++++ ...adless-hydration-ownership-test-fixture.ts | 6 ++- .../output/headless-terminal-state.ts | 19 +++++++++ .../windows-pty-compatibility.test.ts | 9 +++-- .../pane-manager/windows-pty-compatibility.ts | 27 ++++++++++--- src/shared/xterm-conpty-options.ts | 16 ++++++++ 7 files changed, 109 insertions(+), 17 deletions(-) create mode 100644 src/main/runtime/headless-conpty-resize-model.test.ts diff --git a/src/main/daemon/session-output-pipeline.ts b/src/main/daemon/session-output-pipeline.ts index 1df096e1b09..fd4291b4df7 100644 --- a/src/main/daemon/session-output-pipeline.ts +++ b/src/main/daemon/session-output-pipeline.ts @@ -3,7 +3,7 @@ import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' import type { SubprocessHandle } from './session-subprocess-handle' import { release } from 'node:os' import type { PtyOwnerBackend } from '../../shared/pty-owner-backend' -import { buildXtermConptyOptions, parseWindowsBuildNumber } from '../../shared/xterm-conpty-options' +import { windowsPtyForOwnerBackend } from '../../shared/xterm-conpty-options' /** Couple barrier emissions and snapshot ownership so neither can lag the output plane. */ export function createSessionOutputPipeline(opts: { @@ -24,12 +24,7 @@ export function createSessionOutputPipeline(opts: { scrollback: opts.scrollback, wslDistro: opts.wslDistro, historySeedChunks: opts.historySeedChunks, - // Every Windows daemon PTY is ConPTY, WSL shells included; ConPTY repaints its own grid after a - // resize, so the emulator must not pull scrollback into it (xterm's windowsPty resize model). - windowsPty: - opts.ownerBackend && opts.ownerBackend !== 'posix-pty' - ? buildXtermConptyOptions(parseWindowsBuildNumber(release())) - : undefined, + windowsPty: windowsPtyForOwnerBackend(opts.ownerBackend, release()), getTerminalOwner: () => barrier?.getOwner() }) const recoveryBarrier = new TerminalShellRecoveryBarrier({ diff --git a/src/main/runtime/headless-conpty-resize-model.test.ts b/src/main/runtime/headless-conpty-resize-model.test.ts new file mode 100644 index 00000000000..d06f40fe92b --- /dev/null +++ b/src/main/runtime/headless-conpty-resize-model.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { withPlatform } from './orca-runtime-test-fixtures.spec' +import { createHydrationRuntime, PTY_ID } from './headless-hydration-ownership-test-fixture' + +const HISTORY = Array.from({ length: 60 }, (_, i) => `H${i}`).join('\r\n') + +async function scrollbackAfterShrinkAndGrow( + platform: NodeJS.Platform, + ptyId = PTY_ID +): Promise { + return withPlatform(platform, async () => { + const runtime = createHydrationRuntime() + runtime.onPtyData(ptyId, HISTORY, 1) + const model = runtime.modelFor(ptyId) + await model.writeChain + const before = model.emulator.getSnapshot().scrollbackLines + model.emulator.resize(80, 2) + model.emulator.resize(80, 24) + return model.emulator.getSnapshot().scrollbackLines - before + }) +} + +// awin verify-14: ConPTY repaints its own grid after a resize, so rows pulled back from history +// into main's copy were overwritten before a reveal repainted the window from it. +describe("main's copy of a local pane", () => { + it("grows rows the ConPTY way on Windows, keeping history out of ConPTY's grid", async () => { + expect(await scrollbackAfterShrinkAndGrow('win32')).toBeGreaterThan(0) + }) + + it('keeps the Unix grow model elsewhere', async () => { + expect(await scrollbackAfterShrinkAndGrow('darwin')).toBeLessThanOrEqual(0) + }) + + // A restored or adopted relay PTY can stream before its record exists; its id still names SSH. + it('keeps the Unix model for an SSH relay PTY with no record yet', async () => { + expect( + await scrollbackAfterShrinkAndGrow('win32', 'ssh:target-1@@relay-pty-9') + ).toBeLessThanOrEqual(0) + }) +}) diff --git a/src/main/runtime/headless-hydration-ownership-test-fixture.ts b/src/main/runtime/headless-hydration-ownership-test-fixture.ts index 5fb3314419c..c514912ee1d 100644 --- a/src/main/runtime/headless-hydration-ownership-test-fixture.ts +++ b/src/main/runtime/headless-hydration-ownership-test-fixture.ts @@ -13,7 +13,11 @@ export const RETIRED_SNAPSHOT = { export class HydrationRuntime extends OrcaRuntimeService { model() { - const state = this.headlessTerminals.get(PTY_ID) + return this.modelFor(PTY_ID) + } + + modelFor(ptyId: string) { + const state = this.headlessTerminals.get(ptyId) if (!state) { throw new Error('Expected headless model') } diff --git a/src/main/runtime/terminal/output/headless-terminal-state.ts b/src/main/runtime/terminal/output/headless-terminal-state.ts index 244aa2c3908..efc65078908 100644 --- a/src/main/runtime/terminal/output/headless-terminal-state.ts +++ b/src/main/runtime/terminal/output/headless-terminal-state.ts @@ -6,8 +6,26 @@ import { isNativeWindowsConptyPty } from '../../terminal-model-query-authority' import { getTerminalViewAttributes } from '../../terminal-view-attribute-store' import { PtyShellOwnershipMirror } from '../../pty-shell-ownership-mirror' import { PROCESS_BOUNDARY_GROUND } from '../../../../shared/terminal-mode-reset-profiles' +import { release } from 'node:os' +import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../../../../shared/pty-owner-backend' +import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { windowsPtyForOwnerBackend } from '../../../../shared/xterm-conpty-options' export abstract class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer { + /** SSH and paired-runtime panes run on their host's PTY; every PTY this Windows process owns is ConPTY. */ + private ownerBackendForHeadlessPty(ptyId: string): PtyOwnerBackend { + // Why the id too: main's copy starts on the first byte, before a restored relay PTY has a record. + if ( + this.ptysById.get(ptyId)?.connectionId || + parseAppSshPtyId(ptyId) !== null || + isRemoteRuntimePtyId(ptyId) + ) { + return 'posix-pty' + } + return resolvePtyOwnerBackend({ platform: process.platform, shellPath: undefined }) + } + /** Create runtime emulators with query forwarding and ConPTY DA1 override for seed, hydration and live bytes; daemon emulators use another factory. */ protected createPtyHeadlessTerminalState( ptyId: string, @@ -24,6 +42,7 @@ export abstract class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends Orca wslDistro: this.ptysById.get(ptyId)?.connectionId ? undefined : (this.wslDistroByPtyId.get(ptyId) ?? this.ptysById.get(ptyId)?.wslDistro ?? undefined), + windowsPty: windowsPtyForOwnerBackend(this.ownerBackendForHeadlessPty(ptyId), release()), // Use provider input for query replies to retain shell-ready/SSH routing without interactive echo metering. onQueryReply: (reply) => { // Queued links can outlive disposal; only the current state may reply to a reused PTY id. diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts index c5ecc8e6d91..151ba8d8695 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts @@ -88,7 +88,8 @@ describe('buildWindowsPtyCompatibilityOptions', () => { ).toEqual({}) }) - it('skips compatibility options for WSL cwd terminals', () => { + // ConPTY hosts local WSL shells too, so their emulator needs its resize model (awin verify-14). + it('uses the ConPTY model for local WSL cwd terminals', () => { for (const cwd of [ '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', '\\\\wsl$\\Debian\\home\\me\\repo', @@ -104,11 +105,11 @@ describe('buildWindowsPtyCompatibilityOptions', () => { shellOverride: null, executionHostId: 'local' }) - ).toEqual({}) + ).toEqual({ windowsPty: { backend: 'conpty', buildNumber: 26100 } }) } }) - it('skips compatibility options when the shell override launches WSL', () => { + it('uses the ConPTY model when the shell override launches WSL', () => { expect( buildWindowsPtyCompatibilityOptions({ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', @@ -118,7 +119,7 @@ describe('buildWindowsPtyCompatibilityOptions', () => { shellOverride: 'C:\\Windows\\System32\\wsl.exe', executionHostId: 'local' }) - ).toEqual({}) + ).toEqual({ windowsPty: { backend: 'conpty', buildNumber: 26100 } }) }) it('returns no options outside Windows', () => { diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts index 2223fd79c6d..af7d99884af 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts @@ -3,8 +3,10 @@ import { isWslUncPath } from '../../../../shared/wsl-paths' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' import { buildXtermConptyOptions, - parseWindowsBuildNumber + parseWindowsBuildNumber, + windowsPtyForOwnerBackend } from '../../../../shared/xterm-conpty-options' +import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../../../../shared/pty-owner-backend' export type WindowsPtyCompatibilityContext = { userAgent?: string @@ -26,14 +28,29 @@ function isWslShellOverride(shellOverride: string | null | undefined): boolean { return /(?:^|[/\\])wsl(?:\.exe)?$/i.test(shellOverride ?? '') } -/** Apply ConPTY options only to genuine local Windows panes. executionHostId excludes serve/runtime panes that look local to raw connectionId/cwd heuristics. */ +/** + * xterm's ConPTY model for every pane this Windows machine's daemon owns, WSL shells included: the + * same owner-backend rule as the daemon's and main's emulators. Native-only workarounds (cursor, + * CJK, keyboard) stay behind `isLocalNativeWindowsConpty`. executionHostId excludes serve/runtime + * panes that look local to raw connectionId/cwd heuristics. + */ export function buildWindowsPtyCompatibilityOptions( context: WindowsPtyCompatibilityContext & { executionHostId: ExecutionHostId } ): Partial { - if (!isLocalNativeWindowsConpty(context)) { - return {} + const windowsPty = windowsPtyForOwnerBackend(localPaneOwnerBackend(context), context.osRelease) + return windowsPty ? { windowsPty } : {} +} + +function localPaneOwnerBackend( + context: WindowsPtyCompatibilityContext & { executionHostId: ExecutionHostId } +): PtyOwnerBackend | undefined { + if (context.executionHostId !== LOCAL_EXECUTION_HOST_ID || context.connectionId !== null) { + return undefined } - return buildLocalConptyTerminalOptions(context.osRelease) + return resolvePtyOwnerBackend({ + platform: isWindowsUserAgent(context.userAgent) ? 'win32' : 'linux', + shellPath: context.shellOverride ?? undefined + }) } /** Reuse ConPTY options after dashboard preview has already resolved local native-Windows ownership. */ diff --git a/src/shared/xterm-conpty-options.ts b/src/shared/xterm-conpty-options.ts index a797ba40da3..e59a5a03e82 100644 --- a/src/shared/xterm-conpty-options.ts +++ b/src/shared/xterm-conpty-options.ts @@ -1,3 +1,5 @@ +import type { PtyOwnerBackend } from './pty-owner-backend' + /** xterm `windowsPty` for a ConPTY-backed terminal, shared by the window and the daemon's emulator. */ export type XtermConptyOptions = { backend: 'conpty'; buildNumber?: number } @@ -17,3 +19,17 @@ export function buildXtermConptyOptions(buildNumber: number | undefined): XtermC } return { backend: 'conpty', buildNumber } } + +/** + * ConPTY repaints its own grid after a resize, so every emulator of a ConPTY pane (the daemon's, + * main's copy and the window's) must grow rows without pulling scrollback back into it. + * Every PTY a Windows host owns is ConPTY, WSL shells included. + */ +export function windowsPtyForOwnerBackend( + ownerBackend: PtyOwnerBackend | undefined, + osRelease: string | null | undefined +): XtermConptyOptions | undefined { + return ownerBackend === undefined || ownerBackend === 'posix-pty' + ? undefined + : buildXtermConptyOptions(parseWindowsBuildNumber(osRelease)) +}