1
0
Эх сурвалжийг харах

fix(subprocess): detect exited Windows terminals

_Kerman 1 сар өмнө
parent
commit
91e8d62b2a

+ 2 - 2
packages/subprocess/subprocess-local/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md
-README.md: 2c2046a886004321b4438da2af2723413e2454fe
-README.zh.md: 26955caca951388a60b55c7afa806a933531e8e1
+README.md: 0935bb309bd10dec7503a74708a28442223bf296
+README.zh.md: e2e6c67e4dbe1890bcb5532594a62b650bfed85d

+ 1 - 1
packages/subprocess/subprocess-local/README.md

@@ -11,7 +11,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA
 - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
 - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement.
 - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd.
-- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32 with GetProcessTimes start identities, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's absence through those identities because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations.
+- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations.
 - **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes.
 - **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md).
 

+ 1 - 1
packages/subprocess/subprocess-local/README.zh.md

@@ -11,7 +11,7 @@
 - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
 - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。
-- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表并取 GetProcessTimes 启动身份,把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组),拆卸则通过这些身份验证 shell 已消失——因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。
+- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。
 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。
 - **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md)。
 

+ 33 - 12
packages/subprocess/subprocess-local/src/windows-inspector.ts

@@ -1,8 +1,9 @@
 /**
  * Windows process-table operations for terminal readiness, signalling, and
  * teardown: Toolhelp32 snapshot enumeration with GetProcessTimes creation-time
- * identity, the shell pid as a pseudo process group (Windows has no POSIX
- * groups), and taskkill tree signalling. The koffi bindings load lazily so
+ * identity and process-handle wait-state liveness, the shell pid as a pseudo
+ * process group (Windows has no POSIX groups), and taskkill tree signalling.
+ * The koffi bindings load lazily so
  * non-Windows processes never touch Win32 libraries; all decision logic takes
  * an injectable internals boundary so suites can pin it on any host.
  * @module dsh-subprocess-local/windows-inspector
@@ -19,12 +20,20 @@ export interface ProcessEntry {
   parentPid: number
 }
 
+/** Creation identity plus the process object's current wait state. */
+export interface WindowsProcessState {
+  /** GetProcessTimes creation identity used to fence PID reuse. */
+  started: string
+  /** Whether a zero-time process-handle wait reports the process still running. */
+  active: boolean
+}
+
 /** Injectable Windows process operations used by one local PTY session. */
 export interface WindowsProcessInspectorInternals {
   /** Enumerate the current process table (pid/parent pairs). */
   snapshot(): ProcessEntry[]
-  /** Return one process's creation-time identity, or undefined when unreadable. */
-  creationTime(pid: number): string | undefined
+  /** Return one process's creation identity and wait state, or undefined when unreadable. */
+  processState(pid: number): WindowsProcessState | undefined
   /** Terminate one process tree; `force` maps to taskkill `/F`. */
   taskkill(pid: number, force: boolean): void
 }
@@ -89,7 +98,7 @@ export class WindowsProcessInspector implements ProcessInspector {
   }
 
   processTree(rootPid: number): ProcessIdentity[] {
-    return windowsProcessTree(this.internals.snapshot(), rootPid, pid => this.internals.creationTime(pid))
+    return windowsProcessTree(this.internals.snapshot(), rootPid, pid => this.internals.processState(pid)?.started)
   }
 
   processSession(_sessionId: number): ProcessIdentity[] {
@@ -97,8 +106,8 @@ export class WindowsProcessInspector implements ProcessInspector {
   }
 
   isAlive(identity: ProcessIdentity): boolean {
-    const started = this.internals.creationTime(identity.pid)
-    return started !== undefined && started === identity.started
+    const state = this.internals.processState(identity.pid)
+    return state?.active === true && state.started === identity.started
   }
 
   signalGroup(pgid: number, signal: SubprocessTerminalSignal): void {
@@ -158,6 +167,7 @@ interface Win32Bindings {
     kernel: NativePtr,
     user: NativePtr,
   ): number
+  waitForSingleObject(handle: NativePtr, milliseconds: number): number
   closeHandle(handle: NativePtr): number
 }
 
@@ -202,6 +212,9 @@ let cachedStructs: ReturnType<typeof win32Structs> | undefined
 
 const TH32CS_SNAPPROCESS = 0x2
 const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
+const SYNCHRONIZE = 0x00100000
+const WAIT_OBJECT_0 = 0
+const WAIT_TIMEOUT = 0x102
 
 let cachedBindings: Win32Bindings | undefined
 
@@ -230,6 +243,7 @@ function win32Bindings(): Win32Bindings {
       koffi.pointer(FILETIME),
       koffi.pointer(FILETIME),
     ]),
+    waitForSingleObject: bind('WaitForSingleObject', 'uint32', [PVOID, 'uint32']),
     closeHandle: bind('CloseHandle', 'int', [PVOID]),
   } as unknown as Win32Bindings
   return cachedBindings
@@ -273,10 +287,10 @@ function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] {
   return entries
 }
 
-/** Read one process's creation-time identity through GetProcessTimes. */
-function windowsCreationTime(bindings: Win32Bindings, pid: number): string | undefined {
+/** Read one process's creation identity and current wait state. */
+function windowsProcessState(bindings: Win32Bindings, pid: number): WindowsProcessState | undefined {
   const { FILETIME } = win32Structs()
-  const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid)
+  const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, pid)
   if (isInvalidHandle(handle)) return undefined
   try {
     const creation = allocNative(FILETIME, 1)
@@ -288,7 +302,14 @@ function windowsCreationTime(bindings: Win32Bindings, pid: number): string | und
        treats undefined as a detector miss. */
     if (bindings.getProcessTimes(handle, creation, exit, kernel, user) === 0) return undefined
     const record = koffi.decode(creation, FILETIME) as { dwLowDateTime: number; dwHighDateTime: number }
-    return `${record.dwHighDateTime}:${record.dwLowDateTime}`
+    const wait = bindings.waitForSingleObject(handle, 0)
+    /* v8 ignore next -- an opened process handle has exactly one of these two
+       zero-time wait states; an unexpected Win32 failure is an unreadable process. */
+    if (wait !== WAIT_OBJECT_0 && wait !== WAIT_TIMEOUT) return undefined
+    return {
+      started: `${record.dwHighDateTime}:${record.dwLowDateTime}`,
+      active: wait === WAIT_TIMEOUT,
+    }
   } finally {
     bindings.closeHandle(handle)
   }
@@ -298,7 +319,7 @@ function windowsCreationTime(bindings: Win32Bindings, pid: number): string | und
 function defaultWindowsProcessInternals(): WindowsProcessInspectorInternals {
   return {
     snapshot: () => snapshotWindowsProcesses(win32Bindings()),
-    creationTime: pid => windowsCreationTime(win32Bindings(), pid),
+    processState: pid => windowsProcessState(win32Bindings(), pid),
     taskkill: taskkillTree,
   }
 }

+ 10 - 4
packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts

@@ -9,21 +9,22 @@ import type {
   NativePtr,
   ProcessEntry,
   WindowsProcessInspectorInternals,
+  WindowsProcessState,
 } from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts'
 
 function fakeInternals() {
   const entries: ProcessEntry[] = []
-  const times = new Map<number, string>()
+  const states = new Map<number, WindowsProcessState>()
   const kills: Array<[number, boolean]> = []
   return {
     internals: {
       snapshot: () => [...entries],
-      creationTime: pid => times.get(pid),
+      processState: pid => states.get(pid),
       taskkill: (pid: number, force: boolean) => { kills.push([pid, force]) },
     } satisfies WindowsProcessInspectorInternals,
-    add(entry: ProcessEntry, started?: string): void {
+    add(entry: ProcessEntry, started?: string, active = true): void {
       entries.push(entry)
-      if (started !== undefined) times.set(entry.pid, started)
+      if (started !== undefined) states.set(entry.pid, { started, active })
     },
     kills,
   }
@@ -80,6 +81,9 @@ describe('WindowsProcessInspector (injected internals)', () => {
     expect(inspector.isAlive({ pid: 11, started: 't11' })).toBe(true)
     expect(inspector.isAlive({ pid: 11, started: 'stale' })).toBe(false)
     expect(inspector.isAlive({ pid: 99, started: 't99' })).toBe(false)
+
+    fake.add({ pid: 12, parentPid: 10 }, 't12', false)
+    expect(inspector.isAlive({ pid: 12, started: 't12' })).toBe(false)
   })
 
   it('maps SIGKILL to a forced taskkill and other signals to the grace form', () => {
@@ -94,8 +98,10 @@ describe('WindowsProcessInspector (injected internals)', () => {
   it('signals a process only while its start identity matches', () => {
     const fake = fakeInternals()
     fake.add({ pid: 10, parentPid: 0 }, 't10')
+    fake.add({ pid: 11, parentPid: 10 }, 't11', false)
     const inspector = new WindowsProcessInspector(fake.internals)
     inspector.signalProcess({ pid: 10, started: 't10' }, 'SIGKILL')
+    inspector.signalProcess({ pid: 11, started: 't11' }, 'SIGKILL')
     inspector.signalProcess({ pid: 10, started: 'stale' }, 'SIGTERM')
     expect(fake.kills).toEqual([[10, true]])
   })