test(subprocess): harden host-exit cleanup fixtures

This commit is contained in:
pku-xht
2026-08-11 22:56:45 +08:00
parent 87778a43aa
commit 84b15efd85
9 changed files with 21 additions and 16 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 .agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md
2026-08-11-synchronous-subprocess-exit-cleanup.md: e120f87350f1acd28f7f449791f01b6c0a57674e
2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: 6cf7620ed006c22b79b523242fa5f1429c58a2f3
2026-08-11-synchronous-subprocess-exit-cleanup.md: fba5014d67f5152d6f8e42b3b41c1bbd20c7ede3
2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: 33e13b7a1af9a943a266ea3ec979bf14e24f3802

View File

@@ -26,7 +26,7 @@ Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subpr
| --- | --- | --- |
| Normal Cordis disposal | Cooperative termination, bounded escalation, and awaited ordinary/terminal cleanup | Every owned handle reaches quiescence before disposal settles |
| `process.exit()`, default uncaught exception, or default unhandled rejection | Synchronous final signals against the service's current live sets | External observation after the host exits |
| `SIGKILL`, fatal OOM, `process.abort()`, native crash, or power loss | No in-process action can run | External supervisor, container, or OS ownership is required |
| Default termination for an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP`; `SIGKILL`; fatal OOM; `process.abort()`; native crash; or power loss | No in-process action can run | External supervisor, container, or OS ownership is required unless the application installs a signal handler that performs disposal or calls `process.exit()` |
## Verification

View File

@@ -26,7 +26,7 @@ Status: implemented
| --- | --- | --- |
| 正常 Cordis dispose | 协作式终止、有界升级并等待普通terminal清理 | dispose结算前每个自有 handle均达到完全停稳 |
| `process.exit()`、默认未捕获异常或默认未处理 rejection | 对服务当前存活集合发送同步最终信号 | 宿主退出后的外部观察 |
| `SIGKILL`fatal OOM`process.abort()`native crash或断电 | 进程内操作无法运行 | 必须由外部 supervisor、容器或 OS所有权负责 |
| 未安装 handler 时由 `SIGTERM``SIGINT``SIGHUP` 默认终止;`SIGKILL`fatal OOM`process.abort()`native crash或断电 | 进程内操作无法运行 | 必须由外部 supervisor、容器或 OS 所有权负责;应用安装执行 dispose 或调用 `process.exit()` 的信号 handler 时除外 |
## Verification

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: 35b126e3405f7416629c679b1d5510fdaf47b3d5
README.zh.md: ab5761d14b6d0e30c25ec58c9e40c8055d5f3150
README.md: bf0af8779f0cc3e2c20382db40be4715814e78f4
README.zh.md: 64f58f63fd32e1fa45f7642a59b01cf204ba92c3

View File

@@ -28,7 +28,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
- **Windows tree support is best-effort** — 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.
- **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 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.
- **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event. `SIGKILL`, fatal OOM, `process.abort()`, native crashes, power loss, and any failure that cannot run JavaScript require an external supervisor, container init, or equivalent OS owner.
- **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event. The default OS disposition for an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP` bypasses that event; an application covers those signals only by installing a handler that performs normal disposal or calls `process.exit()`. `SIGKILL`, fatal OOM, `process.abort()`, native crashes, power loss, and any failure that cannot run JavaScript require an external supervisor, container init, or equivalent OS owner.
- **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSPHRASE*`) 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

@@ -12,8 +12,8 @@
- **基于偏移量的读取**收集模式的读取器按完整流的字节坐标返回增量服务自身从不持有游标因此消费方自有的游标bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
- **可执行文件查找**`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该能力入口被拒绝,相对 PATH 条目从宿主进程 cwd 解析。
- **终端进程所有权**`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护pid/start 身份则防止清理跟随 PID 复用。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。
- **先终止再等待退出的 dispose资源释放**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn失败的句柄会在整棵进程树或 terminal session清理完成后离开存活集合。
- **同步宿主退出最终清理**:服务 effect仍有效时Node `exit` listener会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX进程组发送 SIGKILL、在 Windows运行 `taskkill /T /F`,并在终止 PTY root前后同步向已捕获及当前可观察的 terminal身份发送信号它们不会创建 Promise或 timer不改变宿主退出码与诊断会分别包含每个目标的失败也不会声称已经完全停稳。正常 dispose仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md)。
- **先终止再等待退出的 dispose资源释放**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。
- **同步宿主退出最终清理**:服务 effect 仍有效时Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer不改变宿主退出码与诊断会分别包含每个目标的失败也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md)。
## 模型体验
@@ -28,7 +28,7 @@
- **Windows 进程树支持仅为尽力而为**:终止经由 `taskkill /PID <pid> /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。
- **终端进程检查仅支持 LinuxmacOS**检查器没有受支持的平台实现时终端原语会失败Linux 精确探针覆盖 x64 与 arm64macOS 则使用 `ps` 快照。
- **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。
- **进程内清理要求退出阶段仍能执行 JavaScript**:直接 `process.exit()`、默认未捕获异常和默认未处理 rejection会发出 Node同步 `exit`事件。`SIGKILL`、fatal OOM、`process.abort()`、native crash、断电以及任何无法运行 JavaScript的故障都需要外部 supervisor、容器 init或等价的 OS所有者负责。
- **进程内清理要求退出阶段仍能执行 JavaScript**:直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件。未安装 handler 时,`SIGTERM``SIGINT``SIGHUP` 的默认 OS 处置不会发出该事件;应用只有安装执行正常 dispose 或调用 `process.exit()` 的 handler 才能覆盖这些信号。`SIGKILL`、fatal OOM、`process.abort()`、native crash、断电以及任何无法运行 JavaScript 的故障,都需要外部 supervisor、容器 init 或等价的 OS 所有者负责。
- **凭据清除依赖名称启发式规则**:只匹配 `*KEY*``*PASSWORD*``*SECRET*``*TOKEN*`;名称不同的 secret例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。

View File

@@ -59,8 +59,9 @@ export interface SpawnInternals {
}
/**
* Local-only extension used by the owning service during Node's synchronous
* host-exit phase. It is intentionally absent from the public subprocess seam.
* Local-only synchronous final termination used by the owning service during
* host exit and as the last fallback after failed normal disposal. It is
* intentionally absent from the public subprocess seam.
*/
export interface LocalSubprocessHandle extends SubprocessHandle {
/** Force-terminate the current tree synchronously without starting timers or waits. */

View File

@@ -104,6 +104,7 @@ async function runScenario(kind: ManagedKind, trigger: ExitTrigger) {
let state: TreeState | undefined
let identities: ProcessIdentity[] = []
let settled = false
let treeGone = false
try {
state = await readTree(join(root, 'tree.json'))
await vi.waitFor(() => readFile(join(root, 'ready'), 'utf8'), {
@@ -115,6 +116,7 @@ async function runScenario(kind: ManagedKind, trigger: ExitTrigger) {
const outcome = await child
settled = true
await waitForGone(state)
treeGone = true
const disposeCounts = trigger === 'dispose'
? JSON.parse(await readFile(join(root, 'dispose.json'), 'utf8')) as {
listenersBefore: number
@@ -128,8 +130,10 @@ async function runScenario(kind: ManagedKind, trigger: ExitTrigger) {
child.kill('SIGKILL')
await child.catch(() => {})
}
cleanupTree(state, identities)
if (state !== undefined) await waitForGone(state).catch(() => {})
if (!treeGone) {
cleanupTree(state, identities)
if (state !== undefined) await waitForGone(state).catch(() => {})
}
await rm(root, { recursive: true, force: true })
}
}

View File

@@ -584,7 +584,7 @@ describe('stdio dispositions', () => {
describe('windows tree semantics (injected platform)', () => {
it('host-exit termination routes through taskkill immediately', async () => {
const killed: number[] = []
const running = spawnSubprocess(spec('sleep 60', { graceMs: 60_000 }), {
const running = spawnSubprocess(spec('exec sleep 60', { graceMs: 60_000 }), {
spillDir,
platform: 'win32',
taskkill: (pid) => {
@@ -603,7 +603,7 @@ describe('windows tree semantics (injected platform)', () => {
it('terminate routes through taskkill by root pid', async () => {
const killed: number[] = []
const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), {
const running = spawnSubprocess(spec('exec sleep 60', { graceMs: 100 }), {
spillDir,
platform: 'win32',
taskkill: (pid) => {