windows-inspector.spec.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. import { spawnSync as nodeSpawnSync } from 'node:child_process'
  2. import { describe, expect, it, vi } from 'vitest'
  3. import {
  4. createWindowsProcessInspector,
  5. isInvalidHandle,
  6. windowsProcessTree,
  7. WindowsProcessInspector,
  8. } from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts'
  9. import type {
  10. NativePtr,
  11. ProcessEntry,
  12. WindowsProcessInspectorInternals,
  13. WindowsProcessState,
  14. } from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts'
  15. vi.mock('node:child_process', async (importOriginal) => {
  16. const actual = await importOriginal<typeof import('node:child_process')>()
  17. return { ...actual, spawnSync: vi.fn(actual.spawnSync) }
  18. })
  19. function fakeInternals() {
  20. const entries: ProcessEntry[] = []
  21. const states = new Map<number, WindowsProcessState>()
  22. const kills: Array<[number, boolean]> = []
  23. const counts = { enumerations: 0, stateReads: 0 }
  24. return {
  25. counts,
  26. internals: {
  27. snapshot: () => { counts.enumerations += 1; return [...entries] },
  28. processState: (pid) => { counts.stateReads += 1; return states.get(pid) },
  29. taskkill: (pid: number, force: boolean) => { kills.push([pid, force]) },
  30. } satisfies WindowsProcessInspectorInternals,
  31. add(entry: ProcessEntry, started?: string, active = true): void {
  32. entries.push(entry)
  33. if (started !== undefined) states.set(entry.pid, { started, active })
  34. },
  35. kills,
  36. }
  37. }
  38. describe('WindowsProcessInspector table enumeration', () => {
  39. it('enumerates the process table only for questions that need it', () => {
  40. const fake = fakeInternals()
  41. fake.add({ pid: 10, parentPid: 0 }, 't10')
  42. fake.add({ pid: 11, parentPid: 10 }, 't11')
  43. const inspector = new WindowsProcessInspector(fake.internals)
  44. // Liveness is a per-handle question on Windows, so a snapshot asked only
  45. // for liveness must not pay a Toolhelp32 walk. The terminal's Windows
  46. // teardown polls exactly this way, every 25 ms.
  47. const observed = inspector.snapshot()
  48. expect(observed.alive({ pid: 11, started: 't11' })).toBe(true)
  49. expect(fake.counts.enumerations).toBe(0)
  50. expect(observed.tree(10)).toHaveLength(2)
  51. expect(fake.counts.enumerations).toBe(1)
  52. // A second tree question reuses the same observation.
  53. observed.tree(10)
  54. expect(fake.counts.enumerations).toBe(1)
  55. })
  56. })
  57. describe('windowsProcessTree', () => {
  58. it('walks a table children-first with readable identities only', () => {
  59. const started = (pid: number): string | undefined => pid === 12 ? undefined : `t${pid}`
  60. expect(windowsProcessTree([
  61. { pid: 10, parentPid: 0 },
  62. { pid: 11, parentPid: 10 },
  63. { pid: 12, parentPid: 11 },
  64. { pid: 13, parentPid: 11 },
  65. { pid: 14, parentPid: 10 },
  66. ], 10, started)).toEqual([
  67. { pid: 13, started: 't13' },
  68. { pid: 11, started: 't11' },
  69. { pid: 14, started: 't14' },
  70. { pid: 10, started: 't10' },
  71. ])
  72. })
  73. it('returns an empty walk for an absent root', () => {
  74. expect(windowsProcessTree([{ pid: 10, parentPid: 0 }], 99, () => 't')).toEqual([])
  75. })
  76. it('terminates on a parent cycle instead of recursing forever', () => {
  77. const entries = [
  78. { pid: 10, parentPid: 11 },
  79. { pid: 11, parentPid: 10 },
  80. ]
  81. expect(windowsProcessTree(entries, 10, () => 't')).toHaveLength(2)
  82. })
  83. })
  84. describe('WindowsProcessInspector (injected internals)', () => {
  85. it('hides the default taskkill helper window for both termination tiers', () => {
  86. const taskkill = vi.mocked(nodeSpawnSync)
  87. taskkill.mockReturnValueOnce({} as never).mockReturnValueOnce({} as never)
  88. const inspector = createWindowsProcessInspector()
  89. inspector.signalGroup(77, 'SIGKILL')
  90. inspector.signalGroup(78, 'SIGTERM')
  91. expect(taskkill).toHaveBeenNthCalledWith(
  92. 1,
  93. 'taskkill',
  94. ['/PID', '77', '/T', '/F'],
  95. { stdio: 'ignore', windowsHide: true },
  96. )
  97. expect(taskkill).toHaveBeenNthCalledWith(
  98. 2,
  99. 'taskkill',
  100. ['/PID', '78', '/T'],
  101. { stdio: 'ignore', windowsHide: true },
  102. )
  103. })
  104. it('exposes the shell pid as the pseudo foreground group and never proves stdin waits', () => {
  105. const fake = fakeInternals()
  106. const inspector = new WindowsProcessInspector(fake.internals)
  107. expect(inspector.foregroundPgid(77)).toBe(77)
  108. expect(inspector.isStdinWaiting(77, 10)).toBe(false)
  109. expect(inspector.snapshot().session(77)).toEqual([])
  110. })
  111. it('delegates tree walks and identity checks to the internals', () => {
  112. const fake = fakeInternals()
  113. fake.add({ pid: 10, parentPid: 0 }, 't10')
  114. fake.add({ pid: 11, parentPid: 10 }, 't11')
  115. const inspector = new WindowsProcessInspector(fake.internals)
  116. expect(inspector.snapshot().tree(10)).toEqual([
  117. { pid: 11, started: 't11' },
  118. { pid: 10, started: 't10' },
  119. ])
  120. expect(inspector.isAlive({ pid: 11, started: 't11' })).toBe(true)
  121. expect(inspector.isAlive({ pid: 11, started: 'stale' })).toBe(false)
  122. expect(inspector.isAlive({ pid: 99, started: 't99' })).toBe(false)
  123. fake.add({ pid: 12, parentPid: 10 }, 't12', false)
  124. expect(inspector.isAlive({ pid: 12, started: 't12' })).toBe(false)
  125. })
  126. it('maps SIGKILL to a forced taskkill and other signals to the grace form', () => {
  127. const fake = fakeInternals()
  128. const inspector = new WindowsProcessInspector(fake.internals)
  129. inspector.signalGroup(77, 'SIGKILL')
  130. inspector.signalGroup(77, 'SIGTERM')
  131. inspector.signalGroup(0, 'SIGKILL')
  132. expect(fake.kills).toEqual([[77, true], [77, false], [0, true]])
  133. })
  134. it('signals a process only while its start identity matches', () => {
  135. const fake = fakeInternals()
  136. fake.add({ pid: 10, parentPid: 0 }, 't10')
  137. fake.add({ pid: 11, parentPid: 10 }, 't11', false)
  138. const inspector = new WindowsProcessInspector(fake.internals)
  139. inspector.signalProcess({ pid: 10, started: 't10' }, 'SIGKILL')
  140. inspector.signalProcess({ pid: 11, started: 't11' }, 'SIGKILL')
  141. inspector.signalProcess({ pid: 10, started: 'stale' }, 'SIGTERM')
  142. expect(fake.kills).toEqual([[10, true]])
  143. })
  144. it('accepts an injected internals factory through the creator', () => {
  145. const fake = fakeInternals()
  146. expect(createWindowsProcessInspector(fake.internals)).toBeInstanceOf(WindowsProcessInspector)
  147. expect(createWindowsProcessInspector()).toBeInstanceOf(WindowsProcessInspector)
  148. })
  149. })
  150. describe('isInvalidHandle', () => {
  151. it('rejects null, zero, and the all-ones INVALID_HANDLE_VALUE forms', () => {
  152. const ptr = (value: bigint): NativePtr => value as NativePtr
  153. expect(isInvalidHandle(null)).toBe(true)
  154. expect(isInvalidHandle(undefined)).toBe(true)
  155. expect(isInvalidHandle(ptr(0n))).toBe(true)
  156. expect(isInvalidHandle(ptr(0xFFFFFFFFFFFFFFFFn))).toBe(true)
  157. expect(isInvalidHandle(ptr(-1n))).toBe(true)
  158. expect(isInvalidHandle(ptr(1234n))).toBe(false)
  159. })
  160. })
  161. const win32 = process.platform === 'win32' ? describe : describe.skip
  162. win32('WindowsProcessInspector over the real koffi bindings', () => {
  163. it('walks the live process table from the test runner itself', () => {
  164. const inspector = createWindowsProcessInspector()
  165. const tree = inspector.snapshot().tree(process.pid)
  166. const self = tree.find(member => member.pid === process.pid)
  167. expect(self).toBeDefined()
  168. expect(inspector.snapshot().alive(self!)).toBe(true)
  169. expect(inspector.foregroundPgid(process.pid)).toBe(process.pid)
  170. })
  171. it('reports unreadable identities for absent processes and no-ops tree signalling', () => {
  172. const inspector = createWindowsProcessInspector()
  173. expect(inspector.isAlive({ pid: 0x7FFFFFFF, started: 'absent' })).toBe(false)
  174. expect(() => { inspector.signalGroup(0x7FFFFFFF, 'SIGKILL') }).not.toThrow()
  175. expect(() => { inspector.signalGroup(0x7FFFFFFF, 'SIGTERM') }).not.toThrow()
  176. expect(() => { inspector.signalGroup(0, 'SIGKILL') }).not.toThrow()
  177. expect(() => { inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL') }).not.toThrow()
  178. })
  179. })