windows-inspector.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. /**
  2. * Windows process-table operations for terminal readiness, signalling, and
  3. * teardown: Toolhelp32 snapshot enumeration with GetProcessTimes creation-time
  4. * identity and process-handle wait-state liveness, the shell pid as a pseudo
  5. * process group (Windows has no POSIX groups), and taskkill tree signalling.
  6. * The koffi bindings load lazily so
  7. * non-Windows processes never touch Win32 libraries; all decision logic takes
  8. * an injectable internals boundary so suites can pin it on any host.
  9. * @module dsh-subprocess-local/windows-inspector
  10. */
  11. import { spawnSync } from 'node:child_process'
  12. import koffi from 'koffi'
  13. import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess'
  14. import type { ProcessIdentity, ProcessInspector, ProcessSnapshot } from './process-inspector.ts'
  15. /** One Toolhelp32 process-table row. */
  16. export interface ProcessEntry {
  17. pid: number
  18. parentPid: number
  19. }
  20. /** Creation identity plus the process object's current wait state. */
  21. export interface WindowsProcessState {
  22. /** GetProcessTimes creation identity used to fence PID reuse. */
  23. started: string
  24. /** Whether a zero-time process-handle wait reports the process still running. */
  25. active: boolean
  26. }
  27. /** Injectable Windows process operations used by one local PTY session. */
  28. export interface WindowsProcessInspectorInternals {
  29. /** Enumerate the current process table (pid/parent pairs). */
  30. snapshot(): ProcessEntry[]
  31. /** Return one process's creation identity and wait state, or undefined when unreadable. */
  32. processState(pid: number): WindowsProcessState | undefined
  33. /** Terminate one process tree; `force` maps to taskkill `/F`. */
  34. taskkill(pid: number, force: boolean): void
  35. }
  36. /**
  37. * Walk a process table from one root in children-first order, retaining only
  38. * members whose start identity is readable (unreadable members are detector
  39. * misses, exactly like an unreadable `/proc` entry on Linux).
  40. * @param entries - the process table snapshot.
  41. * @param rootPid - the tree root to descend from.
  42. * @param started - creation-time identity resolver for one member.
  43. * @returns the root and its current transitive descendants, children first.
  44. */
  45. /* jscpd:ignore-start -- the Windows inspector deliberately mirrors process-inspector.ts:
  46. the decision logic (tree walk, identity fencing, group signalling) is the same contract over
  47. Win32 primitives, per the persistent-pty note 2026-08-11-pwsh-persistent-pty. */
  48. export function windowsProcessTree(
  49. entries: ProcessEntry[],
  50. rootPid: number,
  51. started: (pid: number) => string | undefined,
  52. ): ProcessIdentity[] {
  53. const byPid = new Map(entries.map(entry => [entry.pid, entry]))
  54. const root = byPid.get(rootPid)
  55. if (root === undefined) return []
  56. const byParent = new Map<number, ProcessEntry[]>()
  57. for (const entry of entries) {
  58. const children = byParent.get(entry.parentPid) ?? []
  59. children.push(entry)
  60. byParent.set(entry.parentPid, children)
  61. }
  62. const visited = new Set<number>()
  63. const result: ProcessIdentity[] = []
  64. const visit = (entry: ProcessEntry): void => {
  65. if (visited.has(entry.pid)) return
  66. visited.add(entry.pid)
  67. for (const child of byParent.get(entry.pid) ?? []) visit(child)
  68. const identity = started(entry.pid)
  69. if (identity !== undefined) result.push({ pid: entry.pid, started: identity })
  70. }
  71. visit(root)
  72. return result
  73. }
  74. /**
  75. * Windows {@link ProcessInspector}. The shell pid stands in for a foreground
  76. * process group: it is a stable pseudo-group that lets the prompt-marker
  77. * readiness path compare foreground identities, while every actual signal
  78. * targets the console-wide tree through taskkill (SIGINT is delivered by the
  79. * terminal handle as a `\x03` input write and never reaches this layer).
  80. */
  81. export class WindowsProcessInspector implements ProcessInspector {
  82. constructor(
  83. private readonly internals: WindowsProcessInspectorInternals = defaultWindowsProcessInternals(),
  84. ) {}
  85. foregroundPgid(shellPid: number): number {
  86. return shellPid
  87. }
  88. isStdinWaiting(_pgid: number, _shellPid: number): boolean {
  89. return false
  90. }
  91. isAlive(identity: ProcessIdentity): boolean {
  92. const state = this.internals.processState(identity.pid)
  93. return state?.active === true && state.started === identity.started
  94. }
  95. snapshot(): ProcessSnapshot {
  96. // Enumerated on the first question that reads the table. Liveness never
  97. // does — wait state is a per-handle question here — so the Windows
  98. // teardown poll, which asks only for liveness, pays no Toolhelp32 walk.
  99. let entries: ProcessEntry[] | undefined
  100. return {
  101. tree: rootPid => windowsProcessTree(
  102. entries ??= this.internals.snapshot(),
  103. rootPid,
  104. pid => this.internals.processState(pid)?.started,
  105. ),
  106. // Windows has no POSIX sessions; the shell pid stands in as a pseudo group.
  107. session: () => [],
  108. alive: identity => this.isAlive(identity),
  109. }
  110. }
  111. signalGroup(pgid: number, signal: SubprocessTerminalSignal): void {
  112. this.internals.taskkill(pgid, signal === 'SIGKILL')
  113. }
  114. signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void {
  115. if (this.isAlive(identity)) this.internals.taskkill(identity.pid, signal === 'SIGKILL')
  116. }
  117. }
  118. /* jscpd:ignore-end */
  119. /**
  120. * Create the Windows process inspector.
  121. * @param internals - injectable process operations; defaults to the koffi-backed table.
  122. * @returns the Windows inspector.
  123. */
  124. export function createWindowsProcessInspector(
  125. internals: WindowsProcessInspectorInternals = defaultWindowsProcessInternals(),
  126. ): WindowsProcessInspector {
  127. return new WindowsProcessInspector(internals)
  128. }
  129. /** Terminate one Windows process tree with taskkill, contained like POSIX group signalling. */
  130. function taskkillTree(pid: number, force: boolean): void {
  131. if (pid <= 0) return
  132. // Outcome deliberately unchecked: an already-absent tree, exit races, and a
  133. // missing taskkill binary are as tolerable here as ESRCH is for POSIX.
  134. spawnSync('taskkill', ['/PID', String(pid), '/T', ...(force ? ['/F'] : [])], {
  135. stdio: 'ignore',
  136. windowsHide: true,
  137. })
  138. }
  139. declare const nativePtr: unique symbol
  140. /** Koffi 3 native pointer (a BigInt address), branded so it cannot silently enter numeric contexts. */
  141. export type NativePtr = bigint & { readonly [nativePtr]: true }
  142. /**
  143. * True for NULL and INVALID_HANDLE_VALUE returns from Win32 handle APIs.
  144. * @param value - a handle as koffi may hand it back (pointer, null, or 0n).
  145. * @returns whether the value signals an invalid handle.
  146. */
  147. export function isInvalidHandle(value: NativePtr | null | undefined): boolean {
  148. if (value === null || value === undefined) return true
  149. const asBigInt = value as bigint
  150. return asBigInt === 0n || asBigInt === 0xFFFFFFFFFFFFFFFFn || asBigInt === -1n
  151. }
  152. /** The lazy koffi binding table: every Win32 call the Windows inspector uses. */
  153. interface Win32Bindings {
  154. createToolhelp32Snapshot(flags: number, processId: number): NativePtr
  155. process32FirstW(snapshot: NativePtr, entry: NativePtr): number
  156. process32NextW(snapshot: NativePtr, entry: NativePtr): number
  157. openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr
  158. getProcessTimes(
  159. process: NativePtr,
  160. creation: NativePtr,
  161. exit: NativePtr,
  162. kernel: NativePtr,
  163. user: NativePtr,
  164. ): number
  165. waitForSingleObject(handle: NativePtr, milliseconds: number): number
  166. closeHandle(handle: NativePtr): number
  167. }
  168. const PVOID: ReturnType<typeof koffi.pointer> = koffi.pointer('void')
  169. /**
  170. * Resolve the koffi Win32 struct types once. Registration is lazy and cached
  171. * because koffi's type registry is global per process: test runners that
  172. * re-evaluate this module (a hoisted `vi.mock` re-imports the graph) must not
  173. * re-register the names.
  174. */
  175. function win32Structs(): { PROCESSENTRY32W: ReturnType<typeof koffi.struct>; FILETIME: ReturnType<typeof koffi.struct> } {
  176. if (cachedStructs !== undefined) return cachedStructs
  177. // koffi PROCESSENTRY32W layout (tlhelp32.h); the size assert pins the x64 layout.
  178. const PROCESSENTRY32W = koffi.struct('PROCESSENTRY32W', {
  179. dwSize: 'uint32',
  180. cntUsage: 'uint32',
  181. th32ProcessID: 'uint32',
  182. th32DefaultHeapID: PVOID,
  183. th32ModuleID: 'uint32',
  184. cCntThreads: 'uint32',
  185. th32ParentProcessID: 'uint32',
  186. pcPriClassBase: 'int32',
  187. dwFlags: 'uint32',
  188. szExeFile: koffi.array('char16', 260),
  189. })
  190. // koffi FILETIME layout (minwinbase.h): two 32-bit halves of the 64-bit timestamp.
  191. const FILETIME = koffi.struct('FILETIME', {
  192. dwLowDateTime: 'uint32',
  193. dwHighDateTime: 'uint32',
  194. })
  195. /* v8 ignore start -- a layout-mismatch guard fires only on ABI breakage; the windows-native suites exercise the real struct. */
  196. if (PROCESSENTRY32W.size !== 568) {
  197. throw new Error(`PROCESSENTRY32W layout mismatch: koffi computed ${PROCESSENTRY32W.size}, Windows headers say 568`)
  198. }
  199. /* v8 ignore stop */
  200. cachedStructs = { PROCESSENTRY32W, FILETIME }
  201. return cachedStructs
  202. }
  203. let cachedStructs: ReturnType<typeof win32Structs> | undefined
  204. const TH32CS_SNAPPROCESS = 0x2
  205. const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
  206. const SYNCHRONIZE = 0x00100000
  207. const WAIT_OBJECT_0 = 0
  208. const WAIT_TIMEOUT = 0x102
  209. let cachedBindings: Win32Bindings | undefined
  210. /**
  211. * Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed).
  212. * @returns the cached binding table.
  213. */
  214. function win32Bindings(): Win32Bindings {
  215. if (cachedBindings !== undefined) return cachedBindings
  216. const { PROCESSENTRY32W, FILETIME } = win32Structs()
  217. const kernel32 = koffi.load('kernel32.dll')
  218. const bind = (
  219. name: string,
  220. result: ReturnType<typeof koffi.pointer> | string,
  221. args: Array<ReturnType<typeof koffi.pointer> | string>,
  222. ): unknown => kernel32.func('__stdcall', name, result, args)
  223. cachedBindings = {
  224. createToolhelp32Snapshot: bind('CreateToolhelp32Snapshot', PVOID, ['uint32', 'uint32']),
  225. process32FirstW: bind('Process32FirstW', 'int', [PVOID, koffi.pointer(PROCESSENTRY32W)]),
  226. process32NextW: bind('Process32NextW', 'int', [PVOID, koffi.pointer(PROCESSENTRY32W)]),
  227. openProcess: bind('OpenProcess', PVOID, ['uint32', 'int', 'uint32']),
  228. getProcessTimes: bind('GetProcessTimes', 'int', [
  229. PVOID,
  230. koffi.pointer(FILETIME),
  231. koffi.pointer(FILETIME),
  232. koffi.pointer(FILETIME),
  233. koffi.pointer(FILETIME),
  234. ]),
  235. waitForSingleObject: bind('WaitForSingleObject', 'uint32', [PVOID, 'uint32']),
  236. closeHandle: bind('CloseHandle', 'int', [PVOID]),
  237. } as unknown as Win32Bindings
  238. return cachedBindings
  239. }
  240. /**
  241. * Allocate koffi memory as a branded {@link NativePtr}; koffi's TS types are
  242. * `any`, so the cast goes through `unknown` to keep the unsafe surface here.
  243. * @param type - the koffi type to allocate.
  244. * @param count - element count.
  245. * @returns the branded allocation pointer.
  246. */
  247. function allocNative(type: Parameters<typeof koffi.alloc>[0], count: number): NativePtr {
  248. const value: unknown = koffi.alloc(type, count)
  249. return value as NativePtr
  250. }
  251. /** Enumerate the current process table through Toolhelp32. */
  252. function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] {
  253. const { PROCESSENTRY32W } = win32Structs()
  254. const snapshot = bindings.createToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
  255. /* v8 ignore next -- an invalid snapshot for the process flag is not producible through the public API;
  256. the guard mirrors POSIX's unreadable-proc tolerance and isInvalidHandle is unit-tested. */
  257. if (isInvalidHandle(snapshot)) return []
  258. const entries: ProcessEntry[] = []
  259. try {
  260. const entry = allocNative(PROCESSENTRY32W, 1)
  261. koffi.encode(entry, 'uint32', PROCESSENTRY32W.size)
  262. let ok = bindings.process32FirstW(snapshot, entry)
  263. while (ok !== 0) {
  264. const record = koffi.decode(entry, PROCESSENTRY32W) as {
  265. th32ProcessID: number
  266. th32ParentProcessID: number
  267. }
  268. entries.push({ pid: record.th32ProcessID, parentPid: record.th32ParentProcessID })
  269. ok = bindings.process32NextW(snapshot, entry)
  270. }
  271. } finally {
  272. bindings.closeHandle(snapshot)
  273. }
  274. return entries
  275. }
  276. /** Read one process's creation identity and current wait state. */
  277. function windowsProcessState(bindings: Win32Bindings, pid: number): WindowsProcessState | undefined {
  278. const { FILETIME } = win32Structs()
  279. const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, pid)
  280. if (isInvalidHandle(handle)) return undefined
  281. try {
  282. const creation = allocNative(FILETIME, 1)
  283. const exit = allocNative(FILETIME, 1)
  284. const kernel = allocNative(FILETIME, 1)
  285. const user = allocNative(FILETIME, 1)
  286. /* v8 ignore next -- a GetProcessTimes failure after a successful open races process exit and
  287. cannot be staged deterministically; the absent-process path is covered and the caller
  288. treats undefined as a detector miss. */
  289. if (bindings.getProcessTimes(handle, creation, exit, kernel, user) === 0) return undefined
  290. const record = koffi.decode(creation, FILETIME) as { dwLowDateTime: number; dwHighDateTime: number }
  291. const wait = bindings.waitForSingleObject(handle, 0)
  292. /* v8 ignore next -- an opened process handle has exactly one of these two
  293. zero-time wait states; an unexpected Win32 failure is an unreadable process. */
  294. if (wait !== WAIT_OBJECT_0 && wait !== WAIT_TIMEOUT) return undefined
  295. return {
  296. started: `${record.dwHighDateTime}:${record.dwLowDateTime}`,
  297. active: wait === WAIT_TIMEOUT,
  298. }
  299. } finally {
  300. bindings.closeHandle(handle)
  301. }
  302. }
  303. /** The koffi-backed default internals; bindings resolve lazily on first use. */
  304. function defaultWindowsProcessInternals(): WindowsProcessInspectorInternals {
  305. return {
  306. snapshot: () => snapshotWindowsProcesses(win32Bindings()),
  307. processState: pid => windowsProcessState(win32Bindings(), pid),
  308. taskkill: taskkillTree,
  309. }
  310. }