diff --git a/src/agent-pty.ts b/src/agent-pty.ts index e7a0e72..561bfc2 100644 --- a/src/agent-pty.ts +++ b/src/agent-pty.ts @@ -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() + +/** + * 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, @@ -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 } diff --git a/src/index.ts b/src/index.ts index e87ff4b..85c53d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 => {