process-inspector.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  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. function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1, state = 'S'): string {
  9. const rest = [state, String(parentPid), String(pgrp), String(session), '99', String(tpgid)]
  10. while (rest.length < 19) rest.push('0')
  11. rest.push(started)
  12. return `${pid} (command with space) ${rest.join(' ')}`
  13. }
  14. function syscall(number: number, ...args: number[]): string {
  15. const six = [...args]
  16. while (six.length < 6) six.push(0)
  17. return `${number} ${six.slice(0, 6).map(value => `0x${value.toString(16)}`).join(' ')}`
  18. }
  19. function fakeInternals() {
  20. const files = new Map<string, string>()
  21. const dirs = new Map<string, string[]>()
  22. const memories = new Map<string, Buffer>()
  23. const fds = new Map<number, string>()
  24. const kills: Array<[number, NodeJS.Signals]> = []
  25. let nextFd = 10
  26. let ps = ''
  27. let tpgid = '0'
  28. const internals: ProcessInspectorInternals = {
  29. readFile(path) {
  30. const value = files.get(path)
  31. if (value === undefined) throw new Error(`missing ${path}`)
  32. return value
  33. },
  34. readDir(path) {
  35. const value = dirs.get(path)
  36. if (value === undefined) throw new Error(`missing ${path}`)
  37. return value
  38. },
  39. open(path) {
  40. if (!memories.has(path)) throw new Error(`missing ${path}`)
  41. const fd = nextFd++
  42. fds.set(fd, path)
  43. return fd
  44. },
  45. read(fd, buffer, length, position) {
  46. const path = fds.get(fd)
  47. if (path === undefined) throw new Error('bad fd')
  48. const source = memories.get(path)
  49. if (source === undefined) throw new Error('missing memory')
  50. return source.copy(buffer, 0, position, Math.min(source.length, position + length))
  51. },
  52. close(fd) { fds.delete(fd) },
  53. exec(_file, args) {
  54. if (args.includes('tpgid=')) return tpgid
  55. return ps
  56. },
  57. kill(pid, signal) { kills.push([pid, signal]) },
  58. }
  59. return {
  60. internals, files, dirs, memories, kills,
  61. setPs(value: string) { ps = value },
  62. setTpgid(value: string) { tpgid = value },
  63. }
  64. }
  65. describe('Linux process inspector', () => {
  66. it('treats zombie-only process groups as quiescent and fails closed when unobservable', () => {
  67. const fake = fakeInternals()
  68. expect(linuxProcessGroupHasLiveMembers(77, fake.internals)).toBeUndefined()
  69. fake.dirs.set('/proc', ['self', '10', '11', '12'])
  70. fake.files.set('/proc/10/stat', stat(10, 77, 10, -1, '500', 1, 'Z'))
  71. fake.files.set('/proc/11/stat', stat(11, 77, 10, -1, '501', 1, 'X'))
  72. fake.files.set('/proc/12/stat', stat(12, 88, 12, -1, '502'))
  73. expect(linuxProcessGroupHasLiveMembers(77, fake.internals)).toBe(false)
  74. expect(linuxProcessGroupHasLiveMembers(99, fake.internals)).toBeUndefined()
  75. fake.files.set('/proc/11/stat', stat(11, 77, 10, -1, '501'))
  76. expect(linuxProcessGroupHasLiveMembers(77, fake.internals)).toBe(true)
  77. })
  78. it('parses stat safely, captures only the rooted process tree, and signals identities', () => {
  79. expect(parseProcStat('bad')).toBeUndefined()
  80. expect(parseProcStat('1 () ')).toBeUndefined()
  81. expect(parseProcStat('1 () S')).toBeUndefined()
  82. expect(parseProcStat(stat(10, 20, 30, 40, '500', 1, 'SS'))).toBeUndefined()
  83. expect(parseProcStat(stat(10, 20, 30, 40, '500'))).toEqual({ pid: 10, parentPid: 1, pgrp: 20, session: 30, state: 'S', tpgid: 40, started: '500' })
  84. const fake = fakeInternals()
  85. fake.dirs.set('/proc', ['x', '10', '11', '12', '13', '14'])
  86. fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500'))
  87. fake.files.set('/proc/11/stat', stat(11, 21, 30, -1, '501'))
  88. fake.files.set('/proc/12/stat', stat(12, 22, 30, -1, '502', 10))
  89. fake.files.set('/proc/13/stat', stat(13, 23, 30, -1, '503', 12))
  90. const inspector = createProcessInspector('linux', 'x64', fake.internals)
  91. expect(inspector.foregroundPgid(10)).toBe(40)
  92. expect(inspector.foregroundPgid(11)).toBeUndefined()
  93. expect(inspector.foregroundPgid(99)).toBeUndefined()
  94. expect(inspector.processTree(10)).toEqual([
  95. { pid: 13, started: '503' },
  96. { pid: 12, started: '502' },
  97. { pid: 10, started: '500' },
  98. ])
  99. expect(inspector.processTree(99)).toEqual([])
  100. expect(inspector.processSession(30)).toEqual([
  101. { pid: 10, started: '500' },
  102. { pid: 11, started: '501' },
  103. { pid: 12, started: '502' },
  104. { pid: 13, started: '503' },
  105. ])
  106. expect(inspector.processSession(99)).toEqual([])
  107. expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(true)
  108. expect(inspector.isAlive({ pid: 10, started: 'old' })).toBe(false)
  109. inspector.signalGroup(40, 'SIGINT')
  110. inspector.signalProcess({ pid: 10, started: '500' }, 'SIGTERM')
  111. inspector.signalProcess({ pid: 10, started: 'old' }, 'SIGKILL')
  112. expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
  113. fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500', 1, 'Z'))
  114. expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(false)
  115. inspector.signalProcess({ pid: 10, started: '500' }, 'SIGKILL')
  116. expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
  117. })
  118. it('detects read, select, poll, and epoll waits across non-leader threads', () => {
  119. const fake = fakeInternals()
  120. fake.dirs.set('/proc', ['100', '101'])
  121. fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
  122. fake.files.set('/proc/101/stat', stat(101, 77, 100, 77, '2'))
  123. fake.dirs.set('/proc/100/task', ['100'])
  124. fake.dirs.set('/proc/101/task', ['101', '102'])
  125. const inspector = createProcessInspector('linux', 'x64', fake.internals)
  126. fake.files.set('/proc/100/task/100/syscall', 'running')
  127. fake.files.set('/proc/101/task/101/syscall', '-1 0x0')
  128. fake.files.set('/proc/101/task/102/syscall', syscall(0, 0))
  129. expect(inspector.isStdinWaiting(77)).toBe(true)
  130. fake.files.set('/proc/101/task/102/syscall', syscall(270, 1, 0x10))
  131. const fdSet = Buffer.alloc(0x11)
  132. fdSet[0x10] = 1
  133. fake.memories.set('/proc/101/mem', fdSet)
  134. expect(inspector.isStdinWaiting(77)).toBe(true)
  135. const poll = Buffer.alloc(8)
  136. poll.writeInt32LE(0, 0)
  137. poll.writeInt16LE(1, 4)
  138. fake.files.set('/proc/101/task/102/syscall', syscall(7, 0x20, 1))
  139. fake.memories.set('/proc/101/mem', Buffer.concat([Buffer.alloc(0x20), poll]))
  140. expect(inspector.isStdinWaiting(77)).toBe(true)
  141. fake.files.set('/proc/101/task/102/syscall', syscall(232, 5, 0, 1))
  142. fake.files.set('/proc/101/fdinfo/5', 'pos: 0\ntfd: 0 events: 19\n')
  143. expect(inspector.isStdinWaiting(77)).toBe(true)
  144. })
  145. it('fails closed on unsupported, malformed, unreadable, or non-stdin waits', () => {
  146. const fake = fakeInternals()
  147. fake.dirs.set('/proc', ['100'])
  148. fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
  149. fake.dirs.set('/proc/100/task', ['100'])
  150. fake.files.set('/proc/100/task/100/syscall', syscall(0, 2))
  151. expect(createProcessInspector('linux', 'mips', fake.internals).isStdinWaiting(77)).toBe(false)
  152. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
  153. fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0))
  154. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
  155. fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 0))
  156. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
  157. fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 1))
  158. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
  159. fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1))
  160. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
  161. fake.files.set('/proc/100/task/100/syscall', syscall(232, 9, 0, 1))
  162. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
  163. fake.files.set('/proc/100/task/100/syscall', syscall(999))
  164. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
  165. fake.files.set('/proc/100/task/100/syscall', 'not-a-number 0x0')
  166. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
  167. fake.dirs.delete('/proc/100/task')
  168. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
  169. fake.dirs.set('/proc', ['100', '200'])
  170. fake.files.set('/proc/200/stat', stat(200, 88, 200, 88, '2'))
  171. expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
  172. })
  173. it('contains unreadable syscall, memory, and fdinfo boundaries', () => {
  174. const fake = fakeInternals()
  175. fake.dirs.set('/proc', ['100'])
  176. fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
  177. fake.dirs.set('/proc/100/task', ['100'])
  178. const inspector = createProcessInspector('linux', 'x64', fake.internals)
  179. expect(inspector.isStdinWaiting(77)).toBe(false)
  180. fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0x10))
  181. expect(inspector.isStdinWaiting(77)).toBe(false)
  182. fake.files.set('/proc/100/task/100/syscall', syscall(232, 5, 0, 1))
  183. expect(inspector.isStdinWaiting(77)).toBe(false)
  184. const noStdinPoll = Buffer.alloc(0x28)
  185. noStdinPoll.writeInt32LE(2, 0x20)
  186. noStdinPoll.writeInt16LE(1, 0x24)
  187. fake.memories.set('/proc/100/mem', noStdinPoll)
  188. fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1))
  189. expect(inspector.isStdinWaiting(77)).toBe(false)
  190. })
  191. })
  192. describe('macOS process inspector', () => {
  193. it('reads tpgid and process trees, contains cycles, and identity-fences signals', () => {
  194. const fake = fakeInternals()
  195. fake.setTpgid('55\n')
  196. 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')
  197. const inspector = createProcessInspector('darwin', 'arm64', fake.internals)
  198. expect(inspector.foregroundPgid(10)).toBe(55)
  199. expect(inspector.isStdinWaiting(55)).toBe(false)
  200. expect(inspector.processTree(10)).toEqual([
  201. { pid: 12, started: 'Mon Jul 21 10:00:02 2026' },
  202. { pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
  203. { pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
  204. ])
  205. expect(inspector.processTree(99)).toEqual([])
  206. expect(inspector.processSession(10)).toEqual([])
  207. expect(inspector.isAlive({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' })).toBe(true)
  208. inspector.signalGroup(55, 'SIGTSTP')
  209. inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL')
  210. inspector.signalProcess({ pid: 12, started: 'missing' }, 'SIGTERM')
  211. expect(fake.kills).toEqual([[-55, 'SIGTSTP'], [11, 'SIGKILL']])
  212. fake.setPs(' 10 11 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n')
  213. expect(inspector.processTree(10)).toEqual([
  214. { pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
  215. { pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
  216. ])
  217. })
  218. it('returns undefined for missing or invalid foreground groups and rejects unsupported platforms', () => {
  219. const fake = fakeInternals()
  220. fake.setTpgid('-1')
  221. expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
  222. fake.internals.exec = () => { throw new Error('gone') }
  223. expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
  224. expect(() => createProcessInspector('win32', 'x64', fake.internals)).toThrow('unsupported on platform win32')
  225. })
  226. })