fix(terminal): keep restored history when ConPTY repaints after a resize (#33577)

* fix(terminal): keep restored history when ConPTY repaints after a resize

The Windows daemon's emulator grew rows the way a Unix terminal does,
pulling scrollback back into the screen. ConPTY then repaints the grid
it owns from the top and overwrote those rows, so a relaunched window's
first fit lost about a screen of restored history and printed the new
shell's prompt onto the restored prompt's row. Give ConPTY-backed daemon
sessions (WSL shells included) xterm's windowsPty resize model, as the
window already does for native Windows panes.

* test(terminal): model ConPTY's repaint at the height it was resized to

ConPTY's screen holds only what the shell wrote at its current height, so a
shrink drops the rows above the cursor and each resize repaints that screen.
Assert every restored and shell row appears once, in order, across shrink,
shrink-then-grow and grow-past-original heights.

* test(terminal): show the Unix grow model loses restored history under ConPTY
This commit is contained in:
Neil
2026-10-25 10:05:29 -07:00
committed by GitHub
parent 6808f4be23
commit 55d12e1308
7 changed files with 178 additions and 24 deletions
+5 -3
View File
@@ -1,5 +1,5 @@
import './xterm-env-polyfill'
import { Terminal } from '@xterm/headless'
import { Terminal, type ITerminalOptions } from '@xterm/headless'
import { SerializeAddon } from '@xterm/addon-serialize'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider'
@@ -31,7 +31,8 @@ import {
type HeadlessTerminalRow
} from './headless-terminal-rows'
export type HeadlessEmulatorOptions = {
/** windowsPty: ConPTY repaints its own grid after a resize, so growing rows must not pull scrollback. */
export type HeadlessEmulatorOptions = Pick<ITerminalOptions, 'windowsPty'> & {
cols: number
rows: number
scrollback?: number
@@ -90,7 +91,8 @@ export class HeadlessEmulator {
allowProposedApi: true,
logLevel: 'off',
// Match renderer kitty flag parsing and query responses.
vtExtensions: { kittyKeyboard: true }
vtExtensions: { kittyKeyboard: true },
windowsPty: opts.windowsPty ?? {}
})
this.serializer = new SerializeAddon()
@@ -0,0 +1,134 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Terminal } from '@xterm/headless'
import type { PtyOwnerBackend } from '../../shared/pty-owner-backend'
import { buildFreshShellViewportBlankingSequence } from '../../shared/terminal-fresh-shell-viewport'
import { PROCESS_BOUNDARY_GROUND } from '../../shared/terminal-mode-reset-profiles'
import { Session } from './session'
const COLS = 119
const ROWS = 58
const PROMPT = 'orca@awin:~/projects/p8/deep$ '
const RESTORED = `${Array.from({ length: 6 }, (_, i) => `${PROMPT}echo L${i}\r\nL${i}`).join('\r\n')}\r\n${PROMPT}echo PRESLEEP\r\nPRESLEEP\r\n${PROMPT}`
// The fresh shell's rows, which are all ConPTY knows about.
const SHELL_ROWS = [PROMPT, `${PROMPT}echo WAKE`, 'WAKE', PROMPT]
function createMockSubprocess() {
let onData: ((data: string) => void) | null = null
return {
pid: 12345,
getForegroundProcess: (): string | null => null,
write(_data: string) {},
resize(_cols: number, _rows: number) {},
kill() {},
terminateOwnedTree: () => 'unavailable' as const,
forceKill() {},
signal(_sig: string) {},
onData(cb: (data: string) => void) {
onData = cb
},
onExit(_cb: (code: number) => void) {},
dispose() {},
simulateData(data: string) {
onData?.(data)
}
}
}
let session: Session | null = null
afterEach(() => {
session?.dispose()
session = null
})
async function renderRows(ansi: string): Promise<string[]> {
const terminal = new Terminal({
cols: COLS,
rows: ROWS,
scrollback: 5000,
allowProposedApi: true
})
await new Promise<void>((resolve) => terminal.write(ansi, resolve))
const rows: string[] = []
for (let i = 0; i < terminal.buffer.active.length; i++) {
rows.push(terminal.buffer.active.getLine(i)?.translateToString(true) ?? '')
}
terminal.dispose()
return rows
}
/**
* ConPTY's own screen holds only what the shell wrote, at ConPTY's current height: shrinking keeps
* the cursor row and drops rows above it, and every resize repaints that screen from the top.
*/
class FakeConpty {
private lines = [...SHELL_ROWS]
private cursor = SHELL_ROWS.length - 1
resize(rows: number): string {
const dropped = Math.max(0, this.cursor - rows + 1)
this.lines = this.lines.slice(dropped, dropped + rows)
this.cursor -= dropped
const screen = Array.from({ length: rows }, (_, i) => `${this.lines[i] ?? ''}\x1b[K`)
return `\x1b[H${screen.join('\r\n')}\x1b[${this.cursor + 1};${PROMPT.length + 1}H`
}
}
async function checkpointAfterResizes(
ownerBackend: PtyOwnerBackend,
heights: readonly number[]
): Promise<string[]> {
const subprocess = createMockSubprocess()
session = new Session({
sessionId: 'conpty-resize',
cols: COLS,
rows: ROWS,
subprocess,
shellReadySupported: false,
ownerBackend,
historySeedChunks: [
RESTORED,
PROCESS_BOUNDARY_GROUND,
buildFreshShellViewportBlankingSequence(ROWS)
]
})
subprocess.simulateData(SHELL_ROWS.join('\r\n'))
const conpty = new FakeConpty()
for (const rows of heights) {
session.resize(COLS, rows)
subprocess.simulateData(conpty.resize(rows))
}
await session.settleShellOwnershipConfirmation()
const snapshot = session.takePendingOutput(true, { teardownSnapshot: true })?.snapshot
expect(snapshot).toBeTruthy()
return renderRows(`${snapshot!.rehydrateSequences}${snapshot!.snapshotAnsi}`)
}
const RESTORED_ROWS = [
...Array.from({ length: 6 }, (_, i) => [`${PROMPT}echo L${i}`, `L${i}`]).flat(),
`${PROMPT}echo PRESLEEP`,
'PRESLEEP',
PROMPT
].map((row) => row.trimEnd())
describe('daemon checkpoint after a relaunch resize under ConPTY', () => {
it.each([[[2, ROWS]], [[10, ROWS]], [[2, 30, ROWS]], [[ROWS + 12, ROWS]]])(
'keeps every restored and shell row once, in order, through heights %j',
async (heights) => {
const rows = (await checkpointAfterResizes('windows-wsl', heights)).map((row) =>
row.trimEnd()
)
const written = rows.filter((row) => row !== '')
expect(written).toEqual([...RESTORED_ROWS, ...SHELL_ROWS.map((row) => row.trimEnd())])
// The fresh-shell padding still separates the restored prompt from the new shell.
const restoredPrompt = rows.indexOf('PRESLEEP') + 1
expect(rows[restoredPrompt + 1]).toBe('')
}
)
// Control: the Unix grow model pulls history into ConPTY's grid, and its repaint overwrites it.
it('loses restored history without the ConPTY model', async () => {
const rows = await checkpointAfterResizes('posix-pty', [2, ROWS])
expect(rows.filter((row) => /^L\d$/.test(row.trimEnd())).length).toBeLessThan(6)
})
})
@@ -1,6 +1,9 @@
import { SessionOutputPlane } from './session-output-plane'
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'
/** Couple barrier emissions and snapshot ownership so neither can lag the output plane. */
export function createSessionOutputPipeline(opts: {
@@ -9,6 +12,7 @@ export function createSessionOutputPipeline(opts: {
scrollback?: number | undefined
wslDistro?: string | undefined
historySeedChunks?: readonly string[] | undefined
ownerBackend?: PtyOwnerBackend | undefined
subprocess: SubprocessHandle
isAlive: () => boolean
}): { output: SessionOutputPlane; recoveryBarrier: TerminalShellRecoveryBarrier } {
@@ -20,6 +24,12 @@ 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,
getTerminalOwner: () => barrier?.getOwner()
})
const recoveryBarrier = new TerminalShellRecoveryBarrier({
+4 -1
View File
@@ -1,4 +1,5 @@
import { resolveProcessExitCause, type TerminalExitCause } from '../../shared/terminal-exit-cause'
import type { XtermConptyOptions } from '../../shared/xterm-conpty-options'
import { HeadlessEmulator } from './headless-emulator'
import { StartupDeviceAttributesQueryFilter } from './startup-device-attributes-responder'
import { normalizePtySize } from './daemon-pty-size'
@@ -23,6 +24,7 @@ export type SessionOutputPlaneOptions = {
scrollback?: number | undefined
wslDistro?: string | undefined
historySeedChunks?: readonly string[] | undefined
windowsPty?: XtermConptyOptions | undefined
/** Read ownership after the barrier scans bytes, keeping snapshot metadata aligned with the emulator. */
getTerminalOwner?: (() => TerminalOwner | undefined) | undefined
}
@@ -46,7 +48,8 @@ export class SessionOutputPlane {
cols: size.cols,
rows: size.rows,
scrollback: opts.scrollback,
wslDistro: opts.wslDistro
wslDistro: opts.wslDistro,
...(opts.windowsPty ? { windowsPty: opts.windowsPty } : {})
// Keep the emulator silent; the shell-ready barrier separately owns startup DA1 replies.
})
// Seed before subscribing to synchronous shell output; stop at any failed chunk to avoid a torn stream.
+1
View File
@@ -55,6 +55,7 @@ export class Session {
scrollback: opts.scrollback,
wslDistro: opts.wslDistro,
historySeedChunks: opts.historySeedChunks,
ownerBackend: opts.ownerBackend,
subprocess: this.subprocess,
isAlive: () => !this._disposed && this._state !== 'exited'
})
@@ -1,6 +1,10 @@
import type { ITerminalOptions } from '@xterm/xterm'
import { isWslUncPath } from '../../../../shared/wsl-paths'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
import {
buildXtermConptyOptions,
parseWindowsBuildNumber
} from '../../../../shared/xterm-conpty-options'
export type WindowsPtyCompatibilityContext = {
userAgent?: string
@@ -22,25 +26,6 @@ function isWslShellOverride(shellOverride: string | null | undefined): boolean {
return /(?:^|[/\\])wsl(?:\.exe)?$/i.test(shellOverride ?? '')
}
function parseWindowsBuildNumber(osRelease: string | null | undefined): number | undefined {
const build = osRelease?.split('.')[2]
if (!build) {
return undefined
}
const parsed = Number.parseInt(build, 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
}
function buildXtermWindowsPtyOptions(
buildNumber: number | undefined
): NonNullable<ITerminalOptions['windowsPty']> {
// Old ConPTY lacks reliable wrap markers; low build numbers make xterm misclassify full-width rows as wrapped.
if (buildNumber === undefined || buildNumber < 21376) {
return { backend: 'conpty' }
}
return { backend: 'conpty', buildNumber }
}
/** Apply ConPTY options only to genuine local Windows panes. executionHostId excludes serve/runtime panes that look local to raw connectionId/cwd heuristics. */
export function buildWindowsPtyCompatibilityOptions(
context: WindowsPtyCompatibilityContext & { executionHostId: ExecutionHostId }
@@ -55,7 +40,7 @@ export function buildWindowsPtyCompatibilityOptions(
export function buildLocalConptyTerminalOptions(
osRelease: string | null | undefined
): Partial<ITerminalOptions> {
return { windowsPty: buildXtermWindowsPtyOptions(parseWindowsBuildNumber(osRelease)) }
return { windowsPty: buildXtermConptyOptions(parseWindowsBuildNumber(osRelease)) }
}
/** Match main’s effectiveShellOverride/isNativeWindowsLocalPtySpawn: tab override wins, else global Windows setting, so global WSL classifies identically. */
+19
View File
@@ -0,0 +1,19 @@
/** xterm `windowsPty` for a ConPTY-backed terminal, shared by the window and the daemon's emulator. */
export type XtermConptyOptions = { backend: 'conpty'; buildNumber?: number }
export function parseWindowsBuildNumber(osRelease: string | null | undefined): number | undefined {
const build = osRelease?.split('.')[2]
if (!build) {
return undefined
}
const parsed = Number.parseInt(build, 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
}
export function buildXtermConptyOptions(buildNumber: number | undefined): XtermConptyOptions {
// Old ConPTY lacks reliable wrap markers; low build numbers make xterm misclassify full-width rows as wrapped.
if (buildNumber === undefined || buildNumber < 21376) {
return { backend: 'conpty' }
}
return { backend: 'conpty', buildNumber }
}