process-inspector.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551
  1. /** Platform process-table inspection for terminal readiness, signals, and teardown. */
  2. import { closeSync, openSync, readFileSync, readdirSync, readlinkSync, readSync, statSync } 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. interface FileStatus {
  12. readonly rdev: number
  13. isCharacterDevice(): boolean
  14. }
  15. /**
  16. * One observation of the platform process table, shared by every question a
  17. * single readiness poll or teardown pass asks.
  18. *
  19. * The table is read at most once, on the first question that needs it — a
  20. * `/bin/ps` fork on macOS, a `/proc` walk on Linux, a Toolhelp32 enumeration on
  21. * Windows. Later questions never re-read it, which is what keeps a poll's cost
  22. * independent of how many descendants the running command spawned. Windows
  23. * liveness needs no table at all: wait state is a per-handle question there, so
  24. * a snapshot asked only for liveness never enumerates.
  25. *
  26. * A snapshot answers what the process table showed, which is what batch
  27. * filtering wants and what signalling must not use: {@link ProcessInspector.isAlive}
  28. * is the fence a signal takes, because it reads current state instead.
  29. */
  30. export interface ProcessSnapshot {
  31. /** Whether the process-table scan omitted no unreadable rows; absent means unverified. */
  32. readonly complete?: boolean
  33. /**
  34. * Return the root and its transitive descendants as observed, children first.
  35. * @param rootPid - tree root to descend from.
  36. * @returns Observed root and descendants, children before parents.
  37. */
  38. tree(rootPid: number): ProcessIdentity[]
  39. /**
  40. * Return observed members of one POSIX process session.
  41. * @param sessionId - POSIX session identifier.
  42. * @returns Observed session members, empty where the platform's table omits session ids.
  43. */
  44. session(sessionId: number): ProcessIdentity[]
  45. /**
  46. * Return whether the exact identity was a non-quiescent process.
  47. * @param identity - PID plus start identity to match.
  48. * @returns Whether that exact identity — not merely that PID — was running.
  49. */
  50. alive(identity: ProcessIdentity): boolean
  51. }
  52. /** Injectable OS process operations used by one local PTY session. */
  53. export interface ProcessInspector {
  54. foregroundPgid(shellPid: number): number | undefined
  55. /**
  56. * Report whether the foreground group waits on the terminal shell's stdin.
  57. *
  58. * @param pgid Foreground process-group identifier.
  59. * @param shellPid Persistent terminal shell process identifier.
  60. * @returns Whether a group member is blocked reading the shell's terminal input.
  61. */
  62. isStdinWaiting(pgid: number, shellPid: number): boolean
  63. /**
  64. * Read the process table once and answer tree, session, and liveness from it.
  65. * @returns A process-table observation whose reads are shared.
  66. * @throws when the platform process table cannot be enumerated.
  67. */
  68. snapshot(): ProcessSnapshot
  69. /**
  70. * Return whether the exact identity is a non-quiescent process right now.
  71. *
  72. * Reads the narrowest per-identity source the platform offers rather than a
  73. * whole table, so a signalling round can re-check every target without
  74. * paying for a scan. Callers filtering many members at once want
  75. * {@link ProcessSnapshot.alive} instead.
  76. *
  77. * @param identity - PID plus start identity to match.
  78. * @returns Whether that exact identity — not merely that PID — is running.
  79. */
  80. isAlive(identity: ProcessIdentity): boolean
  81. signalGroup(pgid: number, signal: SubprocessTerminalSignal): void
  82. /**
  83. * Signal one exact process identity, fenced against PID reuse.
  84. *
  85. * The fence reads current state immediately before the signal. An observation
  86. * taken earlier in the same round cannot stand in for it: the observation
  87. * preserves the original PID-to-start-time pairing, so a recycled PID would
  88. * still match and take a signal meant for the process that exited.
  89. *
  90. * @param identity - PID plus start identity to signal.
  91. * @param signal - termination signal to deliver.
  92. */
  93. signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void
  94. }
  95. /** Testable boundary around filesystem, process-table, and signal syscalls. */
  96. export interface ProcessInspectorInternals {
  97. readFile(path: string): string
  98. readDir(path: string): string[]
  99. readLink(path: string): string
  100. stat(path: string): FileStatus
  101. open(path: string): number
  102. read(fd: number, buffer: Buffer, length: number, position: number): number
  103. close(fd: number): void
  104. exec(file: string, args: string[]): string
  105. kill(pid: number, signal: NodeJS.Signals): void
  106. }
  107. /* v8 ignore start -- thin OS bindings; injected logic is unit-tested and real platform composition exercises them. */
  108. const DEFAULT_INTERNALS: ProcessInspectorInternals = {
  109. readFile: path => readFileSync(path, 'utf8'),
  110. readDir: path => readdirSync(path),
  111. readLink: path => readlinkSync(path, 'utf8'),
  112. stat: path => statSync(path),
  113. open: path => openSync(path, 'r'),
  114. read: (fd, buffer, length, position) => readSync(fd, buffer, 0, length, position),
  115. close: closeSync,
  116. exec: (file, args) => execFileSync(file, args, { encoding: 'utf8' }),
  117. kill: (pid, signal) => process.kill(pid, signal),
  118. }
  119. /* v8 ignore stop */
  120. interface ProcStat {
  121. pid: number
  122. parentPid: number
  123. pgrp: number
  124. session: number
  125. state: string
  126. ttyDevice: number
  127. tpgid: number
  128. started: string
  129. }
  130. /**
  131. * Parse fields used from Linux `/proc/<pid>/stat`, including parenthesized comm text.
  132. * @param text - complete stat line.
  133. * @returns Parsed identity/group fields, or undefined for malformed input.
  134. */
  135. export function parseProcStat(text: string): ProcStat | undefined {
  136. const open = text.indexOf('(')
  137. const close = text.lastIndexOf(')')
  138. if (open <= 0 || close <= open) return undefined
  139. const pid = Number(text.slice(0, open).trim())
  140. const rest = text.slice(close + 2).trim().split(/\s+/)
  141. const state = rest[0] || ''
  142. const parentPid = Number(rest[1])
  143. const pgrp = Number(rest[2])
  144. const session = Number(rest[3])
  145. const ttyDevice = Number(rest[4])
  146. const tpgid = Number(rest[5])
  147. const started = rest[19]
  148. if (![pid, parentPid, pgrp, session, ttyDevice, tpgid].every(Number.isSafeInteger)
  149. || state.length !== 1 || started === undefined) return undefined
  150. return { pid, parentPid, pgrp, session, state, ttyDevice, tpgid, started }
  151. }
  152. function readLinuxStat(internals: ProcessInspectorInternals, pid: number): ProcStat | undefined {
  153. try {
  154. return parseProcStat(internals.readFile(`/proc/${pid}/stat`))
  155. } catch (_unreadableProcEntry) {
  156. return undefined
  157. }
  158. }
  159. // `/proc/<pid>/stat` renders tty_nr as a signed 32-bit device number, while
  160. // Node exposes the same st_rdev bits as a nonnegative number.
  161. function linuxDeviceNumber(value: number): number {
  162. return value >>> 0
  163. }
  164. // `/dev/tty` reports the alias device instead of the selected PTY through stat,
  165. // so its owning process's tty_nr is the only comparable terminal identity.
  166. function readLinuxTerminalDevice(
  167. internals: ProcessInspectorInternals,
  168. pid: number,
  169. ttyDevice: number,
  170. tid?: number,
  171. ): number | undefined {
  172. const terminalDevice = linuxDeviceNumber(ttyDevice)
  173. if (terminalDevice === 0) return undefined
  174. const path = tid === undefined ? `/proc/${pid}/fd/0` : `/proc/${pid}/task/${tid}/fd/0`
  175. try {
  176. const target = internals.readLink(path)
  177. if (target === '/dev/tty') return terminalDevice
  178. const status = internals.stat(path)
  179. return status.isCharacterDevice() && linuxDeviceNumber(status.rdev) === terminalDevice
  180. ? terminalDevice
  181. : undefined
  182. } catch (_unreadableStdinDevice) {
  183. return undefined
  184. }
  185. }
  186. /**
  187. * Report whether a Linux process group has an executing member. `false`
  188. * means the group contains only zombie/dead entries; `undefined` means the
  189. * process table could not prove either outcome.
  190. * @param processGroupId - POSIX process-group id to inspect.
  191. * @param internals - injectable process-table operations.
  192. * @returns Live-member presence, or `undefined` when unavailable/absent.
  193. */
  194. export function linuxProcessGroupHasLiveMembers(
  195. processGroupId: number,
  196. internals: ProcessInspectorInternals = DEFAULT_INTERNALS,
  197. ): boolean | undefined {
  198. let entries: string[]
  199. try {
  200. entries = internals.readDir('/proc')
  201. } catch (_unreadableProcDirectory) {
  202. return undefined
  203. }
  204. let matched = false
  205. for (const entry of entries) {
  206. if (!/^\d+$/.test(entry)) continue
  207. const stat = readLinuxStat(internals, Number(entry))
  208. if (stat?.pgrp !== processGroupId) continue
  209. matched = true
  210. if (!/^[ZXx]$/.test(stat.state)) return true
  211. }
  212. return matched ? false : undefined
  213. }
  214. function numericEntries(internals: ProcessInspectorInternals, path: string): number[] | undefined {
  215. try {
  216. return internals.readDir(path).filter(entry => /^\d+$/.test(entry)).map(Number)
  217. } catch (_unreadableProcDirectory) {
  218. return undefined
  219. }
  220. }
  221. interface SyscallInfo {
  222. number: number
  223. args: number[]
  224. }
  225. function readSyscall(internals: ProcessInspectorInternals, pid: number, tid: number): SyscallInfo | undefined {
  226. try {
  227. const text = internals.readFile(`/proc/${pid}/task/${tid}/syscall`).trim()
  228. if (text === 'running' || text.startsWith('-1 ')) return undefined
  229. const fields = text.split(/\s+/)
  230. const number = Number(fields[0])
  231. const args = fields.slice(1, 7).map(field => Number.parseInt(field, 16))
  232. if (!Number.isSafeInteger(number) || args.some(value => !Number.isSafeInteger(value))) return undefined
  233. return { number, args }
  234. } catch (_unreadableSyscall) {
  235. return undefined
  236. }
  237. }
  238. function readMemory(
  239. internals: ProcessInspectorInternals,
  240. pid: number,
  241. address: number,
  242. length: number,
  243. ): Buffer | undefined {
  244. let fd: number | undefined
  245. try {
  246. fd = internals.open(`/proc/${pid}/mem`)
  247. const buffer = Buffer.alloc(length)
  248. const count = internals.read(fd, buffer, length, address)
  249. return buffer.subarray(0, count)
  250. } catch (_unreadableProcessMemory) {
  251. return undefined
  252. } finally {
  253. if (fd !== undefined) internals.close(fd)
  254. }
  255. }
  256. function fdSetHasStdin(internals: ProcessInspectorInternals, pid: number, address: number): boolean {
  257. return address !== 0 && (readMemory(internals, pid, address, 8)?.[0] ?? 0) % 2 === 1
  258. }
  259. function pollHasStdin(
  260. internals: ProcessInspectorInternals,
  261. pid: number,
  262. address: number,
  263. count: number,
  264. ): boolean {
  265. if (address === 0 || count <= 0) return false
  266. const memory = readMemory(internals, pid, address, Math.min(count, 1024) * 8)
  267. if (memory === undefined) return false
  268. for (let offset = 0; offset + 8 <= memory.length; offset += 8) {
  269. if (memory.readInt32LE(offset) === 0 && (memory.readInt16LE(offset + 4) & 0x001) !== 0) return true
  270. }
  271. return false
  272. }
  273. function epollHasStdin(internals: ProcessInspectorInternals, pid: number, tid: number, epfd: number): boolean {
  274. try {
  275. return internals.readFile(`/proc/${pid}/task/${tid}/fdinfo/${epfd}`)
  276. .split('\n')
  277. .some(line => /^tfd:\s+0\b/.test(line.trim()))
  278. } catch (_unreadableFdInfo) {
  279. return false
  280. }
  281. }
  282. interface SyscallTable {
  283. read: number
  284. select?: number
  285. pselect: number
  286. poll?: number
  287. ppoll: number
  288. epollWait?: number
  289. epollPwait: number
  290. }
  291. const SYSCALLS: Partial<Record<NodeJS.Architecture, SyscallTable>> = {
  292. x64: { read: 0, select: 23, pselect: 270, poll: 7, ppoll: 271, epollWait: 232, epollPwait: 281 },
  293. arm64: { read: 63, pselect: 72, ppoll: 73, epollPwait: 22 },
  294. }
  295. const SUPPORTED_SYSCALL_TABLES = Object.values(SYSCALLS)
  296. // `/proc/<pid>/task/<tid>/syscall` uses the kernel ABI's numbers. User-mode
  297. // emulation can therefore expose a supported table different from process.arch.
  298. function linuxSyscallTables(arch: NodeJS.Architecture): readonly SyscallTable[] | undefined {
  299. const primary = SYSCALLS[arch]
  300. if (primary === undefined) return undefined
  301. return [primary, ...SUPPORTED_SYSCALL_TABLES.filter(table => table !== primary)]
  302. }
  303. function syscallWaitsOnStdin(
  304. internals: ProcessInspectorInternals,
  305. pid: number,
  306. tid: number,
  307. syscall: SyscallInfo,
  308. tables: readonly SyscallTable[],
  309. ): boolean {
  310. const [a0 = 0, a1 = 0, a2 = 0] = syscall.args
  311. for (const table of tables) {
  312. if (syscall.number === table.read) return a0 === 0
  313. if (syscall.number === table.select || syscall.number === table.pselect) {
  314. return a0 >= 1 && fdSetHasStdin(internals, pid, a1)
  315. }
  316. if (syscall.number === table.poll || syscall.number === table.ppoll) {
  317. return a1 >= 1 && pollHasStdin(internals, pid, a0, a1)
  318. }
  319. if (syscall.number === table.epollWait || syscall.number === table.epollPwait) {
  320. return a2 >= 1 && epollHasStdin(internals, pid, tid, a0)
  321. }
  322. }
  323. return false
  324. }
  325. abstract class PosixProcessInspector implements ProcessInspector {
  326. constructor(protected readonly internals: ProcessInspectorInternals) {}
  327. abstract foregroundPgid(shellPid: number): number | undefined
  328. abstract isStdinWaiting(pgid: number, shellPid: number): boolean
  329. abstract snapshot(): ProcessSnapshot
  330. abstract isAlive(identity: ProcessIdentity): boolean
  331. signalGroup(pgid: number, signal: SubprocessTerminalSignal): void {
  332. this.internals.kill(-pgid, signal)
  333. }
  334. signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void {
  335. if (this.isAlive(identity)) this.internals.kill(identity.pid, signal)
  336. }
  337. }
  338. interface ProcessTreeEntry extends ProcessIdentity {
  339. parentPid: number
  340. }
  341. /** One process-table row, carrying the fields a platform's table exposes. */
  342. interface ProcessRow extends ProcessTreeEntry {
  343. /** POSIX session identifier, or undefined where the table omits it. */
  344. session: number | undefined
  345. /** Single-letter process state, or undefined where the table omits it. */
  346. state: string | undefined
  347. }
  348. // Zombie and dead states answer "present in the table" but never "still
  349. // running"; a table without a state column can only report presence.
  350. function quiescent(state: string | undefined): boolean {
  351. return state !== undefined && /^[ZXx]$/.test(state)
  352. }
  353. class PosixProcessSnapshot implements ProcessSnapshot {
  354. private readonly byPid: Map<number, ProcessRow>
  355. constructor(private readonly rows: ProcessRow[], readonly complete: boolean) {
  356. this.byPid = new Map(rows.map(row => [row.pid, row]))
  357. }
  358. tree(rootPid: number): ProcessIdentity[] {
  359. return processTree(this.rows, rootPid)
  360. }
  361. session(sessionId: number): ProcessIdentity[] {
  362. return this.rows.flatMap(row =>
  363. row.session === sessionId ? [{ pid: row.pid, started: row.started }] : [])
  364. }
  365. alive(identity: ProcessIdentity): boolean {
  366. const row = this.byPid.get(identity.pid)
  367. return row?.started === identity.started && !quiescent(row.state)
  368. }
  369. }
  370. function processTree(entries: ProcessTreeEntry[], rootPid: number): ProcessIdentity[] {
  371. const byPid = new Map(entries.map(entry => [entry.pid, entry]))
  372. const root = byPid.get(rootPid)
  373. if (root === undefined) return []
  374. const byParent = new Map<number, ProcessTreeEntry[]>()
  375. for (const entry of entries) {
  376. const children = byParent.get(entry.parentPid) ?? []
  377. children.push(entry)
  378. byParent.set(entry.parentPid, children)
  379. }
  380. const visited = new Set<number>()
  381. const result: ProcessIdentity[] = []
  382. const visit = (entry: ProcessTreeEntry): void => {
  383. if (visited.has(entry.pid)) return
  384. visited.add(entry.pid)
  385. for (const child of byParent.get(entry.pid) ?? []) visit(child)
  386. result.push({ pid: entry.pid, started: entry.started })
  387. }
  388. visit(root)
  389. return result
  390. }
  391. class LinuxProcessInspector extends PosixProcessInspector {
  392. constructor(
  393. private readonly arch: NodeJS.Architecture,
  394. internals: ProcessInspectorInternals,
  395. ) {
  396. super(internals)
  397. }
  398. foregroundPgid(shellPid: number): number | undefined {
  399. const tpgid = readLinuxStat(this.internals, shellPid)?.tpgid
  400. return tpgid !== undefined && tpgid > 0 ? tpgid : undefined
  401. }
  402. isStdinWaiting(pgid: number, shellPid: number): boolean {
  403. const tables = linuxSyscallTables(this.arch)
  404. if (tables === undefined) return false
  405. const shell = readLinuxStat(this.internals, shellPid)
  406. if (shell === undefined) return false
  407. const terminalDevice = readLinuxTerminalDevice(this.internals, shellPid, shell.ttyDevice)
  408. if (terminalDevice === undefined) return false
  409. for (const pid of numericEntries(this.internals, '/proc') ?? []) {
  410. const process = readLinuxStat(this.internals, pid)
  411. if (process?.pgrp !== pgid) continue
  412. for (const tid of numericEntries(this.internals, `/proc/${pid}/task`) ?? []) {
  413. const syscall = readSyscall(this.internals, pid, tid)
  414. if (syscall !== undefined
  415. && syscallWaitsOnStdin(this.internals, pid, tid, syscall, tables)
  416. && readLinuxTerminalDevice(this.internals, pid, process.ttyDevice, tid) === terminalDevice) return true
  417. }
  418. }
  419. return false
  420. }
  421. isAlive(identity: ProcessIdentity): boolean {
  422. const stat = readLinuxStat(this.internals, identity.pid)
  423. return stat?.started === identity.started && !quiescent(stat.state)
  424. }
  425. snapshot(): ProcessSnapshot {
  426. const pids = numericEntries(this.internals, '/proc')
  427. if (pids === undefined) throw new Error('Cannot inspect processes: /proc directory is unreadable')
  428. let complete = true
  429. const rows = pids.flatMap((pid) => {
  430. const stat = readLinuxStat(this.internals, pid)
  431. if (stat === undefined) complete = false
  432. return stat === undefined ? [] : [{
  433. pid,
  434. parentPid: stat.parentPid,
  435. started: stat.started,
  436. session: stat.session,
  437. state: stat.state,
  438. }]
  439. })
  440. return new PosixProcessSnapshot(rows, complete)
  441. }
  442. }
  443. // `ps` exposes neither the session id nor a state column in this format, so a
  444. // macOS row can answer presence and parentage but never session membership.
  445. function macProcessTable(internals: ProcessInspectorInternals): { rows: ProcessRow[]; complete: boolean } {
  446. let complete = true
  447. const rows = internals.exec('/bin/ps', ['-axo', 'pid=,ppid=,lstart=']).split('\n').flatMap((line) => {
  448. const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line)
  449. if (match?.[1] === undefined || match[2] === undefined || match[3] === undefined) {
  450. if (line.trim().length > 0) complete = false
  451. return []
  452. }
  453. return [{
  454. pid: Number(match[1]),
  455. parentPid: Number(match[2]),
  456. started: match[3],
  457. session: undefined,
  458. state: undefined,
  459. }]
  460. })
  461. return { rows, complete }
  462. }
  463. class MacProcessInspector extends PosixProcessInspector {
  464. foregroundPgid(shellPid: number): number | undefined {
  465. try {
  466. const value = Number(this.internals.exec('/bin/ps', ['-o', 'tpgid=', '-p', String(shellPid)]).trim())
  467. return Number.isSafeInteger(value) && value > 0 ? value : undefined
  468. } catch (_missingProcess) {
  469. return undefined
  470. }
  471. }
  472. isStdinWaiting(_pgid: number, _shellPid: number): boolean {
  473. return false
  474. }
  475. isAlive(identity: ProcessIdentity): boolean {
  476. return macProcessTable(this.internals).rows
  477. .some(entry => entry.pid === identity.pid && entry.started === identity.started)
  478. }
  479. snapshot(): ProcessSnapshot {
  480. const table = macProcessTable(this.internals)
  481. return new PosixProcessSnapshot(table.rows, table.complete)
  482. }
  483. }
  484. /**
  485. * Create the supported platform inspector or fail at plugin load.
  486. * @param platform - target Node platform.
  487. * @param arch - target CPU architecture for Linux syscall numbers.
  488. * @param internals - filesystem/process boundary, injectable for deterministic tests.
  489. * @returns Platform process inspector.
  490. */
  491. export function createProcessInspector(
  492. platform: NodeJS.Platform = process.platform,
  493. arch: NodeJS.Architecture = process.arch,
  494. internals: ProcessInspectorInternals = DEFAULT_INTERNALS,
  495. ): ProcessInspector {
  496. if (platform === 'linux') return new LinuxProcessInspector(arch, internals)
  497. if (platform === 'darwin') return new MacProcessInspector(internals)
  498. if (platform === 'win32') return createWindowsProcessInspector()
  499. throw new Error(`subprocess-local: terminal inspection is unsupported on platform ${platform}`)
  500. }