fix(runtime): close lifecycle teardown races

This commit is contained in:
Tianyi Cui
2026-07-29 02:40:02 +08:00
parent c17727d43d
commit 32bce339da
20 changed files with 233 additions and 36 deletions

View File

@@ -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 packages/subprocess/subprocess-local/README.md
README.md: 6bc1003ae5903bb5640728bc79c3f9042fddbe7a
README.zh.md: 3c9ce73c7fcbeec9b17d73f212ecdcb6842ec133
README.md: 38b26c33f3854acdb4a2bb48612da73361d727ba
README.zh.md: fc4a5d4961fb7618b9db84b21063f907361a26d8

View File

@@ -11,7 +11,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README
- **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. 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.
- **Execution-world coordinates** — `cwd` is the host process cwd, `runtimeRoot` is an owner-private temporary directory removed on disposal, and `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions.
- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal bytes, inspects and signals the current foreground process group, and cleans descendants before the top-level shell. Linux `/proc`/syscall and macOS `ps` inspectors retain exact pid/start identity so pid reuse cannot redirect cleanup; the higher PTY backend owns prompt readiness, buffers, and model-facing operations.
- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal bytes, inspects and signals the current foreground process group, and cleans descendants before the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. The higher PTY backend owns prompt readiness, buffers, and model-facing operations.
- **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement.
## Model Experience
@@ -26,7 +26,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
- **Windows tree support is best-effort and untested in CI** — termination routes through `taskkill /PID <pid> /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary; the suites cover the routing through an injected runner only, and `packages/subprocess/*` is excluded from the Windows test matrix.
- **Terminal process inspection is Linux/macOS only** — the terminal primitive fails when its inspector has no supported platform implementation; Linux exact probes cover x64 and arm64, while macOS uses `ps` snapshots.
- **A daemonized terminal descendant can escape the captured tree** — a child that reparents before teardown is no longer discoverable from the `node-pty` root. The local provider accepts this gap rather than signal the root PID's POSIX session, which can include unrelated launcher processes.
- **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor.
- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind.

View File

@@ -11,7 +11,7 @@
- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*``*SECRET*``*TOKEN*`)和所有环境中已有的 `DSH_*` 名称spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Noteagent 决策记录)](../../../.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 的后台读取路径)与完整流重读可以共存,结算前后皆然。
- **执行世界坐标**`cwd` 是宿主进程 cwd`runtimeRoot` 是所有者私有的临时目录,在资源释放时删除;`resolveExecutable` 检查绝对文件,或使用平台感知的可执行扩展名在清理后的有效 PATH 中查找。
- **终端进程所有权**`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端字节,检查当前前台进程组并向其发送信号,并先于顶层 shell 清理后代。Linux 的 `/proc`syscall 检查器与 macOS 的 `ps` 检查器会保留精确的 pid启动身份,使 PID 复用无法把清理重定向到其他进程上层 PTY 后端负责提示符就绪检测、缓冲和面向模型的操作。
- **终端进程所有权**`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端字节,检查当前前台进程组并向其发送信号,并先于顶层 shell 清理后代。每次前台检查都会保留有根进程树中的精确身份Linux 还会在会话 leader 退出后枚举该 POSIX 会话。因此,先前观察到的 macOS 后代以及任何同会话 Linux 成员在重新设定父进程后仍受身份围栏保护,而 pid启动身份可防止清理因 PID 复用而跟随到其他进程上层 PTY 后端负责提示符就绪检测、缓冲和面向模型的操作。
- **先终止再等待退出的 dispose**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。
## 模型体验
@@ -26,7 +26,7 @@
- **Windows 进程树支持仅为尽力而为,且未经 CI 测试**:终止经由 `taskkill /PID <pid> /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界;测试套件只通过注入的运行器覆盖这条路由,且 `packages/subprocess/*` 被排除在 Windows 测试矩阵之外。
- **终端进程检查仅支持 LinuxmacOS**检查器没有受支持的平台实现时终端原语会失败Linux 精确探针覆盖 x64 与 arm64macOS 使用 `ps` 快照。
- **守护化的终端后代可能逃离已捕获进程树**:子进程若在拆卸前重新设定父进程,便无法再从 `node-pty` 根发现。本地提供方接受这个缺口,不向根 PID 的 POSIX 会话发送信号,因为其中可能包含无关的启动器进程
- **守护化的终端后代可能逃出可观察边界**:在 macOS 上,子进程若在任何前台检查快照产生前重新设定父进程,便无法再从 `node-pty` 根发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与该提供方拥有的终端会话。本地提供方不会增加持续运行的进程表监视器
- **凭据清除依赖名称启发式规则**:只匹配 `*KEY*``*SECRET*``*TOKEN*`;名称不同的 secret例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。

View File

@@ -16,6 +16,8 @@ export interface ProcessInspector {
isStdinWaiting(pgid: number): boolean
/** Return the root and its current transitive descendants, children first. */
processTree(rootPid: number): ProcessIdentity[]
/** Return current members of one POSIX process session when the platform exposes them. */
processSession(sessionId: number): ProcessIdentity[]
/** Return whether the exact identity remains a non-quiescent process. */
isAlive(identity: ProcessIdentity): boolean
signalGroup(pgid: number, signal: SubprocessTerminalSignal): void
@@ -201,6 +203,7 @@ abstract class PosixProcessInspector implements ProcessInspector {
abstract foregroundPgid(shellPid: number): number | undefined
abstract isStdinWaiting(pgid: number): boolean
abstract processTree(rootPid: number): ProcessIdentity[]
abstract processSession(sessionId: number): ProcessIdentity[]
abstract isAlive(identity: ProcessIdentity): boolean
signalGroup(pgid: number, signal: SubprocessTerminalSignal): void {
@@ -272,6 +275,13 @@ class LinuxProcessInspector extends PosixProcessInspector {
return processTree(entries, rootPid)
}
processSession(sessionId: number): ProcessIdentity[] {
return numericEntries(this.internals, '/proc').flatMap((pid) => {
const stat = readLinuxStat(this.internals, pid)
return stat?.session === sessionId ? [{ pid, started: stat.started }] : []
})
}
isAlive(identity: ProcessIdentity): boolean {
const stat = readLinuxStat(this.internals, identity.pid)
return stat?.started === identity.started && !/^[ZXx]$/.test(stat.state)
@@ -307,6 +317,10 @@ class MacProcessInspector extends PosixProcessInspector {
return processTree(macProcessTable(this.internals), rootPid)
}
processSession(_sessionId: number): ProcessIdentity[] {
return []
}
isAlive(identity: ProcessIdentity): boolean {
return macProcessTable(this.internals).some(entry => entry.pid === identity.pid && entry.started === identity.started)
}

View File

@@ -36,6 +36,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
private exited = false
private termination: Promise<void> | undefined
private removeAbort: (() => void) | undefined
private trackedDescendants: ProcessIdentity[] = []
/**
* @param terminal - allocated node-pty process.
@@ -86,6 +87,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
// Local inspection is synchronous; the seam returns a promise for remote transports.
// eslint-disable-next-line @typescript-eslint/require-await
async inspectForeground(): Promise<SubprocessTerminalForeground | undefined> {
this.descendants()
const processGroupId = this.inspector.foregroundPgid(this.pid)
if (processGroupId === undefined) return undefined
return {
@@ -145,7 +147,12 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
}
private descendants(): ProcessIdentity[] {
return this.inspector.processTree(this.pid).filter(member => member.pid !== this.pid)
this.trackedDescendants = this.survivors(this.unionMembers(
this.trackedDescendants,
this.inspector.processTree(this.pid),
this.inspector.processSession(this.pid),
).filter(member => member.pid !== this.pid))
return this.trackedDescendants
}
private async waitForMembers(members: ProcessIdentity[]): Promise<ProcessIdentity[]> {

View File

@@ -111,6 +111,7 @@ describe('LocalSubprocessService', () => {
foregroundPgid: () => undefined,
isStdinWaiting: () => false,
processTree: () => [],
processSession: () => [],
isAlive: () => false,
signalGroup: () => {},
signalProcess: () => {},
@@ -175,6 +176,7 @@ describe('LocalSubprocessService', () => {
foregroundPgid: () => 123,
isStdinWaiting: () => false,
processTree: () => [{ pid: 124, started: 'child' }],
processSession: () => [],
isAlive: identity => alive.has(identity.pid),
signalGroup: () => {},
signalProcess: () => {},

View File

@@ -86,6 +86,13 @@ describe('Linux process inspector', () => {
{ pid: 10, started: '500' },
])
expect(inspector.processTree(99)).toEqual([])
expect(inspector.processSession(30)).toEqual([
{ pid: 10, started: '500' },
{ pid: 11, started: '501' },
{ pid: 12, started: '502' },
{ pid: 13, started: '503' },
])
expect(inspector.processSession(99)).toEqual([])
expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(true)
expect(inspector.isAlive({ pid: 10, started: 'old' })).toBe(false)
inspector.signalGroup(40, 'SIGINT')
@@ -197,6 +204,7 @@ describe('macOS process inspector', () => {
{ pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
])
expect(inspector.processTree(99)).toEqual([])
expect(inspector.processSession(10)).toEqual([])
expect(inspector.isAlive({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' })).toBe(true)
inspector.signalGroup(55, 'SIGTSTP')
inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL')

View File

@@ -51,6 +51,7 @@ class FakeInspector implements ProcessInspector {
pgid: number | undefined = 456
waiting = false
members: ProcessIdentity[] = []
sessionMembers: ProcessIdentity[] = []
readonly alive = new Set<number>()
readonly groups: Array<[number, SubprocessTerminalSignal]> = []
readonly processes: Array<[number, 'SIGTERM' | 'SIGKILL']> = []
@@ -61,6 +62,7 @@ class FakeInspector implements ProcessInspector {
foregroundPgid() { return this.pgid }
isStdinWaiting() { return this.waiting }
processTree() { return this.members }
processSession() { return this.sessionMembers }
isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) }
signalGroup(pgid: number, signal: SubprocessTerminalSignal) {
if (this.throwGroup) throw new Error('group failed')
@@ -158,6 +160,36 @@ describe('LocalTerminalHandle', () => {
expect(await waiting).toBe(true)
})
it('cleans a same-session descendant after the top-level shell exits naturally', async () => {
const pty = new FakePty()
const inspector = new FakeInspector()
const disowned = { pid: 124, started: 'disowned' }
inspector.processSession = () => inspector.alive.has(disowned.pid) ? [disowned] : []
inspector.alive.add(124)
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
pty.emitExit()
expect(await handle.waitForExit()).toBe(true)
expect(inspector.processes).toEqual([[124, 'SIGTERM']])
})
it('retains an inspected descendant after it reparents away from the shell', async () => {
const pty = new FakePty()
const inspector = new FakeInspector()
const descendant = { pid: 124, started: 'observed' }
inspector.members = [descendant]
inspector.alive.add(descendant.pid)
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
await handle.inspectForeground()
inspector.members = []
pty.emitExit()
expect(await handle.waitForExit()).toBe(true)
expect(inspector.processes).toEqual([[124, 'SIGTERM']])
})
it('rescans for descendants forked during TERM', async () => {
const pty = new FakePty()
const inspector = new FakeInspector()