diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml index 622b1b983c..13b8524906 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md index e120f87350..fba5014d67 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md index 6cf7620ed0..33e13b7a1a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md @@ -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 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 97cea43fb7..60d25bcc1b 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -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 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 35b126e340..bf0af8779f 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -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 /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. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index ab5761d14b..64f58f63fd 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -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 /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。 - **终端进程检查仅支持 Linux/macOS**:检查器没有受支持的平台实现时,终端原语会失败;Linux 精确探针覆盖 x64 与 arm64,macOS 则使用 `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 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 91db98f944..433ba01791 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -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. */ diff --git a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts index 940d21e838..217338fa1e 100644 --- a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts @@ -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 }) } } diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 3d16376904..568a331a28 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -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) => {