fix(runtime): close lifecycle teardown races
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 packages/subprocess/subprocess-local/README.md
|
||||
README.md: 6bc1003ae5903bb5640728bc79c3f9042fddbe7a
|
||||
README.zh.md: 3c9ce73c7fcbeec9b17d73f212ecdcb6842ec133
|
||||
README.md: 38b26c33f3854acdb4a2bb48612da73361d727ba
|
||||
README.zh.md: fc4a5d4961fb7618b9db84b21063f907361a26d8
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 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 的后台读取路径)与完整流重读可以共存,结算前后皆然。
|
||||
- **执行世界坐标**:`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 测试矩阵之外。
|
||||
- **终端进程检查仅支持 Linux/macOS**:检查器没有受支持的平台实现时,终端原语会失败;Linux 精确探针覆盖 x64 与 arm64,macOS 使用 `ps` 快照。
|
||||
- **守护化的终端后代可能逃离已捕获进程树**:子进程若在拆卸前重新设定父进程,便无法再从 `node-pty` 根发现。本地提供方接受这个缺口,不向根 PID 的 POSIX 会话发送信号,因为其中可能包含无关的启动器进程。
|
||||
- **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程若在任何前台检查快照产生前重新设定父进程,便无法再从 `node-pty` 根发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与该提供方拥有的终端会话。本地提供方不会增加持续运行的进程表监视器。
|
||||
- **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
|
||||
- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user