process-inspector.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. /** Platform process-table inspection for terminal readiness, signals, and teardown. */
  2. import { closeSync, openSync, readFileSync, readdirSync, readSync } from 'node:fs'
  3. import { execFileSync } from 'node:child_process'
  4. import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess'
  5. import { createWindowsProcessInspector } from './windows-inspector.ts'
  6. /** PID plus start identity, preventing teardown escalation after PID reuse. */
  7. export interface ProcessIdentity {
  8. pid: number
  9. started: string
  10. }
  11. /** Injectable OS process operations used by one local PTY session. */
  12. export interface ProcessInspector {
  13. foregroundPgid(shellPid: number): number | undefined
  14. isStdinWaiting(pgid: number): boolean
  15. /** Return the root and its current transitive descendants, children first. */
  16. processTree(rootPid: number): ProcessIdentity[]
  17. /** Return current members of one POSIX process session when the platform exposes them. */
  18. processSession(sessionId: number): ProcessIdentity[]
  19. /** Return whether the exact identity remains a non-quiescent process. */
  20. isAlive(identity: ProcessIdentity): boolean
  21. signalGroup(pgid: number, signal: SubprocessTerminalSignal): void
  22. signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void
  23. }
  24. /** Testable boundary around filesystem, process-table, and signal syscalls. */
  25. export interface ProcessInspectorInternals {
  26. readFile(path: string): string
  27. readDir(path: string): string[]
  28. open(path: string): number
  29. read(fd: number, buffer: Buffer, length: number, position: number): number
  30. close(fd: number): void
  31. exec(file: string, args: string[]): string
  32. kill(pid: number, signal: NodeJS.Signals): void
  33. }
  34. /* v8 ignore start -- thin OS bindings; injected logic is unit-tested and real platform composition exercises them. */
  35. const DEFAULT_INTERNALS: ProcessInspectorInternals = {
  36. readFile: path => readFileSync(path, 'utf8'),
  37. readDir: path => readdirSync(path),
  38. open: path => openSync(path, 'r'),
  39. read: (fd, buffer, length, position) => readSync(fd, buffer, 0, length, position),
  40. close: closeSync,
  41. exec: (file, args) => execFileSync(file, args, { encoding: 'utf8' }),
  42. kill: (pid, signal) => process.kill(pid, signal),
  43. }
  44. /* v8 ignore stop */
  45. interface ProcStat {
  46. pid: number
  47. parentPid: number
  48. pgrp: number
  49. session: number
  50. state: string
  51. tpgid: number
  52. started: string
  53. }
  54. /**
  55. * Parse fields used from Linux `/proc/<pid>/stat`, including parenthesized comm text.
  56. * @param text - complete stat line.
  57. * @returns Parsed identity/group fields, or undefined for malformed input.
  58. */
  59. export function parseProcStat(text: string): ProcStat | undefined {
  60. const open = text.indexOf('(')
  61. const close = text.lastIndexOf(')')
  62. if (open <= 0 || close <= open) return undefined
  63. const pid = Number(text.slice(0, open).trim())
  64. const rest = text.slice(close + 2).trim().split(/\s+/)
  65. const state = rest[0] || ''
  66. const parentPid = Number(rest[1])
  67. const pgrp = Number(rest[2])
  68. const session = Number(rest[3])
  69. const tpgid = Number(rest[5])
  70. const started = rest[19]
  71. if (![pid, parentPid, pgrp, session, tpgid].every(Number.isSafeInteger)
  72. || state.length !== 1 || started === undefined) return undefined
  73. return { pid, parentPid, pgrp, session, state, tpgid, started }
  74. }
  75. function readLinuxStat(internals: ProcessInspectorInternals, pid: number): ProcStat | undefined {
  76. try {
  77. return parseProcStat(internals.readFile(`/proc/${pid}/stat`))
  78. } catch (_unreadableProcEntry) {
  79. return undefined
  80. }
  81. }
  82. /**
  83. * Report whether a Linux process group has an executing member. `false`
  84. * means the group contains only zombie/dead entries; `undefined` means the
  85. * process table could not prove either outcome.
  86. * @param processGroupId - POSIX process-group id to inspect.
  87. * @param internals - injectable process-table operations.
  88. * @returns Live-member presence, or `undefined` when unavailable/absent.
  89. */
  90. export function linuxProcessGroupHasLiveMembers(
  91. processGroupId: number,
  92. internals: ProcessInspectorInternals = DEFAULT_INTERNALS,
  93. ): boolean | undefined {
  94. let entries: string[]
  95. try {
  96. entries = internals.readDir('/proc')
  97. } catch (_unreadableProcDirectory) {
  98. return undefined
  99. }
  100. let matched = false
  101. for (const entry of entries) {
  102. if (!/^\d+$/.test(entry)) continue
  103. const stat = readLinuxStat(internals, Number(entry))
  104. if (stat?.pgrp !== processGroupId) continue
  105. matched = true
  106. if (!/^[ZXx]$/.test(stat.state)) return true
  107. }
  108. return matched ? false : undefined
  109. }
  110. function numericEntries(internals: ProcessInspectorInternals, path: string): number[] {
  111. try {
  112. return internals.readDir(path).filter(entry => /^\d+$/.test(entry)).map(Number)
  113. } catch (_unreadableProcDirectory) {
  114. return []
  115. }
  116. }
  117. interface SyscallInfo {
  118. number: number
  119. args: number[]
  120. }
  121. function readSyscall(internals: ProcessInspectorInternals, pid: number, tid: number): SyscallInfo | undefined {
  122. try {
  123. const text = internals.readFile(`/proc/${pid}/task/${tid}/syscall`).trim()
  124. if (text === 'running' || text.startsWith('-1 ')) return undefined
  125. const fields = text.split(/\s+/)
  126. const number = Number(fields[0])
  127. const args = fields.slice(1, 7).map(field => Number.parseInt(field, 16))
  128. if (!Number.isSafeInteger(number) || args.some(value => !Number.isSafeInteger(value))) return undefined
  129. return { number, args }
  130. } catch (_unreadableSyscall) {
  131. return undefined
  132. }
  133. }
  134. function readMemory(
  135. internals: ProcessInspectorInternals,
  136. pid: number,
  137. address: number,
  138. length: number,
  139. ): Buffer | undefined {
  140. let fd: number | undefined
  141. try {
  142. fd = internals.open(`/proc/${pid}/mem`)
  143. const buffer = Buffer.alloc(length)
  144. const count = internals.read(fd, buffer, length, address)
  145. return buffer.subarray(0, count)
  146. } catch (_unreadableProcessMemory) {
  147. return undefined
  148. } finally {
  149. if (fd !== undefined) internals.close(fd)
  150. }
  151. }
  152. function fdSetHasStdin(internals: ProcessInspectorInternals, pid: number, address: number): boolean {
  153. return address !== 0 && (readMemory(internals, pid, address, 8)?.[0] ?? 0) % 2 === 1
  154. }
  155. function pollHasStdin(
  156. internals: ProcessInspectorInternals,
  157. pid: number,
  158. address: number,
  159. count: number,
  160. ): boolean {
  161. if (address === 0 || count <= 0) return false
  162. const memory = readMemory(internals, pid, address, Math.min(count, 1024) * 8)
  163. if (memory === undefined) return false
  164. for (let offset = 0; offset + 8 <= memory.length; offset += 8) {
  165. if (memory.readInt32LE(offset) === 0 && (memory.readInt16LE(offset + 4) & 0x001) !== 0) return true
  166. }
  167. return false
  168. }
  169. function epollHasStdin(internals: ProcessInspectorInternals, pid: number, epfd: number): boolean {
  170. try {
  171. return internals.readFile(`/proc/${pid}/fdinfo/${epfd}`)
  172. .split('\n')
  173. .some(line => /^tfd:\s+0\b/.test(line.trim()))
  174. } catch (_unreadableFdInfo) {
  175. return false
  176. }
  177. }
  178. interface SyscallTable {
  179. read: number
  180. select?: number
  181. pselect: number
  182. poll?: number
  183. ppoll: number
  184. epollWait?: number
  185. epollPwait: number
  186. }
  187. const SYSCALLS: Partial<Record<NodeJS.Architecture, SyscallTable>> = {
  188. x64: { read: 0, select: 23, pselect: 270, poll: 7, ppoll: 271, epollWait: 232, epollPwait: 281 },
  189. arm64: { read: 63, pselect: 72, ppoll: 73, epollPwait: 22 },
  190. }
  191. function syscallWaitsOnStdin(
  192. internals: ProcessInspectorInternals,
  193. pid: number,
  194. syscall: SyscallInfo,
  195. table: SyscallTable,
  196. ): boolean {
  197. const [a0 = 0, a1 = 0, a2 = 0] = syscall.args
  198. if (syscall.number === table.read) return a0 === 0
  199. if (syscall.number === table.select || syscall.number === table.pselect) {
  200. return a0 >= 1 && fdSetHasStdin(internals, pid, a1)
  201. }
  202. if (syscall.number === table.poll || syscall.number === table.ppoll) {
  203. return a1 >= 1 && pollHasStdin(internals, pid, a0, a1)
  204. }
  205. if (syscall.number === table.epollWait || syscall.number === table.epollPwait) {
  206. return a2 >= 1 && epollHasStdin(internals, pid, a0)
  207. }
  208. return false
  209. }
  210. abstract class PosixProcessInspector implements ProcessInspector {
  211. constructor(protected readonly internals: ProcessInspectorInternals) {}
  212. abstract foregroundPgid(shellPid: number): number | undefined
  213. abstract isStdinWaiting(pgid: number): boolean
  214. abstract processTree(rootPid: number): ProcessIdentity[]
  215. abstract processSession(sessionId: number): ProcessIdentity[]
  216. abstract isAlive(identity: ProcessIdentity): boolean
  217. signalGroup(pgid: number, signal: SubprocessTerminalSignal): void {
  218. this.internals.kill(-pgid, signal)
  219. }
  220. signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void {
  221. if (this.isAlive(identity)) this.internals.kill(identity.pid, signal)
  222. }
  223. }
  224. interface ProcessTreeEntry extends ProcessIdentity {
  225. parentPid: number
  226. }
  227. function processTree(entries: ProcessTreeEntry[], rootPid: number): ProcessIdentity[] {
  228. const byPid = new Map(entries.map(entry => [entry.pid, entry]))
  229. const root = byPid.get(rootPid)
  230. if (root === undefined) return []
  231. const byParent = new Map<number, ProcessTreeEntry[]>()
  232. for (const entry of entries) {
  233. const children = byParent.get(entry.parentPid) ?? []
  234. children.push(entry)
  235. byParent.set(entry.parentPid, children)
  236. }
  237. const visited = new Set<number>()
  238. const result: ProcessIdentity[] = []
  239. const visit = (entry: ProcessTreeEntry): void => {
  240. if (visited.has(entry.pid)) return
  241. visited.add(entry.pid)
  242. for (const child of byParent.get(entry.pid) ?? []) visit(child)
  243. result.push({ pid: entry.pid, started: entry.started })
  244. }
  245. visit(root)
  246. return result
  247. }
  248. class LinuxProcessInspector extends PosixProcessInspector {
  249. constructor(
  250. private readonly arch: NodeJS.Architecture,
  251. internals: ProcessInspectorInternals,
  252. ) {
  253. super(internals)
  254. }
  255. foregroundPgid(shellPid: number): number | undefined {
  256. const tpgid = readLinuxStat(this.internals, shellPid)?.tpgid
  257. return tpgid !== undefined && tpgid > 0 ? tpgid : undefined
  258. }
  259. isStdinWaiting(pgid: number): boolean {
  260. const table = SYSCALLS[this.arch]
  261. if (table === undefined) return false
  262. for (const pid of numericEntries(this.internals, '/proc')) {
  263. if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue
  264. for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) {
  265. const syscall = readSyscall(this.internals, pid, tid)
  266. if (syscall !== undefined && syscallWaitsOnStdin(this.internals, pid, syscall, table)) return true
  267. }
  268. }
  269. return false
  270. }
  271. processTree(rootPid: number): ProcessIdentity[] {
  272. const entries = numericEntries(this.internals, '/proc').flatMap((pid) => {
  273. const stat = readLinuxStat(this.internals, pid)
  274. return stat === undefined ? [] : [{ pid, parentPid: stat.parentPid, started: stat.started }]
  275. })
  276. return processTree(entries, rootPid)
  277. }
  278. processSession(sessionId: number): ProcessIdentity[] {
  279. return numericEntries(this.internals, '/proc').flatMap((pid) => {
  280. const stat = readLinuxStat(this.internals, pid)
  281. return stat?.session === sessionId ? [{ pid, started: stat.started }] : []
  282. })
  283. }
  284. isAlive(identity: ProcessIdentity): boolean {
  285. const stat = readLinuxStat(this.internals, identity.pid)
  286. return stat?.started === identity.started && !/^[ZXx]$/.test(stat.state)
  287. }
  288. }
  289. interface PsEntry extends ProcessTreeEntry {}
  290. function macProcessTable(internals: ProcessInspectorInternals): PsEntry[] {
  291. return internals.exec('/bin/ps', ['-axo', 'pid=,ppid=,lstart=']).split('\n').flatMap((line) => {
  292. const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line)
  293. if (match?.[1] === undefined || match[2] === undefined || match[3] === undefined) return []
  294. return [{ pid: Number(match[1]), parentPid: Number(match[2]), started: match[3] }]
  295. })
  296. }
  297. class MacProcessInspector extends PosixProcessInspector {
  298. foregroundPgid(shellPid: number): number | undefined {
  299. try {
  300. const value = Number(this.internals.exec('/bin/ps', ['-o', 'tpgid=', '-p', String(shellPid)]).trim())
  301. return Number.isSafeInteger(value) && value > 0 ? value : undefined
  302. } catch (_missingProcess) {
  303. return undefined
  304. }
  305. }
  306. isStdinWaiting(_pgid: number): boolean {
  307. return false
  308. }
  309. processTree(rootPid: number): ProcessIdentity[] {
  310. return processTree(macProcessTable(this.internals), rootPid)
  311. }
  312. processSession(_sessionId: number): ProcessIdentity[] {
  313. return []
  314. }
  315. isAlive(identity: ProcessIdentity): boolean {
  316. return macProcessTable(this.internals).some(entry => entry.pid === identity.pid && entry.started === identity.started)
  317. }
  318. }
  319. /**
  320. * Create the supported platform inspector or fail at plugin load.
  321. * @param platform - target Node platform.
  322. * @param arch - target CPU architecture for Linux syscall numbers.
  323. * @param internals - filesystem/process boundary, injectable for deterministic tests.
  324. * @returns Platform process inspector.
  325. */
  326. export function createProcessInspector(
  327. platform: NodeJS.Platform = process.platform,
  328. arch: NodeJS.Architecture = process.arch,
  329. internals: ProcessInspectorInternals = DEFAULT_INTERNALS,
  330. ): ProcessInspector {
  331. if (platform === 'linux') return new LinuxProcessInspector(arch, internals)
  332. if (platform === 'darwin') return new MacProcessInspector(internals)
  333. if (platform === 'win32') return createWindowsProcessInspector()
  334. throw new Error(`subprocess-local: terminal inspection is unsupported on platform ${platform}`)
  335. }