fix(pty): contain node-pty's deferred Windows resize after pty exit
node-pty's WindowsTerminal queues resize calls that arrive before the ConPTY control socket's first data flush (_deferNoArgs). If the pty exits before the flush, the deferred resize throws 'Cannot resize a pty that has already exited' inside the socket's 'data' handler — uncatchable by any caller and fatal to the host process. The Windows CI lane caught exactly this as an unhandled error while all 1233 tests passed. Park pre-first-output resizes in our own gate (armed at every spawn site) and replay them via setImmediate once output has flushed the deferred list — by then node-pty executes resizes synchronously, so the exited throw lands in tryResizePty's catch instead of the host. POSIX terminals resize synchronously (no deferral), so the gate is armed on Windows only and the other platforms keep their exact behavior. Also route AgentPtyRegistry.resize through tryResizePty: it previously called pty.resize bare, missing the just-exited throw guard the tool and attach-socket paths already had.
This commit is contained in:
+58
-2
@@ -40,12 +40,58 @@ export function clampDims(cols: number, rows: number): { cols: number; rows: num
|
||||
return { cols: clamp(cols), rows: clamp(rows) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-pty Windows resize-gate state (see {@link armPtyResizeGate}).
|
||||
*/
|
||||
interface ResizeGateState {
|
||||
/** Whether the pty has produced any output yet (ConPTY flush completed). */
|
||||
sawData: boolean
|
||||
/** Latest dims requested before the first output; replayed once it arrives. */
|
||||
pending?: { cols: number; rows: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* node-pty's Windows terminal queues resize calls that arrive before the
|
||||
* ConPTY control socket's first data flush (`_deferNoArgs` in
|
||||
* windowsTerminal.js). If the pty exits before the queue flushes, the
|
||||
* deferred resize throws inside the socket's 'data' handler — uncatchable
|
||||
* by any caller and fatal to the host process. POSIX terminals have no such
|
||||
* queue (resize is synchronous), so the gate is armed on Windows only:
|
||||
* {@link tryResizePty} parks dims requested before the first output and
|
||||
* replays them after the flush, when node-pty executes resizes
|
||||
* synchronously (and the throw for an exited pty is catchable).
|
||||
*/
|
||||
const ptyResizeGates = new WeakMap<object, ResizeGateState>()
|
||||
|
||||
/**
|
||||
* Arm the Windows pre-ready resize gate for one freshly spawned pty.
|
||||
* No-op on POSIX and for injected ptys without `onData`.
|
||||
*/
|
||||
export function armPtyResizeGate(pty: IPty): void {
|
||||
if (process.platform !== 'win32') return
|
||||
if (typeof pty.onData !== 'function' || ptyResizeGates.has(pty)) return
|
||||
const state: ResizeGateState = { sawData: false }
|
||||
ptyResizeGates.set(pty, state)
|
||||
pty.onData(() => {
|
||||
if (state.sawData) return
|
||||
state.sawData = true
|
||||
const dims = state.pending
|
||||
state.pending = undefined
|
||||
if (dims === undefined) return
|
||||
// node-pty's deferred flush shares this very 'data' dispatch; hop past
|
||||
// it so the replayed resize executes synchronously (catchable) instead
|
||||
// of being queued into the same deferred list we are draining.
|
||||
setImmediate(() => { tryResizePty(pty, dims.cols, dims.rows) })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort resize for WebSocket-driven terminal views. Layout animation
|
||||
* can briefly produce unusable dimensions, and node-pty can reject a resize
|
||||
* after the socket setup's outer try/catch has returned. Ignore that one
|
||||
* frame so the host stays alive and a later valid measurement can retry.
|
||||
* Returns whether node-pty accepted the resize.
|
||||
* Returns whether node-pty accepted the resize (or parked it for replay on
|
||||
* the first output — the Windows pre-ready window).
|
||||
*/
|
||||
export function tryResizePty(
|
||||
pty: Pick<IPty, 'resize'>,
|
||||
@@ -54,6 +100,11 @@ export function tryResizePty(
|
||||
): boolean {
|
||||
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return false
|
||||
const dims = clampDims(cols, rows)
|
||||
const gate = ptyResizeGates.get(pty)
|
||||
if (gate !== undefined && !gate.sawData) {
|
||||
gate.pending = dims
|
||||
return true
|
||||
}
|
||||
try {
|
||||
pty.resize(dims.cols, dims.rows)
|
||||
return true
|
||||
@@ -249,6 +300,7 @@ export class AgentPtyRegistry {
|
||||
cwd,
|
||||
env: { ...process.env },
|
||||
})
|
||||
armPtyResizeGate(pty)
|
||||
const handle: AgentTerminalHandle = {
|
||||
uuid,
|
||||
sessionId,
|
||||
@@ -376,7 +428,11 @@ export class AgentPtyRegistry {
|
||||
resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } {
|
||||
const handle = this.expect(uuid)
|
||||
const dims = clampDims(cols, rows)
|
||||
if (!handle.exited) handle.pty.resize(dims.cols, dims.rows)
|
||||
// Through tryResizePty: node-pty throws on a just-exited pty (the exit
|
||||
// event may not have flipped `handle.exited` yet), and on Windows a
|
||||
// pre-ready resize must go through the gate instead of node-pty's own
|
||||
// deferred queue (which flushes uncatchably inside its socket handler).
|
||||
if (!handle.exited) tryResizePty(handle.pty, dims.cols, dims.rows)
|
||||
return dims
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -42,7 +42,7 @@ import { launchExternal } from './open-external.ts'
|
||||
import * as git from './git.ts'
|
||||
import { SettingsConflictError } from '@deepseek-ai/dsh-settings'
|
||||
import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.ts'
|
||||
import { AgentPtyRegistry, tryResizePty, type AgentTerminalHandle } from './agent-pty.ts'
|
||||
import { AgentPtyRegistry, armPtyResizeGate, tryResizePty, type AgentTerminalHandle } from './agent-pty.ts'
|
||||
import {
|
||||
DSH_NODE_PTY_RANGE,
|
||||
depsStatus,
|
||||
@@ -1209,6 +1209,9 @@ async function attachTerminal(
|
||||
// terminals opened from now on (existing pty handles keep their shell).
|
||||
const overrides = shellOverridesOf(getSettings)
|
||||
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24, overrides.shell, overrides.shellArgs)
|
||||
// Windows pre-ready gate for the resize frames this socket may deliver
|
||||
// (see armPtyResizeGate; inert on POSIX).
|
||||
armPtyResizeGate(handle.pty)
|
||||
// Replay the transcript, then follow live output.
|
||||
if (handle.transcript !== '') ws.send(handle.transcript)
|
||||
const onData = (data: string): void => {
|
||||
|
||||
Reference in New Issue
Block a user