1
/**2
* Private teardown ladder for the runtime subprocess: stdin EOF (cooperative3
* quiesce), then SIGTERM, then SIGKILL, resolving only after the process has4
* actually exited. The SDK client runs OUTSIDE any harness context, so it5
* cannot ride the `dsh-subprocess` service — this module is the seam's6
* documented exception for SDK-managed transports.7
*8
* @module @deepseek-ai/dsh-sdk-client/dispose9
*/11
import type { ChildProcess } from 'node:child_process'13
/**14
* Race the child's exit against a timer. Neither outcome leaves anything15
* behind on the child: the exit listener is removed on timeout and the timer16
* is cleared on exit, so the ladder's tiers never accumulate listeners.17
*/18
function exitsWithin(child: ChildProcess, ms: number): Promise<boolean> {19
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)20
return new Promise<boolean>((resolve) => {21
const onExit = (): void => {22
clearTimeout(timer)23
resolve(true)24
}25
// `.unref()` so a pending grace timer never keeps the parent's loop alive.26
const timer = setTimeout(() => {27
child.removeListener('exit', onExit)28
resolve(false)29
}, ms).unref()30
child.once('exit', onExit)31
})32
}34
/** Force-terminate the runtime and reject if no exit edge arrives within the grace. */35
function forceTerminateWithin(child: ChildProcess, ms: number): Promise<void> {36
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()37
return new Promise<void>((resolve, reject) => {38
let accepted = false39
let settled = false40
const cleanup = (): void => {41
clearTimeout(timer)42
child.off('exit', onExit)43
child.off('error', onError)44
}45
const settle = (complete: () => void): void => {46
if (settled) return47
settled = true48
cleanup()49
complete()50
}51
const onExit = (): void => { settle(resolve) }52
const onError = (error: Error): void => { settle(() => { reject(error) }) }53
child.once('exit', onExit)54
child.once('error', onError)55
const timer = setTimeout(() => {56
const disposition = accepted ? 'accepted' : 'refused'57
settle(() => {58
reject(new Error(`runtime process did not exit within ${ms}ms after SIGKILL was ${disposition}`))59
})60
}, ms).unref()61
try {62
accepted = child.kill('SIGKILL')63
if (child.exitCode !== null || child.signalCode !== null) settle(resolve)64
} catch (error: unknown) {65
settle(() => { reject(new Error('SIGKILL failed', { cause: error })) })66
}67
})68
}70
/**71
* Tear the runtime down to quiescence, resolving only after exit: close stdin72
* and allow cooperative flush, then use the host's graceful and forced73
* termination semantics. POSIX sends `SIGTERM` before `SIGKILL`; Windows74
* skips directly to forced termination because Node maps both signals to75
* `TerminateProcess`.76
* @param child - the runtime child process to tear down.77
* @param graces - the EOF and termination-confirmation windows (ms).78
* @param platform - the host platform, injectable for unit coverage.79
* @throws When forced termination errors or the child does not report exit80
* within `disposeGraceMs`.81
*/82
export async function disposeRuntimeProcess(83
child: ChildProcess,84
graces: { disposeEofGraceMs: number; disposeGraceMs: number },85
platform: NodeJS.Platform = process.platform,86
): Promise<void> {87
// Already gone: nothing to reap.88
if (child.exitCode !== null || child.signalCode !== null) return89
// 1. Close stdin and allow cooperative teardown and durable-state flush.90
child.stdin?.end()91
if (await exitsWithin(child, graces.disposeEofGraceMs)) return92
// 2. POSIX gets a catchable graceful signal; Windows signals all force-terminate.93
if (platform !== 'win32') {94
child.kill('SIGTERM')95
if (await exitsWithin(child, graces.disposeGraceMs)) return96
}97
// 3. Force-kill and await a bounded exit edge.98
await forceTerminateWithin(child, graces.disposeGraceMs)99
}