refactor(subprocess): the dispose ladder moves to its one consumer
SubprocessHandle loses dispose(graces) and SubprocessDisposeGraces: the stdin-EOF→SIGTERM→SIGKILL sequence is teardown POLICY encoding one consumer's cooperation shape, not process vocabulary — the seam keeps kill/terminate/waitForExit, and waitForExit(signal?) is the quiescence probe a consumer ladder needs. dsh-subagent-acp owns disposeAcpChild() over those public verbs (tier tests move into its suite; a never-exits stub pins the fail-loud path); dsh-subprocess-local sheds the ladder, its deadline import, and the dsh-timeout dependency. Every future backend now owes four verbs and no teardown policy. New bilingual ladder-ownership Agent Note records the decision; catalogs regenerated.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 08cc2ce7d92569222b99992d0f4c43551a2c9623
|
||||
README.zh.md: da230ba37d406a6ad4ec669ceead45f2c2dd7069
|
||||
README.md: 16d83032ebf271a3e3e091962cad6dc9b689f072
|
||||
README.zh.md: e9a1284a81269741909631a6fbac8af3549800a3
|
||||
|
||||
@@ -6,7 +6,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README
|
||||
|
||||
## Behavior (and where it came from)
|
||||
|
||||
- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID <pid> /T /F` (injectable for tests). `terminate()` sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent); `kill(signal)` sends exactly one signal and is a no-op after settlement; `dispose(graces)` runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools.
|
||||
- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID <pid> /T /F` (injectable for tests). `terminate()` sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent); `kill(signal)` sends exactly one signal and is a no-op after settlement; `waitForExit()` polls whole-tree liveness so consumer teardown confirms real quiescence. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools.
|
||||
- **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory.
|
||||
- **Credential scrub + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 行为(以及设计来源)
|
||||
|
||||
- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID <pid> /T /F` 终止进程树(可为测试注入)。`terminate()` 先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束);`kill(signal)` 恰好发送一个信号,结算后为空操作;`dispose(graces)` 以调用方提供的时间窗运行 stdin EOF→SIGTERM→SIGKILL 阶梯,dispose(资源释放)按句柄 memoize 化、只执行一次。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。
|
||||
- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID <pid> /T /F` 终止进程树(可为测试注入)。`terminate()` 先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束);`kill(signal)` 恰好发送一个信号,结算后为空操作;`waitForExit()` 轮询整棵进程树的存活状态,使消费方的拆卸能确认真正的完全停稳。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。
|
||||
- **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留尾部,即诊断尾部的形状。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。
|
||||
- **凭据清除 + 受管 `DSH_*` 合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的普通 `env` 在清除后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止陈旧的嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
- **基于偏移量的读取**:收集模式的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
|
||||
|
||||
@@ -29,13 +29,11 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import type { SpawnInternals } from './spawn.ts'
|
||||
/**
|
||||
* Local subprocess service: detached process trees, Node-shaped stdio
|
||||
* dispositions (raw pipes, inherit, bounded tail-keep collection with spill
|
||||
* files), credential-scrubbed environment, tree-scoped signalling with
|
||||
* SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder.
|
||||
* files), credential-scrubbed environment, and tree-scoped signalling with
|
||||
* SIGTERM→grace→SIGKILL escalation.
|
||||
*/
|
||||
export class LocalSubprocessService extends SubprocessService {
|
||||
/** Live handles retained only so disposal can terminate and join them. */
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* Process plumbing for the local subprocess service: detached process-tree
|
||||
* spawn with per-stream stdio dispositions, tail-keep collection with spill
|
||||
* files, tree-scoped signalling (POSIX groups; Windows taskkill), the
|
||||
* SIGTERM→SIGKILL escalation, and the cooperative EOF-first dispose ladder.
|
||||
* This layer reacts to an abort signal; callers own deadlines and classify
|
||||
* causes.
|
||||
* files, tree-scoped signalling (POSIX groups; Windows taskkill), and the
|
||||
* SIGTERM→SIGKILL escalation. This layer reacts to an abort signal; callers
|
||||
* own deadlines, teardown ladders, and cause classification.
|
||||
* @module dsh-subprocess-local/spawn
|
||||
*/
|
||||
|
||||
@@ -15,13 +14,11 @@ import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { setTimeout as sleepMs } from 'node:timers/promises'
|
||||
import { deadline } from '@deepseek-ai/dsh-timeout'
|
||||
import { DSH_ENV_PREFIX, scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
||||
import type {
|
||||
CollectedOutput,
|
||||
DshEnvironment,
|
||||
SubprocessCollect,
|
||||
SubprocessDisposeGraces,
|
||||
SubprocessHandle,
|
||||
SubprocessOutcome,
|
||||
SubprocessOutputMode,
|
||||
@@ -63,9 +60,6 @@ export interface SpawnInternals {
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
/** Timeout code marking a dispose-ladder tier bound (vs an external abort). */
|
||||
const DISPOSE_TIER_TIMEOUT = 'SUBPROCESS_DISPOSE_TIER'
|
||||
|
||||
/**
|
||||
* Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an
|
||||
* awaited teardown must keep the event loop alive until the tree really
|
||||
@@ -469,39 +463,6 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait, bounded, for whole-tree exit — the dispose ladder's quiescence test.
|
||||
* Tree liveness, not direct-child settlement: a TERM-trapping helper that
|
||||
* outlives the leader must hold the ladder on its tier until it exits.
|
||||
*/
|
||||
const treeExitsWithin = async (ms: number): Promise<boolean> => {
|
||||
using bound = deadline(undefined, ms, DISPOSE_TIER_TIMEOUT)
|
||||
return await waitForExit(bound.signal)
|
||||
}
|
||||
|
||||
let disposal: Promise<void> | undefined
|
||||
const dispose = (graces: SubprocessDisposeGraces): Promise<void> => (disposal ??= (async () => {
|
||||
// A spawn failure has no process to tear down; observe the rejection so
|
||||
// disposal in a finally block cannot surface it as unhandled.
|
||||
if (pid <= 0) {
|
||||
await done.catch(() => {})
|
||||
return
|
||||
}
|
||||
// 1. Close a piped stdin and allow cooperative teardown and flush.
|
||||
if (stdinMode === 'pipe') child.stdin?.end()
|
||||
if (await treeExitsWithin(graces.eofGraceMs)) return
|
||||
// 2. POSIX gets a catchable graceful signal; Windows taskkill force-terminates.
|
||||
if (platform !== 'win32') {
|
||||
kill('SIGTERM')
|
||||
if (await treeExitsWithin(graces.graceMs)) return
|
||||
}
|
||||
// 3. Force-kill the tree and await a bounded exit edge.
|
||||
kill('SIGKILL')
|
||||
if (!(await treeExitsWithin(graces.graceMs))) {
|
||||
throw new Error(`child process tree did not exit within ${graces.graceMs}ms after forced termination`)
|
||||
}
|
||||
})())
|
||||
|
||||
return {
|
||||
pid,
|
||||
/* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */
|
||||
@@ -517,6 +478,5 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
|
||||
kill,
|
||||
terminate,
|
||||
waitForExit,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,40 +506,6 @@ describe('stdio dispositions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose ladder', () => {
|
||||
it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => {
|
||||
const running = spawnSubprocess({
|
||||
...spec('read -r line; exit 0'),
|
||||
stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
|
||||
})
|
||||
await running.dispose({ eofGraceMs: 5_000, graceMs: 200 })
|
||||
const outcome = await running.done
|
||||
expect(outcome.exitCode).toBe(0)
|
||||
expect(outcome.signal).toBeNull()
|
||||
})
|
||||
|
||||
it('tier 2: an EOF-deaf child dies by SIGTERM', async () => {
|
||||
const running = spawnSubprocess({
|
||||
...spec('sleep 60'),
|
||||
stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
|
||||
})
|
||||
await running.dispose({ eofGraceMs: 100, graceMs: 5_000 })
|
||||
const outcome = await running.done
|
||||
expect(outcome.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('tier 3: a TERM-trapping child dies by SIGKILL, and dispose() is idempotent', async () => {
|
||||
const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60'))
|
||||
await waitForStdout(running, 'armed\n')
|
||||
const first = running.dispose({ eofGraceMs: 50, graceMs: 200 })
|
||||
const second = running.dispose({ eofGraceMs: 50, graceMs: 200 })
|
||||
expect(second).toBe(first)
|
||||
await first
|
||||
const outcome = await running.done
|
||||
expect(outcome.signal).toBe('SIGKILL')
|
||||
})
|
||||
})
|
||||
|
||||
describe('windows tree semantics (injected platform)', () => {
|
||||
it('kill and terminate route through taskkill by root pid', async () => {
|
||||
const killed: number[] = []
|
||||
@@ -590,7 +556,7 @@ describe('waitForExit', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('tree-survivor escalation (terminate/dispose reach helpers the leader left behind)', () => {
|
||||
describe('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => {
|
||||
it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => {
|
||||
// The leader spawns a TERM-trapping helper with all stdio detached from
|
||||
// the collected pipes, then exits: the helper holds the GROUP alive while
|
||||
@@ -609,18 +575,21 @@ describe('tree-survivor escalation (terminate/dispose reach helpers the leader l
|
||||
await waitGone(helper)
|
||||
})
|
||||
|
||||
it('dispose() holds each tier on whole-tree exit, not direct-child settlement', async () => {
|
||||
const pidFile = join(spillDir, `survivor-dispose-${Date.now()}.pid`)
|
||||
it('a bounded waitForExit reports false while a survivor lives, true after escalation', async () => {
|
||||
const pidFile = join(spillDir, `survivor-wait-${Date.now()}.pid`)
|
||||
const running = spawnSubprocess(spec(
|
||||
`bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; exit 0`,
|
||||
{ graceMs: 200 },
|
||||
))
|
||||
const helper = await waitForPidFile(pidFile)
|
||||
await running.done
|
||||
expect(() => process.kill(helper, 0)).not.toThrow()
|
||||
|
||||
await running.dispose({ eofGraceMs: 100, graceMs: 300 })
|
||||
// The ladder only returns once the WHOLE tree is gone.
|
||||
// A consumer-owned teardown tier bounds its wait and reads the verdict.
|
||||
const bound = new AbortController()
|
||||
const timer = setTimeout(() => { bound.abort() }, 100)
|
||||
await expect(running.waitForExit(bound.signal)).resolves.toBe(false)
|
||||
clearTimeout(timer)
|
||||
running.terminate()
|
||||
await expect(running.waitForExit()).resolves.toBe(true)
|
||||
expect(() => process.kill(helper, 0)).toThrow()
|
||||
})
|
||||
|
||||
@@ -653,11 +622,10 @@ describe('coverage seams', () => {
|
||||
expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('dispose on a spawn-failed handle observes the rejection and returns', async () => {
|
||||
it('a spawn-failed handle rejects done while waitForExit reports gone', async () => {
|
||||
const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' }))
|
||||
const disposal = running.dispose({ eofGraceMs: 1_000, graceMs: 1_000 })
|
||||
await expect(running.done).rejects.toThrow()
|
||||
await expect(disposal).resolves.toBeUndefined()
|
||||
await expect(running.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it("an 'inherit' stdout with collected stderr wires only the requested collector", async () => {
|
||||
@@ -692,25 +660,10 @@ describe('coverage seams', () => {
|
||||
await expect(running.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('dispose() on an already-exited tree returns without delivering a signal', async () => {
|
||||
const running = spawnSubprocess(spec('true'))
|
||||
await running.done
|
||||
await running.waitForExit()
|
||||
const spy = vi.spyOn(process, 'kill')
|
||||
try {
|
||||
await running.dispose({ eofGraceMs: 50, graceMs: 50 })
|
||||
const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0)
|
||||
expect(delivered).toEqual([])
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('a batch-stdin handle exposes no stdin and dispose skips the EOF tier', async () => {
|
||||
it('a batch-stdin handle exposes no stdin surface', async () => {
|
||||
const running = spawnSubprocess(spec('cat', { stdin: 'batch\n' }))
|
||||
expect(running.stdin).toBeUndefined()
|
||||
await running.done
|
||||
await running.dispose({ eofGraceMs: 50, graceMs: 50 })
|
||||
expect(running.collected.stdout!.readFrom(0).text).toBe('batch\n')
|
||||
})
|
||||
})
|
||||
@@ -739,33 +692,15 @@ describe('coverage seams 2', () => {
|
||||
await expect(running.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('the win32 dispose ladder skips the POSIX SIGTERM tier and force-terminates', async () => {
|
||||
const kills: number[] = []
|
||||
const running = spawnSubprocess({
|
||||
...spec('sleep 60'),
|
||||
stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
|
||||
}, {
|
||||
spillDir,
|
||||
platform: 'win32',
|
||||
taskkill: (pid) => {
|
||||
kills.push(pid)
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
},
|
||||
})
|
||||
await running.dispose({ eofGraceMs: 50, graceMs: 5_000 })
|
||||
// Exactly one forced tree termination: no POSIX SIGTERM tier ran.
|
||||
expect(kills).toEqual([running.pid])
|
||||
})
|
||||
|
||||
it('dispose throws when even SIGKILL produces no exit within the grace', async () => {
|
||||
// An inert taskkill simulates a tree that never reports exit.
|
||||
it('an inert win32 taskkill leaves the tree alive for a bounded wait to report', async () => {
|
||||
// An inert taskkill simulates a tree that never reports exit: terminate()
|
||||
// delivers nothing, so a bounded consumer wait must come back false.
|
||||
const running = spawnSubprocess(spec('sleep 60'), { spillDir, platform: 'win32', taskkill: () => {} })
|
||||
await expect(running.dispose({ eofGraceMs: 20, graceMs: 40 }))
|
||||
.rejects.toThrow(/did not exit within 40ms after forced termination/)
|
||||
running.terminate()
|
||||
const bound = new AbortController()
|
||||
const timer = setTimeout(() => { bound.abort() }, 60)
|
||||
await expect(running.waitForExit(bound.signal)).resolves.toBe(false)
|
||||
clearTimeout(timer)
|
||||
// Real cleanup: the injected platform spawned without detachment, so the
|
||||
// child is a plain (group-less) POSIX process — kill it directly.
|
||||
process.kill(running.pid, 'SIGKILL')
|
||||
|
||||
@@ -17,9 +17,6 @@
|
||||
{
|
||||
"path": "../subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 2cb7a5ebce404c440e625dea844ed28ceadb06f3
|
||||
README.zh.md: a3211834e065359e813e8148a8f6a6a15f8f89b6
|
||||
README.md: 271e9b912077bf5ec18d96666c216562cb87cc6e
|
||||
README.zh.md: 692108ffe8848f823b3ab3fa487a2bf0fbc8fa44
|
||||
|
||||
@@ -9,7 +9,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes
|
||||
- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures.
|
||||
- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself.
|
||||
- Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement.
|
||||
- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification).
|
||||
- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence — the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification).
|
||||
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, explicit `env` merges after the scrub (a deliberately forwarded key survives), and `dshEnv` carries current harness facts on its own validated channel; `splitEnvChannels()` partitions a consumer config's single mixed env map onto those two channels (lsp-local servers and the ACP backend expose one map, and a configured `DSH_*` fact must ride the managed channel the ordinary one rejects). Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub.
|
||||
- Disposal of the service terminates all still-running managed processes and awaits their exit.
|
||||
|
||||
@@ -26,4 +26,4 @@ No direct invalidation; the named consumers own any request-prefix changes.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **node-pty and SDK-managed spawns share only the scrub** — the PTY backend's terminal fork and the MCP SDK's own stdio transport cannot route their spawns through this seam (the library owns the fork/spawn call); they import `scrubbedParentEnv` so the environment policy stays single-sourced.
|
||||
- **The dispose ladder assumes stdin-EOF cooperation** — a child that quiesces on a different signal (SIGHUP conventions, control sockets) needs its own tier-1 before the generic ladder fits.
|
||||
- **Teardown ladders are consumer-owned** — the seam ships signalling verbs and the tree-liveness wait, not a canned quiesce sequence; each out-of-process consumer encodes its child's cooperation shape itself (the ACP backend's stdin-EOF-first ladder is the in-repo template).
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- `spawn(spec)` 立即返回一个实时句柄;`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。
|
||||
- spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。
|
||||
- stdio 按流采用 Node 形状:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。
|
||||
- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit()` 观察整棵进程树;`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。管理器只响应中止,但绝不判定原因(deadline 与原因分类归调用方所有)。
|
||||
- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。
|
||||
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并(有意转发的键会保留下来),`dshEnv` 则经由自身带校验的通道携带当前 harness 事实;`splitEnvChannels()` 把消费方配置中单一的混合 env 映射按这两条通道切分(lsp-local 的服务器配置与 ACP 后端只暴露一个映射,而配置的 `DSH_*` 事实必须走受管通道,普通通道会拒绝它)。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入凭据清除函数。
|
||||
- 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。
|
||||
|
||||
@@ -26,4 +26,4 @@
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **node-pty 与由 SDK 管理的 spawn 只共享凭据清除**:PTY 后端的终端 fork 与 MCP SDK 自己的 stdio 传输层无法把 spawn 路由到这道 seam(fork/spawn 调用归库所有);它们改为导入 `scrubbedParentEnv`,使环境策略保持单一来源。
|
||||
- **dispose 阶梯假定子进程配合 stdin EOF**:依赖其他信号(SIGHUP 惯例、控制 socket)才能完全停稳的子进程,需要自己的第一阶,通用阶梯才适用。
|
||||
- **拆卸阶梯归消费方所有**:该 seam 只提供信号动词与进程树存活等待,不提供现成的停稳序列;每个进程外消费方自行编码其子进程的配合形状(ACP 后端以 stdin EOF 打头的阶梯是仓库内模板)。
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
* The subprocess seam (`ctx.subprocess`): spawn fully-specified commands into
|
||||
* managed process trees with Node-shaped stdio dispositions — raw pipes for
|
||||
* protocol streams, inherit for diagnostics, bounded spill-backed collection
|
||||
* for batch output — plus tree-scoped signalling and a cooperative dispose
|
||||
* ladder. Command defaulting, shell semantics, deadlines, framing, and
|
||||
* presentation belong to consumers; the bash executor seam is the owning
|
||||
* template. The local implementation lives in
|
||||
* for batch output — plus tree-scoped signalling. Command defaulting, shell
|
||||
* semantics, deadlines, teardown ladders, framing, and presentation belong to
|
||||
* consumers; the bash executor seam is the owning template. The local implementation lives in
|
||||
* `@deepseek-ai/dsh-subprocess-local`.
|
||||
* @module @deepseek-ai/dsh-subprocess
|
||||
*/
|
||||
@@ -22,7 +21,6 @@ export type {
|
||||
DshEnvironmentKey,
|
||||
SubprocessCollect,
|
||||
SubprocessCollectedOutputs,
|
||||
SubprocessDisposeGraces,
|
||||
SubprocessHandle,
|
||||
SubprocessOutcome,
|
||||
SubprocessOutputMode,
|
||||
@@ -102,10 +100,11 @@ declare module 'cordis' {
|
||||
* readers never consume one another's output; lossy reads report truncation
|
||||
* and the spill file holding the complete stream when one exists. Piped
|
||||
* streams are handed to the caller raw and never buffered here.
|
||||
* - {@link SubprocessHandle.kill} signals without escalation,
|
||||
* - {@link SubprocessHandle.kill} signals without escalation, and
|
||||
* {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates
|
||||
* SIGTERM→grace→SIGKILL, and {@link SubprocessHandle.dispose} runs the
|
||||
* cooperative EOF-first ladder — all tree-scoped on every platform.
|
||||
* SIGTERM→grace→SIGKILL — both tree-scoped on every platform.
|
||||
* {@link SubprocessHandle.waitForExit} observes whole-tree liveness, so a
|
||||
* consumer-owned teardown ladder can hold each tier on real quiescence.
|
||||
* - Disposal of the service terminates all still-running managed processes
|
||||
* and awaits their exit.
|
||||
*/
|
||||
|
||||
@@ -160,30 +160,6 @@ export interface SubprocessCollectedOutputs {
|
||||
readonly stderr?: SubprocessOutputReader
|
||||
}
|
||||
|
||||
/**
|
||||
* The two grace periods of the cooperative dispose ladder
|
||||
* ({@link SubprocessHandle.dispose}). Consumers carry them as defaulted,
|
||||
* validated Config fields, so teardown timing is deployment-tunable and this
|
||||
* seam hardcodes nothing.
|
||||
*/
|
||||
export interface SubprocessDisposeGraces {
|
||||
/**
|
||||
* Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce
|
||||
* ON ITS OWN — flush durable state, tear down its own descendants — before
|
||||
* escalation to platform termination. Usually WIDER than
|
||||
* {@link SubprocessDisposeGraces.graceMs}: a cooperative child's EOF-driven
|
||||
* teardown may itself wait on a signal-trapping grandchild plus a final
|
||||
* flush.
|
||||
*/
|
||||
eofGraceMs: number
|
||||
/**
|
||||
* Termination confirmation window (ms): POSIX applies it after `SIGTERM`
|
||||
* and again after `SIGKILL`; Windows applies it after the forced tree
|
||||
* termination.
|
||||
*/
|
||||
graceMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A live child process rooted in its own process tree. Collected output
|
||||
* remains readable after exit; piped streams belong to the caller.
|
||||
@@ -226,13 +202,4 @@ export interface SubprocessHandle {
|
||||
* @returns `true` when the tree exited, `false` when the signal aborted first.
|
||||
*/
|
||||
waitForExit(signal?: AbortSignal): Promise<boolean>
|
||||
/**
|
||||
* Tear the child down to quiescence, resolving only after exit: close stdin
|
||||
* (when this handle owns a piped one) and allow cooperative flush for
|
||||
* `eofGraceMs`, then SIGTERM with a `graceMs` window (POSIX), then forced
|
||||
* tree termination with a final bounded `graceMs` wait.
|
||||
* @param graces - the ladder's two windows, from the consumer's Config.
|
||||
* @throws when the child still has not exited `graceMs` after the forced tier.
|
||||
*/
|
||||
dispose(graces: SubprocessDisposeGraces): Promise<void>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { scrubbedParentEnv, splitEnvChannels, SubprocessService } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { SubprocessDisposeGraces, SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
/**
|
||||
* Minimal concrete service: a hand-built handle. The seam is spawn-only —
|
||||
@@ -24,7 +24,6 @@ class StubSubprocessService extends SubprocessService {
|
||||
kill: () => {},
|
||||
terminate: () => {},
|
||||
waitForExit: () => Promise.resolve(true),
|
||||
dispose: (_graces: SubprocessDisposeGraces) => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +43,6 @@ describe('SubprocessService seam', () => {
|
||||
handle.kill()
|
||||
handle.terminate()
|
||||
await expect(handle.waitForExit()).resolves.toBe(true)
|
||||
await expect(handle.dispose({ eofGraceMs: 1, graceMs: 1 })).resolves.toBeUndefined()
|
||||
const outcome = await handle.done
|
||||
expect(outcome.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user