process-exit.spec.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { fileURLToPath } from 'node:url'
  5. import { execa } from 'execa'
  6. import { describe, expect, it, vi } from 'vitest'
  7. import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
  8. import { createProcessInspector } from '../src/process-inspector.ts'
  9. import type { ProcessIdentity, ProcessInspector } from '../src/process-inspector.ts'
  10. import { taskkillProcessTree } from '../src/spawn.ts'
  11. type ExitTrigger = 'direct' | 'uncaught-exception' | 'unhandled-rejection' | 'dispose'
  12. type ManagedKind = 'ordinary' | 'terminal'
  13. interface TreeState { root: number; descendant: number }
  14. const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
  15. const hostScript = fileURLToPath(new URL('./fixtures/process-exit-host.ts', import.meta.url))
  16. const scenarioTimeoutMs = process.platform === 'win32' ? 60_000 : 30_000
  17. const testTimeoutMs = scenarioTimeoutMs + 15_000
  18. function processExists(pid: number): boolean {
  19. try {
  20. process.kill(pid, 0)
  21. return true
  22. } catch (error: unknown) {
  23. if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false
  24. throw error
  25. }
  26. }
  27. async function readTree(path: string): Promise<TreeState> {
  28. return vi.waitFor(async () => {
  29. const text = await readFile(path, 'utf8')
  30. const state = JSON.parse(text) as Partial<TreeState>
  31. if (!Number.isSafeInteger(state.root) || !Number.isSafeInteger(state.descendant)
  32. || (state.root ?? 0) <= 0 || (state.descendant ?? 0) <= 0 || state.root === state.descendant) {
  33. throw new Error(`invalid managed-tree state: ${text}`)
  34. }
  35. return state as TreeState
  36. }, { interval: 10, timeout: scenarioTimeoutMs })
  37. }
  38. async function captureIdentities(inspector: ProcessInspector, state: TreeState): Promise<ProcessIdentity[]> {
  39. return vi.waitFor(() => {
  40. const expected = new Set([state.root, state.descendant])
  41. const identities = inspector.snapshot().tree(state.root).filter(identity => expected.has(identity.pid))
  42. if (identities.length !== expected.size) throw new Error('managed tree is not fully observable yet')
  43. return identities
  44. }, { interval: 10, timeout: scenarioTimeoutMs })
  45. }
  46. async function waitForGone(state: TreeState): Promise<void> {
  47. await Promise.all([state.root, state.descendant].map(pid => vi.waitFor(() => {
  48. if (processExists(pid)) throw new Error(`managed pid ${pid} is still alive`)
  49. }, { interval: 25, timeout: 10_000 })))
  50. }
  51. function cleanupTree(state: TreeState | undefined, identities: ProcessIdentity[]): void {
  52. if (state === undefined) return
  53. if (process.platform === 'win32') {
  54. taskkillProcessTree(state.root)
  55. for (const pid of [state.descendant, state.root]) {
  56. try {
  57. process.kill(pid, 'SIGKILL')
  58. } catch (_alreadyGone) {
  59. // The exact recorded process already exited.
  60. }
  61. }
  62. return
  63. }
  64. const inspector = createProcessInspector()
  65. for (const identity of identities) {
  66. try {
  67. inspector.signalProcess(identity, 'SIGKILL')
  68. } catch (_alreadyGone) {
  69. // Exact start identity prevents PID-reuse cleanup from reaching another process.
  70. }
  71. }
  72. if (identities.length === 0) {
  73. for (const pid of [state.descendant, state.root]) {
  74. try {
  75. process.kill(pid, 'SIGKILL')
  76. } catch (_alreadyGone) {
  77. // The scenario failed before process identities became observable.
  78. }
  79. }
  80. }
  81. }
  82. async function runScenario(kind: ManagedKind, trigger: ExitTrigger) {
  83. const root = await mkdtemp(join(tmpdir(), `dsh-subprocess-host-exit-${kind}-${trigger}-`))
  84. const launch = resolveExampleLaunch({
  85. srcBin: hostScript,
  86. mode: 'src',
  87. tsconfigPath: join(repoRoot, 'tsconfig.json'),
  88. configArgs: [kind, trigger, root],
  89. })
  90. const child = execa(launch.command, launch.args, {
  91. cwd: repoRoot,
  92. env: launch.env,
  93. stdin: 'ignore',
  94. reject: false,
  95. timeout: scenarioTimeoutMs,
  96. })
  97. let state: TreeState | undefined
  98. let identities: ProcessIdentity[] = []
  99. let settled = false
  100. let treeGone = false
  101. try {
  102. // The host validates tree.json before waiting for proceed, so observing it
  103. // is sufficient readiness; a second marker only adds a redundant Windows poll.
  104. state = await readTree(join(root, 'tree.json'))
  105. if (process.platform !== 'win32') identities = await captureIdentities(createProcessInspector(), state)
  106. await writeFile(join(root, 'proceed'), 'proceed')
  107. const outcome = await child
  108. settled = true
  109. await waitForGone(state)
  110. treeGone = true
  111. const disposeCounts = trigger === 'dispose'
  112. ? JSON.parse(await readFile(join(root, 'dispose.json'), 'utf8')) as {
  113. listenersBefore: number
  114. listenersAfterLoad: number
  115. listenersAfterDispose: number
  116. }
  117. : undefined
  118. return { outcome, disposeCounts }
  119. } finally {
  120. if (!settled) {
  121. child.kill('SIGKILL')
  122. await child.catch(() => {})
  123. }
  124. if (!treeGone) {
  125. cleanupTree(state, identities)
  126. if (state !== undefined) await waitForGone(state).catch(() => {})
  127. }
  128. await rm(root, { recursive: true, force: true })
  129. }
  130. }
  131. describe('synchronous cleanup on host exit', () => {
  132. it.each([
  133. { trigger: 'direct' as const, expectedCode: 23, diagnostic: undefined },
  134. { trigger: 'uncaught-exception' as const, expectedCode: 1, diagnostic: 'host-exit-uncaught-exception' },
  135. { trigger: 'unhandled-rejection' as const, expectedCode: 1, diagnostic: 'host-exit-unhandled-rejection' },
  136. ])('removes an ordinary managed tree after $trigger', { timeout: testTimeoutMs }, async ({
  137. trigger,
  138. expectedCode,
  139. diagnostic,
  140. }) => {
  141. const { outcome } = await runScenario('ordinary', trigger)
  142. expect(outcome.exitCode).toBe(expectedCode)
  143. expect(outcome.signal).toBeUndefined()
  144. if (diagnostic !== undefined) expect(outcome.stderr).toContain(diagnostic)
  145. })
  146. it.skipIf(process.platform === 'win32')(
  147. 'removes a terminal root and descendant after direct exit',
  148. { timeout: testTimeoutMs },
  149. async () => {
  150. const { outcome } = await runScenario('terminal', 'direct')
  151. expect(outcome.exitCode).toBe(23)
  152. expect(outcome.signal).toBeUndefined()
  153. },
  154. )
  155. it('preserves normal terminate-and-join disposal and removes the exit listener', { timeout: testTimeoutMs }, async () => {
  156. const { outcome, disposeCounts } = await runScenario('ordinary', 'dispose')
  157. expect(outcome.exitCode).toBe(0)
  158. expect(disposeCounts?.listenersAfterLoad).toBe((disposeCounts?.listenersBefore ?? 0) + 1)
  159. expect(disposeCounts?.listenersAfterDispose).toBe(disposeCounts?.listenersBefore)
  160. })
  161. })