fix(subprocess): back Windows terminals with node-pty's bundled console host
The console host Windows provides, on the images the master standby pool runs (Server 2022, Windows 10 22H2), replays a prompt line that scrolled out of the viewport as `\ndsh>\x1b[1C`: the trailing space becomes a cursor move, so the persistent-pwsh readiness path never sees its `dsh> ` tail and every send waits out the silence tier. The same case passes on the Blacksmith Windows image, whose console host emits the tail verbatim. Allocate Windows PTYs with `useConptyDll: true`, so the PTY is backed by the OpenConsole the node-pty package ships. The option is set only for `win32`; the terminal spec pins it per platform. Probing the pool machine with the bundled console host rendered the `dsh> ` tail for all seven prompts of the failing case's command sequence, and `pty.pid` still resolves to `pwsh.exe` and equals the shell's own `$PID`.
This commit is contained in:
1 parent
4e25074651
commit
59ba1321eb
5 files changed
+155
-3
No files matched your search
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-28-pty-bundled-console-host.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-28-pty-bundled-console-host.md
|
||||
/agent-note-windows-persistent-ptys-use-the-console-host-node-pty-ships:
|
||||
en: 8c1283b0cd083ec7
|
||||
zh: 1961a038cd4bca7b
|
||||
/agent-note-windows-persistent-ptys-use-the-console-host-node-pty-ships/problem:
|
||||
en: 1897904855f9204e
|
||||
zh: a7294f9b5a267da4
|
||||
/agent-note-windows-persistent-ptys-use-the-console-host-node-pty-ships/decision:
|
||||
en: 1b4f56354db760c5
|
||||
zh: f467d13f84872e66
|
||||
/agent-note-windows-persistent-ptys-use-the-console-host-node-pty-ships/alternatives-considered:
|
||||
en: 4aee3131286ebe70
|
||||
zh: 5ee25ef9f87921ed
|
||||
/agent-note-windows-persistent-ptys-use-the-console-host-node-pty-ships/consequences:
|
||||
en: 51346a31a540ea1b
|
||||
zh: 95473ec62edb537d
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Windows persistent PTYs use the console host node-pty ships
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-28-pty-bundled-console-host.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The master `serial / windows (self-hosted standby)` drill has been red since 2026-09-15: its `test:coverage` gate fails on `packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts`, either with `settleReasons` reporting one `stdin_read` among six `inferred_idle` settlements or with the case exhausting its 120 s budget. The same case passes in 5.3 s on the Blacksmith Windows image.
|
||||
|
||||
Windows has no exact stdin-wait tier: `WindowsProcessInspector.isStdinWaiting()` always returns false and `foregroundPgid()` returns the shell pid, so `inspectForeground()` can only report `inputWaiting: false`. The persistent-pwsh readiness path therefore settles `stdin_read` only after it has seen both the OSC `133;D;` marker and the printable `dsh> ` tail written by the same prompt render (`terminal-bash/src/session.ts`).
|
||||
|
||||
Probing the pool machine directly (node-pty 1.2.0-beta.15, `pwsh -NoLogo -NoProfile`, 160x40, `TERM=dumb`) shows why that tail never matches there: once a command's output scrolls the viewport, the console host Windows itself provides replays the prompt line as `\ndsh>\x1b[1C` — a leading newline, the prompt text without its trailing space, and a cursor-forward escape in place of that space. `TerminalSanitizer` accumulates the printable text after the marker, so the tail becomes `\ndsh>`, which neither equals `dsh> ` nor prefixes it, and every later send waits out the silence tier. Five of seven prompts took that form on the pool machine; the same command sequence on Windows 11 25H2 (conhost 10.0.26100.1) renders `dsh> ` verbatim.
|
||||
|
||||
## Decision
|
||||
|
||||
`subprocess-local` allocates Windows terminals with node-pty's `useConptyDll: true`, so the PTY is backed by the OpenConsole the node-pty package ships instead of the console host the operating system provides. The option is set only when the runtime platform is `win32`; POSIX allocation is unchanged.
|
||||
|
||||
The same command sequence on the pool machine through the bundled console host rendered `dsh> ` for all seven prompts.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Teach `TerminalSanitizer` to tolerate the operating system console host's rendering.** Rejected: normalizing the leading newline and `CSI nC` into the tail keeps the readiness path working, but it writes one console host's quirk into the sanitizer and leaves any other repaint form to reintroduce the same divergence. The console host is a replaceable component, and the package already ships a current one.
|
||||
|
||||
**Keep the operating system console host and accept the red drill.** Rejected: the drill is the only continuous evidence that the in-house Windows pool can take over the required lanes, and it has been red for two weeks.
|
||||
|
||||
**Upgrade the pool machines' Windows.** Rejected as this repository's fix: it requires rebuilding production CI hosts (Server 2022, Windows 10 22H2), and the rendering difference returns on any host that is not current.
|
||||
|
||||
## Consequences
|
||||
|
||||
Windows PTY allocation now depends on node-pty's bundled OpenConsole binaries. The desktop runtime file policy already retains `node-pty/prebuilds/win32-x64/conpty/conpty.dll` and `OpenConsole.exe`, and the package resolves them relative to its own addon, so the packaged desktop runtime keeps working; that policy is now load-bearing for terminal allocation rather than only for keeping the source distribution intact.
|
||||
|
||||
Console signals keep their documented behavior: SIGINT is still delivered as a `\x03` input write that the console host turns into a console-wide CTRL_C event, and the Windows inspector is unchanged. The allocated process id still names the shell: on the pool machine `pty.pid` resolves to `pwsh.exe` and equals the shell's own `$PID` under both console hosts. node-pty marks `useConptyDll` experimental, so a node-pty upgrade can change it; a unit test pins the option as present for `win32` and absent elsewhere.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Windows 持久 PTY 使用 node-pty 自带的控制台宿主
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-28-pty-bundled-console-host.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
master 的 `serial / windows (self-hosted standby)` 演练自 2026-09-15 起一直红:`test:coverage` gate 在 `packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` 上失败,形态要么是 `settleReasons` 报出六次 `inferred_idle` 结算里只有一次 `stdin_read`,要么是用例耗尽 120 s 预算。同一个用例在 Blacksmith 的 Windows 镜像上 5.3 s 通过。
|
||||
|
||||
Windows 没有精确的 stdin-wait 档:`WindowsProcessInspector.isStdinWaiting()` 恒为 false、`foregroundPgid()` 返回 shell pid,因此 `inspectForeground()` 只能报 `inputWaiting: false`。持久 pwsh 的就绪路径只有在同时看到 OSC `133;D;` 标记与同一次 prompt 渲染写出的可打印 `dsh> ` 尾部之后,才结算 `stdin_read`(`terminal-bash/src/session.ts`)。
|
||||
|
||||
直接在池机器上探测(node-pty 1.2.0-beta.15、`pwsh -NoLogo -NoProfile`、160x40、`TERM=dumb`)说明了那个尾部为何在池上匹配不上:命令输出把视口滚动一次之后,Windows 自带的控制台宿主把提示符行重绘成 `\ndsh>\x1b[1C`——一个前导换行、去掉尾随空格的提示符文本,以及代替那个空格的 cursor-forward 转义。`TerminalSanitizer` 累积标记之后的非转义文本,于是尾部成了 `\ndsh>`,既不等于 `dsh> ` 也不构成它的前缀,此后每次 send 都要等满静默档。池机器上七个提示符里有五个是这个形态;同一串命令在 Windows 11 25H2(conhost 10.0.26100.1)上原样渲染出 `dsh> `。
|
||||
|
||||
## 决策
|
||||
|
||||
`subprocess-local` 分配 Windows 终端时传 node-pty 的 `useConptyDll: true`,PTY 因此由 node-pty 包自带的 OpenConsole 承载,而不是操作系统提供的控制台宿主。该选项仅在运行时平台为 `win32` 时设置;POSIX 分配不变。
|
||||
|
||||
同一串命令在池机器上经由自带控制台宿主时,七个提示符全部渲染出 `dsh> `。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**让 `TerminalSanitizer` 容忍操作系统控制台宿主的渲染形态。** 否决:把前导换行与 `CSI nC` 归一进尾部能让就绪路径继续工作,但它把一个控制台宿主的怪癖写进 sanitizer,之后任何别的重绘形态都会重新引入同一类偏离。控制台宿主是可替换组件,而包里已经带了当前版本。
|
||||
|
||||
**保留操作系统控制台宿主并接受演练长期红。** 否决:这条演练是「自有 Windows 池能接管必需 lane」的唯一持续证据,它已经红了两周。
|
||||
|
||||
**升级池机器的 Windows。** 作为本仓库的修法否决:它需要重建生产 CI 主机(Server 2022、Windows 10 22H2),而且任何非最新宿主上这个渲染差异都会回来。
|
||||
|
||||
## 后果
|
||||
|
||||
Windows PTY 分配现在依赖 node-pty 自带的 OpenConsole 二进制。桌面端运行时文件策略已经保留 `node-pty/prebuilds/win32-x64/conpty/conpty.dll` 与 `OpenConsole.exe`,且包按自身 addon 位置解析它们,因此打包后的桌面运行时仍然可用;该策略现在对终端分配是承重的,不只是为了保留源码分发内容。
|
||||
|
||||
控制台信号保持文档化的行为:SIGINT 仍以 `\x03` 输入写入投递、由控制台宿主转成控制台级 CTRL_C 事件,Windows inspector 未变。分配到的进程 id 仍然指向 shell:在池机器上 `pty.pid` 解析为 `pwsh.exe`,且与 shell 自己的 `$PID` 在两种控制台宿主下都相等。node-pty 把 `useConptyDll` 标为实验性,因此 node-pty 升级可能改变它;一条单测钉住该选项:`win32` 上存在、其它平台不存在。
|
||||
@@ -15,7 +15,7 @@ import { delimiter, extname, isAbsolute, resolve } from 'node:path'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type * as NodePty from 'node-pty'
|
||||
import type { IPtyForkOptions } from 'node-pty'
|
||||
import type { IPtyForkOptions, IWindowsPtyForkOptions } from 'node-pty'
|
||||
import { createLazyRequire } from '@deepseek-ai/dsh-lazy-require'
|
||||
import { SubprocessRuntime, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess'
|
||||
import type {
|
||||
@@ -270,14 +270,23 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
|
||||
const inspector = this.terminalInspector ?? createProcessInspector()
|
||||
const containmentMode = this.selectContainmentMode('terminal')
|
||||
const env = targetEnvironment(spec)
|
||||
const activity = prepareShellActivity(spec, env, this.internals.platform ?? process.platform)
|
||||
const platform = this.internals.platform ?? process.platform
|
||||
const activity = prepareShellActivity(spec, env, platform)
|
||||
const launch = activity === undefined ? spec : { ...spec, argv: activity.argv, env: activity.env }
|
||||
const options: IPtyForkOptions = {
|
||||
const options: IPtyForkOptions | IWindowsPtyForkOptions = {
|
||||
name: spec.terminalType,
|
||||
rows: spec.rows,
|
||||
cols: spec.cols,
|
||||
cwd: spec.cwd,
|
||||
env: { ...activity?.env ?? env, TERM: spec.terminalType },
|
||||
// The console host the Windows images of the master standby pool provide
|
||||
// (Server 2022, Windows 10 22H2) replays a prompt line that scrolled out of
|
||||
// the viewport as `\ndsh>\x1b[1C`: the trailing space becomes a cursor
|
||||
// move, so the persistent-pwsh readiness path never sees its `dsh> ` tail
|
||||
// and every send waits out the silence tier. The console host node-pty
|
||||
// ships emits the tail verbatim; the desktop runtime file policy already
|
||||
// keeps its conpty assets.
|
||||
...(platform === 'win32' ? { useConptyDll: true } : {}),
|
||||
}
|
||||
let scope: ReturnType<typeof prepareLinuxTerminalScope> | undefined
|
||||
let terminal: NodePty.IPty
|
||||
|
||||
@@ -489,6 +489,64 @@ describe('LocalSubprocessRuntime', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('allocates Windows terminals through the console host node-pty ships', async () => {
|
||||
const exitListeners: ((event: { exitCode: number; signal?: number }) => void)[] = []
|
||||
const terminal = {
|
||||
pid: 123,
|
||||
onData: () => ({ dispose: () => {} }),
|
||||
onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
|
||||
exitListeners.push(listener)
|
||||
return { dispose: () => {} }
|
||||
},
|
||||
write: () => {},
|
||||
kill: () => {},
|
||||
}
|
||||
const nodePtySpawn = vi.fn(() => terminal)
|
||||
const inspector = {
|
||||
foregroundPgid: () => undefined,
|
||||
isStdinWaiting: () => false,
|
||||
snapshot: () => ({ tree: () => [], session: () => [], alive: () => false }),
|
||||
isAlive: () => false,
|
||||
signalGroup: () => {},
|
||||
signalProcess: () => {},
|
||||
}
|
||||
vi.resetModules()
|
||||
mockWin32ForIsolatedRuntime()
|
||||
mockNodePtyForIsolatedRuntime(nodePtySpawn)
|
||||
vi.doMock('../src/process-inspector.ts', async importOriginal => ({
|
||||
...await importOriginal<typeof import('../src/process-inspector.ts')>(),
|
||||
createProcessInspector: () => inspector,
|
||||
}))
|
||||
let fiber: { dispose(): Promise<void> } | undefined
|
||||
try {
|
||||
const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
|
||||
const ctx = new Context()
|
||||
fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
|
||||
const runtime = ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
|
||||
runtime.terminalInspector = inspector
|
||||
const spawn = async (platform: NodeJS.Platform): Promise<SubprocessTerminalHandle> => {
|
||||
runtime.internals = { platform }
|
||||
return runtime.spawnTerminal({
|
||||
argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'dumb', graceMs: 1,
|
||||
})
|
||||
}
|
||||
|
||||
const windows = await spawn('win32')
|
||||
expect(nodePtySpawn).toHaveBeenLastCalledWith('shell', [], expect.objectContaining({ useConptyDll: true }))
|
||||
const posix = await spawn('darwin')
|
||||
expect(nodePtySpawn).toHaveBeenLastCalledWith('shell', [], expect.not.objectContaining({ useConptyDll: true }))
|
||||
|
||||
for (const listener of exitListeners) listener({ exitCode: 0 })
|
||||
await Promise.all([windows.done, posix.done])
|
||||
} finally {
|
||||
await fiber?.dispose()
|
||||
unmockLazyRequireForIsolatedRuntime()
|
||||
vi.doUnmock('../src/process-inspector.ts')
|
||||
unmockWin32ForIsolatedRuntime()
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('wraps Linux terminals in the selected scope and binds owner liveness', async () => {
|
||||
let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
|
||||
let launcherRunning: (() => boolean) | undefined
|
||||
|
||||
Reference in new issue
Block a user