process-inspector.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. import { describe, expect, it } from 'vitest'
  2. import {
  3. createProcessInspector,
  4. linuxProcessGroupHasLiveMembers,
  5. parseProcStat,
  6. } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
  7. import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
  8. import { WindowsProcessInspector } from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts'
  9. function stat(
  10. pid: number,
  11. pgrp: number,
  12. session: number,
  13. tpgid: number,
  14. started: string,
  15. parentPid = 1,
  16. state = 'S',
  17. ttyDevice = 99,
  18. ): string {
  19. const rest = [state, String(parentPid), String(pgrp), String(session), String(ttyDevice), String(tpgid)]
  20. while (rest.length < 19) rest.push('0')
  21. rest.push(started)
  22. return `${pid} (command with space) ${rest.join(' ')}`
  23. }
  24. function syscall(number: number, ...args: number[]): string {
  25. const six = [...args]
  26. while (six.length < 6) six.push(0)
  27. return `${number} ${six.slice(0, 6).map(value => `0x${value.toString(16)}`).join(' ')}`
  28. }
  29. function fakeInternals() {
  30. const files = new Map<string, string>()
  31. const dirs = new Map<string, string[]>()
  32. const links = new Map<string, string>()
  33. const devices = new Map<string, { character: boolean; rdev: number }>()
  34. const memories = new Map<string, Buffer>()
  35. const fds = new Map<number, string>()
  36. const kills: Array<[number, NodeJS.Signals]> = []
  37. let nextFd = 10
  38. let ps = ''
  39. let tpgid = '0'
  40. const internals: ProcessInspectorInternals = {
  41. readFile(path) {
  42. const value = files.get(path)
  43. if (value === undefined) throw new Error(`missing ${path}`)
  44. return value
  45. },
  46. readDir(path) {
  47. const value = dirs.get(path)
  48. if (value === undefined) throw new Error(`missing ${path}`)
  49. return value
  50. },
  51. readLink(path) {
  52. const value = links.get(path)
  53. if (value === undefined) throw new Error(`missing ${path}`)
  54. return value
  55. },
  56. stat(path) {
  57. const value = devices.get(path)
  58. if (value === undefined) throw new Error(`missing ${path}`)
  59. return { rdev: value.rdev, isCharacterDevice: () => value.character }
  60. },
  61. open(path) {
  62. if (!memories.has(path)) throw new Error(`missing ${path}`)
  63. const fd = nextFd++
  64. fds.set(fd, path)
  65. return fd
  66. },
  67. read(fd, buffer, length, position) {
  68. const path = fds.get(fd)
  69. if (path === undefined) throw new Error('bad fd')
  70. const source = memories.get(path)
  71. if (source === undefined) throw new Error('missing memory')
  72. return source.copy(buffer, 0, position, Math.min(source.length, position + length))
  73. },
  74. close(fd) { fds.delete(fd) },
  75. exec(_file, args) {
  76. if (args.includes('tpgid=')) return tpgid
  77. return ps
  78. },
  79. kill(pid, signal) { kills.push([pid, signal]) },
  80. }
  81. return {
  82. internals, files, dirs, links, devices, memories, kills,
  83. setPs(value: string) { ps = value },
  84. setTpgid(value: string) { tpgid = value },
  85. }
  86. }
  87. describe('Linux process inspector', () => {
  88. it.each(['EACCES', 'EMFILE'])('refuses to publish an empty snapshot when /proc enumeration fails with %s', (code) => {
  89. const fake = fakeInternals()
  90. const readDir = fake.internals.readDir.bind(fake.internals)
  91. fake.internals.readDir = () => { throw Object.assign(new Error('Enumeration failed'), { code }) }
  92. const inspector = createProcessInspector('linux', 'x64', fake.internals)
  93. expect(() => inspector.snapshot()).toThrow('/proc directory is unreadable')
  94. fake.internals.readDir = readDir
  95. fake.dirs.set('/proc', ['10'])
  96. fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500'))
  97. expect(inspector.snapshot().complete).toBe(true)
  98. expect(inspector.snapshot().tree(10)).toEqual([{ pid: 10, started: '500' }])
  99. })
  100. it('makes no stdin-wait claim when process or task directories cannot be enumerated', () => {
  101. const fake = fakeInternals()
  102. fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500'))
  103. fake.links.set('/proc/10/fd/0', '/dev/pts/1')
  104. fake.devices.set('/proc/10/fd/0', { character: true, rdev: 99 })
  105. const inspector = createProcessInspector('linux', 'x64', fake.internals)
  106. expect(inspector.isStdinWaiting(10, 10)).toBe(false)
  107. fake.dirs.set('/proc', ['10'])
  108. expect(inspector.isStdinWaiting(10, 10)).toBe(false)
  109. })
  110. it('treats zombie-only process groups as quiescent and fails closed when unobservable', () => {
  111. const fake = fakeInternals()
  112. expect(linuxProcessGroupHasLiveMembers(77, fake.internals)).toBeUndefined()
  113. fake.dirs.set('/proc', ['self', '10', '11', '12'])
  114. fake.files.set('/proc/10/stat', stat(10, 77, 10, -1, '500', 1, 'Z'))
  115. fake.files.set('/proc/11/stat', stat(11, 77, 10, -1, '501', 1, 'X'))
  116. fake.files.set('/proc/12/stat', stat(12, 88, 12, -1, '502'))
  117. expect(linuxProcessGroupHasLiveMembers(77, fake.internals)).toBe(false)
  118. expect(linuxProcessGroupHasLiveMembers(99, fake.internals)).toBeUndefined()
  119. fake.files.set('/proc/11/stat', stat(11, 77, 10, -1, '501'))
  120. expect(linuxProcessGroupHasLiveMembers(77, fake.internals)).toBe(true)
  121. })
  122. it('parses stat safely, captures only the rooted process tree, and signals identities', () => {
  123. expect(parseProcStat('bad')).toBeUndefined()
  124. expect(parseProcStat('1 () ')).toBeUndefined()
  125. expect(parseProcStat('1 () S')).toBeUndefined()
  126. expect(parseProcStat(stat(10, 20, 30, 40, '500', 1, 'SS'))).toBeUndefined()
  127. expect(parseProcStat(stat(10, 20, 30, 40, '500'))).toEqual({ pid: 10, parentPid: 1, pgrp: 20, session: 30, state: 'S', ttyDevice: 99, tpgid: 40, started: '500' })
  128. const fake = fakeInternals()
  129. fake.dirs.set('/proc', ['x', '10', '11', '12', '13', '14'])
  130. fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500'))
  131. fake.files.set('/proc/11/stat', stat(11, 21, 30, -1, '501'))
  132. fake.files.set('/proc/12/stat', stat(12, 22, 30, -1, '502', 10))
  133. fake.files.set('/proc/13/stat', stat(13, 23, 30, -1, '503', 12))
  134. const inspector = createProcessInspector('linux', 'x64', fake.internals)
  135. expect(inspector.foregroundPgid(10)).toBe(40)
  136. expect(inspector.foregroundPgid(11)).toBeUndefined()
  137. expect(inspector.foregroundPgid(99)).toBeUndefined()
  138. const observed = inspector.snapshot()
  139. expect(observed.tree(10)).toEqual([
  140. { pid: 13, started: '503' },
  141. { pid: 12, started: '502' },
  142. { pid: 10, started: '500' },
  143. ])
  144. expect(observed.tree(99)).toEqual([])
  145. expect(observed.session(30)).toEqual([
  146. { pid: 10, started: '500' },
  147. { pid: 11, started: '501' },
  148. { pid: 12, started: '502' },
  149. { pid: 13, started: '503' },
  150. ])
  151. expect(observed.session(99)).toEqual([])
  152. expect(observed.alive({ pid: 10, started: '500' })).toBe(true)
  153. expect(observed.alive({ pid: 10, started: 'old' })).toBe(false)
  154. inspector.signalGroup(40, 'SIGINT')
  155. inspector.signalProcess({ pid: 10, started: '500' }, 'SIGTERM')
  156. inspector.signalProcess({ pid: 10, started: 'old' }, 'SIGKILL')
  157. expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
  158. fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500', 1, 'Z'))
  159. // A zombie is present in the table but never signallable; both the batch
  160. // view and the signal fence report it quiescent once the state changes.
  161. expect(inspector.snapshot().alive({ pid: 10, started: '500' })).toBe(false)
  162. expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(false)
  163. inspector.signalProcess({ pid: 10, started: '500' }, 'SIGKILL')
  164. expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
  165. })
  166. it('detects supported kernel ABI waits across non-leader threads', () => {
  167. const fake = fakeInternals()
  168. fake.dirs.set('/proc', ['100', '101'])
  169. fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
  170. fake.files.set('/proc/101/stat', stat(101, 77, 100, 77, '2'))
  171. fake.dirs.set('/proc/100/task', ['100'])
  172. fake.dirs.set('/proc/101/task', ['101', '102'])
  173. fake.links.set('/proc/100/fd/0', '/dev/pts/1')
  174. fake.devices.set('/proc/100/fd/0', { character: true, rdev: 99 })
  175. fake.links.set('/proc/101/task/102/fd/0', '/dev/pts/1')
  176. fake.devices.set('/proc/101/task/102/fd/0', { character: true, rdev: 99 })
  177. const inspector = createProcessInspector('linux', 'x64', fake.internals)
  178. fake.files.set('/proc/100/task/100/syscall', 'running')
  179. fake.files.set('/proc/101/task/101/syscall', '-1 0x0')
  180. fake.files.set('/proc/101/task/102/syscall', syscall(0, 0))
  181. expect(inspector.isStdinWaiting(77, 100)).toBe(true)
  182. fake.files.set('/proc/101/task/102/syscall', syscall(63, 0))
  183. expect(inspector.isStdinWaiting(77, 100)).toBe(true)
  184. fake.files.set('/proc/101/task/102/syscall', syscall(270, 1, 0x10))
  185. const fdSet = Buffer.alloc(0x11)
  186. fdSet[0x10] = 1
  187. fake.memories.set('/proc/101/mem', fdSet)
  188. expect(inspector.isStdinWaiting(77, 100)).toBe(true)
  189. const poll = Buffer.alloc(8)
  190. poll.writeInt32LE(0, 0)
  191. poll.writeInt16LE(1, 4)
  192. fake.files.set('/proc/101/task/102/syscall', syscall(7, 0x20, 1))
  193. fake.memories.set('/proc/101/mem', Buffer.concat([Buffer.alloc(0x20), poll]))
  194. expect(inspector.isStdinWaiting(77, 100)).toBe(true)
  195. fake.files.set('/proc/101/task/102/syscall', syscall(232, 5, 0, 1))
  196. fake.files.set('/proc/101/task/102/fdinfo/5', 'pos: 0\ntfd: 0 events: 19\n')
  197. expect(inspector.isStdinWaiting(77, 100)).toBe(true)
  198. })
  199. it('uses the waiting thread fd table and recognizes the controlling-terminal alias', () => {
  200. const fake = fakeInternals()
  201. fake.dirs.set('/proc', ['100'])
  202. fake.files.set('/proc/99/stat', stat(99, 99, 99, 77, '0'))
  203. fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
  204. fake.dirs.set('/proc/100/task', ['100'])
  205. fake.files.set('/proc/100/task/100/syscall', syscall(0, 0))
  206. fake.links.set('/proc/99/fd/0', '/dev/pts/1')
  207. fake.devices.set('/proc/99/fd/0', { character: true, rdev: 99 })
  208. fake.links.set('/proc/100/fd/0', '/dev/pts/1')
  209. fake.devices.set('/proc/100/fd/0', { character: true, rdev: 99 })
  210. fake.links.set('/proc/100/task/100/fd/0', 'pipe:[123]')
  211. fake.devices.set('/proc/100/task/100/fd/0', { character: false, rdev: 0 })
  212. const inspector = createProcessInspector('linux', 'x64', fake.internals)
  213. expect(inspector.isStdinWaiting(77, 99)).toBe(false)
  214. fake.links.delete('/proc/100/task/100/fd/0')
  215. expect(inspector.isStdinWaiting(77, 99)).toBe(false)
  216. fake.links.set('/proc/100/task/100/fd/0', '/dev/tty')
  217. expect(inspector.isStdinWaiting(77, 99)).toBe(true)
  218. fake.links.set('/proc/100/task/100/fd/0', '/dev/pts/2')
  219. fake.devices.set('/proc/100/task/100/fd/0', { character: true, rdev: 100 })
  220. expect(inspector.isStdinWaiting(77, 99)).toBe(false)
  221. })
  222. it('fails closed on unsupported, malformed, unreadable, or non-stdin waits', () => {
  223. const fake = fakeInternals()
  224. fake.dirs.set('/proc', ['100'])
  225. fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
  226. fake.dirs.set('/proc/100/task', ['100'])
  227. fake.links.set('/proc/100/fd/0', '/dev/pts/1')
  228. fake.devices.set('/proc/100/fd/0', { character: true, rdev: 99 })
  229. fake.links.set('/proc/100/task/100/fd/0', '/dev/pts/1')
  230. fake.devices.set('/proc/100/task/100/fd/0', { character: true, rdev: 99 })
  231. fake.files.set('/proc/100/task/100/syscall', syscall(0, 2))
  232. expect(createProcessInspector('linux', 'mips', fake.internals).isStdinWaiting(77, 100)).toBe(false)
  233. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false)
  234. fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0))
  235. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false)
  236. fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 0))
  237. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false)
  238. fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 1))
  239. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false)
  240. fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1))
  241. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false)
  242. fake.files.set('/proc/100/task/100/syscall', syscall(232, 9, 0, 1))
  243. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false)
  244. fake.files.set('/proc/100/task/100/syscall', syscall(999))
  245. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false)
  246. fake.files.set('/proc/100/task/100/syscall', 'not-a-number 0x0')
  247. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false)
  248. fake.dirs.delete('/proc/100/task')
  249. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false)
  250. fake.dirs.set('/proc', ['100', '200'])
  251. fake.files.set('/proc/200/stat', stat(200, 88, 200, 88, '2'))
  252. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false)
  253. })
  254. it('contains unreadable syscall, memory, and fdinfo boundaries', () => {
  255. const fake = fakeInternals()
  256. fake.dirs.set('/proc', ['100'])
  257. fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
  258. fake.dirs.set('/proc/100/task', ['100'])
  259. const inspector = createProcessInspector('linux', 'x64', fake.internals)
  260. fake.files.delete('/proc/100/stat')
  261. expect(inspector.isStdinWaiting(77, 100)).toBe(false)
  262. fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1', 1, 'S', 0))
  263. expect(inspector.isStdinWaiting(77, 100)).toBe(false)
  264. fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
  265. expect(inspector.isStdinWaiting(77, 100)).toBe(false)
  266. fake.links.set('/proc/100/fd/0', '/dev/pts/1')
  267. expect(inspector.isStdinWaiting(77, 100)).toBe(false)
  268. fake.devices.set('/proc/100/fd/0', { character: true, rdev: 99 })
  269. fake.links.set('/proc/100/task/100/fd/0', '/dev/pts/1')
  270. fake.devices.set('/proc/100/task/100/fd/0', { character: true, rdev: 99 })
  271. expect(inspector.isStdinWaiting(77, 100)).toBe(false)
  272. fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0x10))
  273. expect(inspector.isStdinWaiting(77, 100)).toBe(false)
  274. fake.files.set('/proc/100/task/100/syscall', syscall(232, 5, 0, 1))
  275. expect(inspector.isStdinWaiting(77, 100)).toBe(false)
  276. const noStdinPoll = Buffer.alloc(0x28)
  277. noStdinPoll.writeInt32LE(2, 0x20)
  278. noStdinPoll.writeInt16LE(1, 0x24)
  279. fake.memories.set('/proc/100/mem', noStdinPoll)
  280. fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1))
  281. expect(inspector.isStdinWaiting(77, 100)).toBe(false)
  282. })
  283. })
  284. describe('macOS process inspector', () => {
  285. it('reads tpgid and process trees, contains cycles, and identity-fences signals', () => {
  286. const fake = fakeInternals()
  287. fake.setTpgid('55\n')
  288. fake.setPs(' 10 1 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n 12 11 Mon Jul 21 10:00:02 2026\n 13 99 Mon Jul 21 10:00:03 2026\nmalformed\n')
  289. const inspector = createProcessInspector('darwin', 'arm64', fake.internals)
  290. expect(inspector.foregroundPgid(10)).toBe(55)
  291. expect(inspector.isStdinWaiting(55, 10)).toBe(false)
  292. const observed = inspector.snapshot()
  293. expect(observed.tree(10)).toEqual([
  294. { pid: 12, started: 'Mon Jul 21 10:00:02 2026' },
  295. { pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
  296. { pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
  297. ])
  298. expect(observed.tree(99)).toEqual([])
  299. expect(observed.session(10)).toEqual([])
  300. expect(observed.alive({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' })).toBe(true)
  301. inspector.signalGroup(55, 'SIGTSTP')
  302. inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL')
  303. inspector.signalProcess({ pid: 12, started: 'missing' }, 'SIGTERM')
  304. expect(fake.kills).toEqual([[-55, 'SIGTSTP'], [11, 'SIGKILL']])
  305. fake.setPs(' 10 11 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n')
  306. expect(inspector.snapshot().tree(10)).toEqual([
  307. { pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
  308. { pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
  309. ])
  310. })
  311. it('re-reads the process table before signalling instead of trusting an earlier observation', () => {
  312. const fake = fakeInternals()
  313. fake.setPs(' 11 10 Mon Jul 21 10:00:01 2026\n')
  314. const inspector = createProcessInspector('darwin', 'arm64', fake.internals)
  315. inspector.snapshot()
  316. // The member exits after that observation; a recycled pid would otherwise
  317. // inherit the observed identity and take the signal meant for the original.
  318. fake.setPs('')
  319. inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL')
  320. expect(fake.kills).toEqual([])
  321. })
  322. it('returns undefined for missing or invalid foreground groups and dispatches platform inspectors', () => {
  323. const fake = fakeInternals()
  324. fake.setTpgid('-1')
  325. expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
  326. fake.internals.exec = () => { throw new Error('gone') }
  327. expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
  328. expect(createProcessInspector('win32', 'x64', fake.internals)).toBeInstanceOf(WindowsProcessInspector)
  329. expect(() => createProcessInspector('freebsd', 'x64', fake.internals)).toThrow('unsupported on platform freebsd')
  330. })
  331. })