|
|
|
|
@@ -3,9 +3,11 @@
|
|
|
|
|
* {@link HarnessClient} owns the child process: it spawns the runtime, speaks
|
|
|
|
|
* the `@deepseek-ai/dsh-sdk-protocol` wire over the child's stdio, fans
|
|
|
|
|
* server notifications out to subscriptions, and tears the child down to
|
|
|
|
|
* quiescence through the shared subprocess dispose ladder. The design twin is
|
|
|
|
|
* the Python SDK's `HarnessClient` (`python/sdk`); both drive the same
|
|
|
|
|
* runtime protocol.
|
|
|
|
|
* quiescence through a private EOF → SIGTERM → SIGKILL ladder. The design
|
|
|
|
|
* twin is the Python SDK's `HarnessClient` (`python/sdk`); both drive the
|
|
|
|
|
* same runtime protocol. This client runs OUTSIDE any harness context, so it
|
|
|
|
|
* spawns directly rather than through the `dsh-subprocess` service — the
|
|
|
|
|
* seam's documented exception for SDK-managed transports.
|
|
|
|
|
*
|
|
|
|
|
* @module @deepseek-ai/dsh-sdk-client/client
|
|
|
|
|
*/
|
|
|
|
|
@@ -18,7 +20,6 @@ import {
|
|
|
|
|
type InitializeResult,
|
|
|
|
|
type SessionPromptParams,
|
|
|
|
|
} from '@deepseek-ai/dsh-sdk-protocol'
|
|
|
|
|
import { disposeChildProcess } from '@deepseek-ai/dsh-subagent-subprocess'
|
|
|
|
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import type { HarnessClientOptions, HarnessNotification, NotificationFilter } from './types.ts'
|
|
|
|
|
|
|
|
|
|
@@ -372,7 +373,7 @@ export class HarnessClient {
|
|
|
|
|
// for a runtime that cannot answer shutdown anymore.
|
|
|
|
|
this.appendStderr([`shutdown request failed: ${errorMessage(error)}`])
|
|
|
|
|
}
|
|
|
|
|
await disposeChildProcess(child, {
|
|
|
|
|
await disposeRuntimeProcess(child, {
|
|
|
|
|
disposeEofGraceMs: this.options.disposeEofGraceMs ?? 6_000,
|
|
|
|
|
disposeGraceMs: this.options.disposeGraceMs ?? 3_000,
|
|
|
|
|
})
|
|
|
|
|
@@ -446,6 +447,91 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Race the child's exit against a timer. Neither outcome leaves anything
|
|
|
|
|
* behind on the child: the exit listener is removed on timeout and the timer
|
|
|
|
|
* is cleared on exit, so the ladder's tiers never accumulate listeners.
|
|
|
|
|
*/
|
|
|
|
|
function exitsWithin(child: ChildProcess, ms: number): Promise<boolean> {
|
|
|
|
|
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
|
|
|
|
|
return new Promise<boolean>((resolve) => {
|
|
|
|
|
const onExit = (): void => {
|
|
|
|
|
clearTimeout(timer)
|
|
|
|
|
resolve(true)
|
|
|
|
|
}
|
|
|
|
|
// `.unref()` so a pending grace timer never keeps the parent's loop alive.
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
child.removeListener('exit', onExit)
|
|
|
|
|
resolve(false)
|
|
|
|
|
}, ms).unref()
|
|
|
|
|
child.once('exit', onExit)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Force-terminate the runtime and reject if no exit edge arrives within the grace. */
|
|
|
|
|
function forceTerminateWithin(child: ChildProcess, ms: number): Promise<void> {
|
|
|
|
|
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
|
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
|
|
|
let accepted = false
|
|
|
|
|
let settled = false
|
|
|
|
|
const cleanup = (): void => {
|
|
|
|
|
clearTimeout(timer)
|
|
|
|
|
child.off('exit', onExit)
|
|
|
|
|
child.off('error', onError)
|
|
|
|
|
}
|
|
|
|
|
const settle = (complete: () => void): void => {
|
|
|
|
|
if (settled) return
|
|
|
|
|
settled = true
|
|
|
|
|
cleanup()
|
|
|
|
|
complete()
|
|
|
|
|
}
|
|
|
|
|
const onExit = (): void => { settle(resolve) }
|
|
|
|
|
const onError = (error: Error): void => { settle(() => { reject(error) }) }
|
|
|
|
|
child.once('exit', onExit)
|
|
|
|
|
child.once('error', onError)
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
const disposition = accepted ? 'accepted' : 'refused'
|
|
|
|
|
settle(() => {
|
|
|
|
|
reject(new Error(`runtime process did not exit within ${ms}ms after SIGKILL was ${disposition}`))
|
|
|
|
|
})
|
|
|
|
|
}, ms).unref()
|
|
|
|
|
try {
|
|
|
|
|
accepted = child.kill('SIGKILL')
|
|
|
|
|
if (child.exitCode !== null || child.signalCode !== null) settle(resolve)
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
settle(() => { reject(new Error('SIGKILL failed', { cause: error })) })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Tear the runtime down to quiescence, resolving only after exit: close stdin
|
|
|
|
|
* and allow cooperative flush, then use the host's graceful and forced
|
|
|
|
|
* termination semantics. POSIX sends `SIGTERM` before `SIGKILL`; Windows
|
|
|
|
|
* skips directly to forced termination because Node maps both signals to
|
|
|
|
|
* `TerminateProcess`.
|
|
|
|
|
* @throws When forced termination errors or the child does not report exit
|
|
|
|
|
* within `disposeGraceMs`.
|
|
|
|
|
*/
|
|
|
|
|
async function disposeRuntimeProcess(
|
|
|
|
|
child: ChildProcess,
|
|
|
|
|
graces: { disposeEofGraceMs: number; disposeGraceMs: number },
|
|
|
|
|
platform: NodeJS.Platform = process.platform,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
// Already gone: nothing to reap.
|
|
|
|
|
if (child.exitCode !== null || child.signalCode !== null) return
|
|
|
|
|
// 1. Close stdin and allow cooperative teardown and durable-state flush.
|
|
|
|
|
child.stdin?.end()
|
|
|
|
|
if (await exitsWithin(child, graces.disposeEofGraceMs)) return
|
|
|
|
|
// 2. POSIX gets a catchable graceful signal; Windows signals all force-terminate.
|
|
|
|
|
if (platform !== 'win32') {
|
|
|
|
|
child.kill('SIGTERM')
|
|
|
|
|
if (await exitsWithin(child, graces.disposeGraceMs)) return
|
|
|
|
|
}
|
|
|
|
|
// 3. Force-kill and await a bounded exit edge.
|
|
|
|
|
await forceTerminateWithin(child, graces.disposeGraceMs)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The message of a thrown value (the transport only throws `Error`s; `String` covers the rest). */
|
|
|
|
|
function errorMessage(error: unknown): string {
|
|
|
|
|
/* v8 ignore next -- the transport and dispose ladder reject only with Errors */
|
|
|
|
|
|