| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231 |
- /**
- * Deterministic ladder coverage against a scriptable fake child: each
- * escalation tier's timing is driven exactly (the client suite exercises the
- * same ladder against real subprocesses end to end).
- */
- import { EventEmitter } from 'node:events'
- import type { ChildProcess } from 'node:child_process'
- import { describe, expect, it, vi } from 'vitest'
- import { disposeRuntimeProcess } from '../src/dispose.ts'
- /** What fells a scripted {@link FakeChild}. */
- type LethalTrigger = 'eof' | NodeJS.Signals
- /** Per-scenario script for a {@link FakeChild}. */
- interface FakeChildScript {
- /**
- * The one trigger that makes the child exit (SIGKILL always does,
- * uncatchable, like a real process). Omitted: only SIGKILL fells it.
- */
- diesOn?: LethalTrigger
- /** Delay (ms) between the lethal trigger and the exit event. */
- delayMs?: number
- /** Complete the scripted exit inside the triggering call. */
- synchronousExit?: boolean
- /** `false` models a child spawned without a stdin pipe. */
- stdin?: boolean
- }
- /**
- * A scriptable stand-in for a ChildProcess carrying exactly the API the
- * ladder reads: `exitCode`/`signalCode`, `stdin.end()`, `kill()`, and the
- * `exit` event.
- */
- class FakeChild extends EventEmitter {
- exitCode: number | null = null
- signalCode: NodeJS.Signals | null = null
- readonly kills: NodeJS.Signals[] = []
- stdinEnded = false
- readonly stdin: { end: () => void } | null
- constructor(private readonly script: FakeChildScript = {}) {
- super()
- this.stdin = script.stdin === false
- ? null
- : { end: () => { this.stdinEnded = true; this.maybeDie('eof') } }
- }
- kill(signal: NodeJS.Signals): boolean {
- this.kills.push(signal)
- this.maybeDie(signal)
- return true
- }
- private maybeDie(trigger: LethalTrigger): void {
- // SIGKILL is uncatchable — it always fells the child; any other trigger
- // only when the scenario scripts it as the lethal one.
- if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return
- const exit = (): void => {
- if (trigger === 'eof') this.exitCode = 0
- else this.signalCode = trigger
- this.emit('exit', this.exitCode, this.signalCode)
- }
- if (this.script.synchronousExit === true) exit()
- else setTimeout(exit, this.script.delayMs ?? 0)
- }
- }
- /** The ladder takes a real ChildProcess; the fake carries the read surface. */
- function asChild(fake: FakeChild): ChildProcess {
- return fake as unknown as ChildProcess
- }
- describe('disposeRuntimeProcess', () => {
- it('returns immediately for an already-exited child (no EOF, no signals)', async () => {
- const fake = new FakeChild()
- fake.exitCode = 0
- await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
- expect(fake.stdinEnded).toBe(false)
- expect(fake.kills).toEqual([])
- })
- it('returns immediately for a child already dead by signal', async () => {
- const fake = new FakeChild()
- fake.signalCode = 'SIGKILL'
- await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
- expect(fake.stdinEnded).toBe(false)
- expect(fake.kills).toEqual([])
- })
- it('tier 1: a cooperative child quiesces on stdin EOF — no signal is ever sent', async () => {
- const fake = new FakeChild({ diesOn: 'eof', delayMs: 5 })
- await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
- expect(fake.stdinEnded).toBe(true)
- expect(fake.kills).toEqual([])
- expect(fake.exitCode).toBe(0)
- })
- it('recognizes a child that exits synchronously on stdin EOF', async () => {
- const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true })
- await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
- expect(fake.exitCode).toBe(0)
- expect(fake.listenerCount('exit')).toBe(0)
- })
- it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => {
- const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
- await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
- expect(fake.stdinEnded).toBe(true)
- expect(fake.kills).toEqual(['SIGTERM'])
- expect(fake.signalCode).toBe('SIGTERM')
- expect(fake.listenerCount('exit')).toBe(0)
- })
- it('recognizes a child that exits synchronously on SIGTERM', async () => {
- const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true })
- await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
- expect(fake.kills).toEqual(['SIGTERM'])
- expect(fake.signalCode).toBe('SIGTERM')
- expect(fake.listenerCount('exit')).toBe(0)
- })
- it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => {
- const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it
- await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
- expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
- // Quiescence, not a request: at resolution the child has ACTUALLY exited
- // (the exit event landed, despite the scripted post-SIGKILL delay).
- expect(fake.signalCode).toBe('SIGKILL')
- })
- it('recognizes a child already gone when the final exit wait begins', async () => {
- const fake = new FakeChild({ synchronousExit: true })
- await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
- expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
- expect(fake.signalCode).toBe('SIGKILL')
- })
- it.each(['exitCode', 'signalCode'] as const)('accepts a late OS %s marker before the final forced wait', async (marker) => {
- const fake = new FakeChild()
- vi.spyOn(fake, 'kill').mockImplementation((signal) => {
- fake.kills.push(signal)
- queueMicrotask(() => {
- if (marker === 'exitCode') fake.exitCode = 0
- else fake.signalCode = 'SIGTERM'
- })
- return true
- })
- await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1, disposeGraceMs: 10 }, 'linux')
- expect(fake.kills).toEqual(['SIGTERM'])
- })
- it('walks the ladder for a child spawned without a stdin pipe', async () => {
- const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 })
- await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
- expect(fake.kills).toEqual(['SIGTERM'])
- })
- it('skips the redundant SIGTERM tier on Windows and awaits forced exit', async () => {
- const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
- await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'win32')
- expect(fake.kills).toEqual(['SIGKILL'])
- expect(fake.signalCode).toBe('SIGKILL')
- })
- it('propagates a forced-termination error without waiting for the grace', async () => {
- const fake = new FakeChild()
- const failure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' })
- vi.spyOn(fake, 'kill').mockImplementation((signal) => {
- fake.kills.push(signal)
- fake.emit('error', failure)
- return false
- })
- await expect(disposeRuntimeProcess(
- asChild(fake),
- { disposeEofGraceMs: 1, disposeGraceMs: 1000 },
- 'win32',
- )).rejects.toBe(failure)
- expect(fake.kills).toEqual(['SIGKILL'])
- expect(fake.listenerCount('error')).toBe(0)
- expect(fake.listenerCount('exit')).toBe(0)
- })
- it('wraps a synchronous forced-termination exception and removes its listeners', async () => {
- const fake = new FakeChild()
- const failure = new Error('invalid signal state')
- vi.spyOn(fake, 'kill').mockImplementation(() => { throw failure })
- await expect(disposeRuntimeProcess(
- asChild(fake),
- { disposeEofGraceMs: 1, disposeGraceMs: 1000 },
- 'win32',
- )).rejects.toMatchObject({ message: 'SIGKILL failed', cause: failure })
- expect(fake.listenerCount('error')).toBe(0)
- expect(fake.listenerCount('exit')).toBe(0)
- })
- it('bounds a refused forced termination that produces no error or exit', async () => {
- const fake = new FakeChild()
- vi.spyOn(fake, 'kill').mockImplementation((signal) => {
- fake.kills.push(signal)
- return false
- })
- await expect(disposeRuntimeProcess(
- asChild(fake),
- { disposeEofGraceMs: 1, disposeGraceMs: 10 },
- 'win32',
- )).rejects.toThrow('runtime process did not exit within 10ms after SIGKILL was refused')
- expect(fake.listenerCount('error')).toBe(0)
- expect(fake.listenerCount('exit')).toBe(0)
- })
- it('bounds an accepted forced termination that never reports exit', async () => {
- const fake = new FakeChild()
- vi.spyOn(fake, 'kill').mockImplementation((signal) => {
- fake.kills.push(signal)
- return true
- })
- await expect(disposeRuntimeProcess(
- asChild(fake),
- { disposeEofGraceMs: 1, disposeGraceMs: 10 },
- 'win32',
- )).rejects.toThrow('runtime process did not exit within 10ms after SIGKILL was accepted')
- expect(fake.listenerCount('error')).toBe(0)
- expect(fake.listenerCount('exit')).toBe(0)
- })
- })
|