fix(subprocess): preserve clean Windows range settlement
This commit is contained in:
1 parent
6e7b152de7
commit
5aab793045
5 files changed
+18
-12
No files matched your search
+2
-2
@@ -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/architecture/2026-08-28-subprocess-native-containment.md
|
||||
2026-08-28-subprocess-native-containment.md: 937c148101388aa49aac4826120218ff4a67a0e8
|
||||
2026-08-28-subprocess-native-containment.zh.md: 156d3a84d3a3a92a605866ee94a07616d18a09ee
|
||||
2026-08-28-subprocess-native-containment.md: 4d3832b969f31a24578c4cdd92eded0d11552ff0
|
||||
2026-08-28-subprocess-native-containment.zh.md: 05e2a9d6774aa1f64c43f2e743d790be0bd1a760
|
||||
@@ -28,7 +28,7 @@ The ordinary target result still comes from the same child process. The PTY path
|
||||
|
||||
### Windows runner and Job
|
||||
|
||||
The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one result. Runner fd 0 through fd 2 are isolated, fd 3 carries IPC, and fd 4 through fd 6 carry target stdin, stdout, and stderr. Fd 4 is always a pipe; when stdin is ignored, the parent destroys its writer before sending the start request so the target receives EOF without changing descriptor positions. The shared Win32 layer calls `GetStartupInfoW`, strictly decodes libuv's `cbReserved2`/`lpReserved2` table for fds 4 through 6, temporarily enables inheritance on those OS handles, and passes them through `STARTF_USESTDHANDLES`. `spawnCurrentTokenJobProcess` requires a separately resolved `applicationName` and a complete target environment, which it sends as a sorted, double-NUL-terminated UTF-16LE block with `CREATE_UNICODE_ENVIRONMENT`, including `=X:` drive entries, without mutating the runner environment. After the suspended target enters the Job and resumes, the runner closes only fd 4 through fd 6; it never mutates or destroys Node's standard streams. The parent returns the carrier streams as the ordinary handle's stdio, and user bytes never pass through IPC.
|
||||
The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one result. Runner fd 0 through fd 2 are isolated, fd 3 carries IPC, and fd 4 through fd 6 carry target stdin, stdout, and stderr. Fd 4 inherits the platform null-device descriptor when stdin is ignored and uses a pipe otherwise. The shared Win32 layer calls `GetStartupInfoW`, strictly decodes libuv's `cbReserved2`/`lpReserved2` table for fds 4 through 6, temporarily enables inheritance on those OS handles, and passes them through `STARTF_USESTDHANDLES`. `spawnCurrentTokenJobProcess` requires a separately resolved `applicationName` and a complete target environment, which it sends as a sorted, double-NUL-terminated UTF-16LE block with `CREATE_UNICODE_ENVIRONMENT`, including `=X:` drive entries, without mutating the runner environment. After the suspended target enters the Job and resumes, the runner closes only fd 4 through fd 6; it never mutates or destroys Node's standard streams. The parent returns the pipe carriers as the ordinary handle's stdio, and user bytes never pass through IPC.
|
||||
|
||||
The runner is the sole owner of the target process handle and unnamed Job handle. `spawnCurrentTokenJobProcess` creates the target suspended, assigns it to a kill-on-close Job that disallows active breakaway, and resumes it only after assignment. The runner polls the direct process for the target exit code and the Job for active-process count. It exits successfully only after the direct result has been delivered through the IPC send callback and the Job has reported zero active processes; the parent maps only that clean exit to successful `waitForExit()`.
|
||||
|
||||
@@ -55,7 +55,7 @@ This note owns the current native-containment mechanism. It partially updates th
|
||||
## Verification
|
||||
|
||||
- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, the three scope-establishment states, and exactly-once PTY managed-owner cleanup.
|
||||
- Windows protocol and Win32 suites pin exactly three result branches, numeric-only target exits, raw local cancellation reasons, `EPERM`/`-4048` access-denied mapping, explicit sorted target environment blocks with `=C:` preservation and double-NUL termination, strict `GetStartupInfoW` libuv descriptor-table decoding, the always-piped ignored-stdin carrier, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup.
|
||||
- Windows protocol and Win32 suites pin exactly three result branches, numeric-only target exits, raw local cancellation reasons, `EPERM`/`-4048` access-denied mapping, explicit sorted target environment blocks with `=C:` preservation and double-NUL termination, strict `GetStartupInfoW` libuv descriptor-table decoding, the null-device ignored-stdin carrier and piped non-ignored stdin, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup.
|
||||
- Real Linux user-systemd tests run one ordinary and one `node-pty` `setsid`/reparent scenario through the production entry. They prove scope signalling and collection, bare executable lookup, escaped-descendant termination, range settlement, and unchanged PTY PID, session, controlling-terminal, foreground-input, `/dev/tty`, readiness, and startup-failure semantics.
|
||||
- Native Windows tests prove suspended creation, Job assignment before resume, inherited stdio, default descendant inheritance, direct result, termination, active-process zero, abnormal/disconnected runner cleanup, kill-on-close, and synchronous host-exit termination. Source, built, and Python packaged smokes enter the same runner core.
|
||||
- Public seam types, local and E2B providers, LSP and subagent consumers, shell fixtures, READMEs, the Cordis catalog, and the keyless subprocess API snapshot contain no ordinary PID; terminal PID remains.
|
||||
|
||||
+2
-2
@@ -28,7 +28,7 @@ request 被消费或 manager 已观察到 unit 都能建立 scope ownership。
|
||||
|
||||
### Windows runner 与 Job
|
||||
|
||||
Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 result。runner 的 fd 0 至 fd 2 相互隔离,fd 3 承载 IPC,fd 4 至 fd 6 承载 target stdin、stdout 与 stderr。fd 4 始终是 pipe;忽略 stdin 时,parent 会在发送 start request 前销毁写端,使 target 在不改变描述符位置的情况下收到 EOF。共享 Win32 层调用 `GetStartupInfoW`,严格解码 libuv 的 `cbReserved2`/`lpReserved2` 表以取得 fd 4 至 fd 6 的 OS handle,临时启用这些 handle 的继承,并通过 `STARTF_USESTDHANDLES` 传入。`spawnCurrentTokenJobProcess` 要求单独解析的 `applicationName` 与完整 target 环境,并使用 `CREATE_UNICODE_ENVIRONMENT` 传入排序、双 NUL 结尾的 UTF-16LE 块,其中包括 `=X:` 驱动器条目,而不修改 runner 环境。suspended target 进入 Job 并恢复后,runner 只关闭 fd 4 至 fd 6,绝不改写或销毁 Node 标准流。parent 把 carrier stream 作为普通句柄的 stdio 返回,用户字节绝不经过 IPC。
|
||||
Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 result。runner 的 fd 0 至 fd 2 相互隔离,fd 3 承载 IPC,fd 4 至 fd 6 承载 target stdin、stdout 与 stderr。忽略 stdin 时,fd 4 继承平台 null-device descriptor;其他模式使用 pipe。共享 Win32 层调用 `GetStartupInfoW`,严格解码 libuv 的 `cbReserved2`/`lpReserved2` 表以取得 fd 4 至 fd 6 的 OS handle,临时启用这些 handle 的继承,并通过 `STARTF_USESTDHANDLES` 传入。`spawnCurrentTokenJobProcess` 要求单独解析的 `applicationName` 与完整 target 环境,并使用 `CREATE_UNICODE_ENVIRONMENT` 传入排序、双 NUL 结尾的 UTF-16LE 块,其中包括 `=X:` 驱动器条目,而不修改 runner 环境。suspended target 进入 Job 并恢复后,runner 只关闭 fd 4 至 fd 6,绝不改写或销毁 Node 标准流。parent 把 pipe carrier stream 作为普通句柄的 stdio 返回,用户字节绝不经过 IPC。
|
||||
|
||||
runner 是 target process handle 与 unnamed Job handle 的唯一 owner。`spawnCurrentTokenJobProcess` 以 suspended 状态创建 target,把它分配给不允许 active breakaway 的 kill-on-close Job,并只在分配后恢复。runner 轮询 direct process 获取 target exit code,并轮询 Job 获取 active-process count。只有 direct result 已通过 IPC send callback 交付且 Job 已报告零 active process 后,runner 才成功退出;parent 只把这次 clean exit 映射成成功的 `waitForExit()`。
|
||||
|
||||
@@ -55,7 +55,7 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu
|
||||
## Verification
|
||||
|
||||
- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、三种 scope 建立状态,以及 PTY managed-owner 恰好一次 cleanup。
|
||||
- Windows 协议与 Win32 测试套件固定恰好三个 result 分支、只含数字的 target exit、原样本地 cancellation reason、access denied 到 `EPERM`/`-4048` 的映射、显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、严格的 `GetStartupInfoW` libuv 描述符表解码、始终使用 pipe 的 ignored-stdin carrier、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。
|
||||
- Windows 协议与 Win32 测试套件固定恰好三个 result 分支、只含数字的 target exit、原样本地 cancellation reason、access denied 到 `EPERM`/`-4048` 的映射、显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、严格的 `GetStartupInfoW` libuv 描述符表解码、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。
|
||||
- 真实 Linux user-systemd 测试会分别通过生产入口运行一条普通命令与一条 `node-pty` `setsid`/reparent 场景。它们证明 scope signalling 与 collection、裸可执行文件查找、逃逸后代终止、range settlement,以及不变的 PTY PID、session、控制终端、前台输入、`/dev/tty`、readiness 与 startup-failure 语义。
|
||||
- native Windows 测试证明 suspended creation、resume 前 Job assignment、继承 stdio、默认后代继承、direct result、termination、active-process zero、异常/disconnected runner cleanup、kill-on-close 与同步 host-exit termination。source、built 与 Python packaged 冒烟测试进入同一 runner core。
|
||||
- 公共 seam 类型、local 与 E2B provider、LSP 与 subagent 消费方、shell fixture、README、Cordis catalog 与 keyless subprocess API snapshot 都不包含普通 PID;terminal PID 保留。
|
||||
|
||||
@@ -65,6 +65,7 @@ class WindowsJobOwner implements BoundProcessOwner {
|
||||
constructor(
|
||||
private readonly runner: RunnerProcess,
|
||||
private readonly exited: Promise<void>,
|
||||
private readonly directResultSeen: () => boolean,
|
||||
private readonly failInfrastructure: (error: unknown) => void,
|
||||
) {
|
||||
void this.exited.catch(() => {})
|
||||
@@ -79,7 +80,7 @@ class WindowsJobOwner implements BoundProcessOwner {
|
||||
this.terminationSent = true
|
||||
try {
|
||||
this.runner.send?.({ type: 'terminate' }, (error) => {
|
||||
if (error === null || !this.runner.connected) return
|
||||
if (error === null || this.directResultSeen() || !this.runner.connected) return
|
||||
this.failInfrastructure(error)
|
||||
this.terminateForHostExit()
|
||||
})
|
||||
@@ -145,7 +146,12 @@ export function launchWindowsJob(
|
||||
rangeExit.reject(error)
|
||||
}
|
||||
|
||||
const owner = new WindowsJobOwner(child, rangeExit.promise, failInfrastructure)
|
||||
const owner = new WindowsJobOwner(
|
||||
child,
|
||||
rangeExit.promise,
|
||||
() => resultSeen,
|
||||
failInfrastructure,
|
||||
)
|
||||
child.on('message', (value: unknown) => {
|
||||
if (resultSeen) {
|
||||
const error = new Error('subprocess-local: Windows runner emitted more than one direct result')
|
||||
|
||||
@@ -334,7 +334,7 @@ describe('Windows parent runner contract', () => {
|
||||
await expect(error.result.owner.waitForExit()).rejects.toThrow('send threw')
|
||||
})
|
||||
|
||||
it('ignores a terminate callback error delivered after clean runner disconnect', async () => {
|
||||
it('ignores a terminate callback error after a direct result while the runner is connected', async () => {
|
||||
const child = new FakeChild()
|
||||
const launched = launch(child)
|
||||
const handle = bindManagedProcess(spec, launched.result)
|
||||
@@ -346,12 +346,12 @@ describe('Windows parent runner contract', () => {
|
||||
await expect(handle.done).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(child.pendingSendCallbacks).toHaveLength(1)
|
||||
|
||||
child.connected = false
|
||||
child.emit('close', 0, null)
|
||||
await expect(handle.waitForExit()).resolves.toBe(true)
|
||||
expect(child.connected).toBe(true)
|
||||
child.deliverNextSend(new Error('late EPIPE'))
|
||||
await Promise.resolve()
|
||||
expect(child.killed).toEqual([])
|
||||
child.connected = false
|
||||
child.emit('close', 0, null)
|
||||
await expect(handle.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user