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.
This commit is contained in:
Neil
2026-10-25 17:11:57 -07:00
committed by GitHub
parent a5f7da4928
commit 2209d55b9d
7 changed files with 109 additions and 17 deletions
+2 -7
View File
@@ -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({
@@ -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<number> {
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)
})
})
@@ -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')
}
@@ -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.
@@ -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', () => {
@@ -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<ITerminalOptions> {
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. */
+16
View File
@@ -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))
}