From 5b47da02aee90da2b369b0a8c1beb08968859caf Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 15:21:01 +0800 Subject: [PATCH] refactor(win32-process): restore mechanical extraction --- ...-shared-win32-process-primitives.i18n.yaml | 4 +- ...6-08-19-shared-win32-process-primitives.md | 8 +- ...8-19-shared-win32-process-primitives.zh.md | 8 +- .../2026-07-26-ci-failover-runbook.i18n.yaml | 4 +- .../process/2026-07-26-ci-failover-runbook.md | 2 +- .../2026-07-26-ci-failover-runbook.zh.md | 2 +- .github/workflows/ci.yml | 8 - .../sandbox/sandbox-windows-acl/src/index.ts | 96 +++-------- .../sandbox/sandbox-windows-acl/src/spawn.ts | 2 +- .../tests/index-failure-paths.spec.ts | 150 +----------------- packages/subprocess/README.i18n.yaml | 4 +- packages/subprocess/README.md | 2 +- packages/subprocess/README.zh.md | 2 +- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 7 +- .../subprocess/win32-process/README.zh.md | 7 +- packages/subprocess/win32-process/src/abi.ts | 10 +- packages/subprocess/win32-process/src/ffi.ts | 27 +--- .../subprocess/win32-process/src/index.ts | 1 - .../win32-process/src/job-attribute.ts | 124 --------------- .../subprocess/win32-process/src/process.ts | 74 +++++---- .../win32-process/tests/job-attribute.spec.ts | 65 -------- .../tests/process-allocation-failure.spec.ts | 26 +-- .../tests/process-failure-paths.spec.ts | 74 ++++----- .../win32-process/tests/process.spec.ts | 137 +++++++--------- .../win32-process/verify/abi-probe.cpp | 10 +- scripts/ci-workflow.spec.ts | 15 +- scripts/verify-win32-abi.ps1 | 25 --- 28 files changed, 188 insertions(+), 710 deletions(-) delete mode 100644 packages/subprocess/win32-process/src/job-attribute.ts delete mode 100644 packages/subprocess/win32-process/tests/job-attribute.spec.ts delete mode 100644 scripts/verify-win32-abi.ps1 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 4e5dac463c..780aa7e236 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.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/architecture/2026-08-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: a190fbd78d3f6e33e5626b01a38a9c2cfbb8216f -2026-08-19-shared-win32-process-primitives.zh.md: 79e1ae576bc0d14d0e4f152825171d3d8510bb70 +2026-08-19-shared-win32-process-primitives.md: 8765e5f7350dab56ad42169f6e16b55679ca8982 +2026-08-19-shared-win32-process-primitives.zh.md: b21ece8445e8863c08818c42d6c9bf7672813823 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index a190fbd78d..8765e5f735 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -10,17 +10,17 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p ## Decision -`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations currently consumed by `sandbox-windows-acl`. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW`, `STARTUPINFOEXW`, and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW`, and exposes checked restricted-token pipe and inherited-stdio Job operations. +`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations currently consumed by `sandbox-windows-acl`. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW`, and exposes checked restricted-token pipe and inherited-stdio Job operations. The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle acquired before a failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox; if either drain fails, sandbox settlement requests direct-child termination, cancels and joins the sibling drain, then performs the direct-child wait only when termination succeeded. A termination failure instead closes the process handle and reports both failures. Either result leaves no polling timer alive even when a descendant inherited a pipe writer. Inherited-stdio creation puts the kill-on-close Job in `STARTUPINFOEXW`, so the child is already Job-owned before any user code can run; attribute or creation failure therefore has one deterministic cleanup owner. The sandbox owns returned process, pipe, and Job handles until wait or disposal. +Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Inherited-stdio creation starts the target suspended, assigns it to the kill-on-close Job, and resumes it only after assignment, so target code cannot run outside the Job. Assignment failure terminates the suspended target before releasing its handles; resume failure closes the assigned Job. The sandbox retains its existing pipe-drain, direct-wait, result, and returned-Job lifecycle. The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK. ## Verification -The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, atomic Job attachment during creation, wait and exit-code reads, native allocation release, and every acquired-resource failure set. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. Native Windows checks compile both header probes and run the migrated sandbox paths; Wine supplies the emulated Windows package and composition signal. +The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, suspended creation followed by Job assignment and resume, wait and exit-code reads, native allocation release, and the acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the migrated ABI and native paths; Wine supplies the emulated Windows package and composition signal. ## Alternatives considered @@ -32,4 +32,4 @@ The shared suite covers x64 ABI values, command-line quoting, binding extension, ## Consequences -The sandbox keeps its public behavior while generic Win32 resource ownership has one package and one test home. The package boundary adds one workspace dependency and a published library, and callers must explicitly own policy, scheduling, result composition, and returned HANDLE closure. Future process consumers extend the low-level package only when their production path exists. +The sandbox keeps its public behavior while generic Win32 resource ownership has one package and one test home. The package boundary adds one workspace dependency and a published library, and callers must explicitly own policy, scheduling, result composition, and returned HANDLE closure. Suspended creation guarantees that target code starts only after Job assignment, but it does not make the runner's create-to-assignment interval atomic against external termination. Future process consumers extend the low-level package only when their production path exists. diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index 79e1ae576b..b21ece8445 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -10,17 +10,17 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p ## Decision -`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 当前消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW`、`STARTUPINFOEXW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 引用 argv,并提供带检查的 restricted-token pipe 与 inherited-stdio Job 操作。 +`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 当前消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 引用 argv,并提供带检查的 restricted-token pipe 与 inherited-stdio Job 操作。 Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox;任一 drain 失败时,sandbox settlement 会请求终止 direct child,取消并等待 sibling drain,再只在终止成功时执行 direct-child wait。若终止本身失败,则关闭 process handle 并同时报告两项失败。即使 descendant 继承了 pipe writer,两种结果也都不会留下持续轮询的 timer。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。 +每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。分配失败会先终止 suspended target 再释放句柄;恢复失败会关闭已经分配的 Job。sandbox 保留既有 pipe-drain、direct-wait、result 与返回 Job 的生命周期。 该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。 ## Verification -shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、创建时的原子 Job 附加、wait 与 exit-code 读取、native allocation 释放,以及每组已取得资源的失败闭集。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。Windows native 检查会编译两份 header probe 并运行迁移后的 sandbox 路径;Wine 提供模拟 Windows package 与组合信号。 +shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、suspended 创建后的 Job 分配与恢复、wait 与 exit-code 读取、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖迁移后的 ABI 和 native 路径;Wine 提供模拟 Windows package 与组合信号。 ## Alternatives considered @@ -32,4 +32,4 @@ shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF ## Consequences -sandbox 保持公共行为,而通用 Win32 resource ownership 只有一个 package 与一个测试归属。该 package boundary 增加一个 workspace dependency 和发布 library;调用方必须显式拥有 policy、调度、result 组合与返回 HANDLE 的关闭责任。后续 process consumer 只在其生产路径存在时扩展低层 package。 +sandbox 保持公共行为,而通用 Win32 resource ownership 只有一个 package 与一个测试归属。该 package boundary 增加一个 workspace dependency 和发布 library;调用方必须显式拥有 policy、调度、result 组合与返回 HANDLE 的关闭责任。suspended 创建保证目标代码只在 Job 分配后启动,但不会让 runner 的 create-to-assignment 区间对外部终止具备原子性。后续 process consumer 只在其生产路径存在时扩展低层 package。 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 55592adfb6..f8cdf8e924 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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/process/2026-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: c4d1677d8f8f632ae31cf5bcfbbd5386c9932919 -2026-07-26-ci-failover-runbook.zh.md: bce9054e051d8c919b038337922174e33ad60f9c +2026-07-26-ci-failover-runbook.md: e8a1d1dc339cc5d9be3db3be395e2cddad93b6fc +2026-07-26-ci-failover-runbook.zh.md: 8f92b7b60c075f21b6f2c83dc46a6e0e5d8acce2 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index c4d1677d8f..e8a1d1dc33 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -24,7 +24,7 @@ The decision belongs at workflow level because cancellation applies to the whole #### Windows pool -`dsh-win-ci`: 32 always-on runner instances (scheduled tasks `GH-Runner-01`…`GH-Runner-32`) on the in-house Windows CI server (one 96-core / 580 GB machine). Labels: `[self-hosted, dsh-win-ci, windows]`. The image must preinstall Node 24, pnpm, Git (with Git Bash on `PATH`, i.e. `C:\Program Files\Git\bin` — the `bash` tool spawns `bash` by name), PowerShell 7, Visual Studio C++ Build Tools with the x64 MSVC toolchain and Windows SDK, and enable Developer Mode for symlink support. Check the latest `serial / windows (self-hosted standby)` run before switching: before the complete aggregate, that lane compiles and runs the same two Win32 header ABI probes as `windows-native`, so a green standby verifies both the compiler prerequisite and `check:ci:windows-complete` end-to-end. +`dsh-win-ci`: 32 always-on runner instances (scheduled tasks `GH-Runner-01`…`GH-Runner-32`) on the in-house Windows CI server (one 96-core / 580 GB machine). Labels: `[self-hosted, dsh-win-ci, windows]`. The image must preinstall Node 24, pnpm, Git (with Git Bash on `PATH`, i.e. `C:\Program Files\Git\bin` — the `bash` tool spawns `bash` by name), PowerShell 7, and enable Developer Mode for symlink support. Check the latest `serial / windows (self-hosted standby)` run before switching: a green standby verifies the pool can execute `check:ci:windows-complete` end-to-end. ### Switch (any repository writer, ~1 minute, no merge) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index bce9054e05..8f92b7b60c 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -24,7 +24,7 @@ Status: implemented #### Windows 池 -`dsh-win-ci`:公司内部 Windows CI 服务器(一台 96 核 / 580 GB 机器)上 32 个常驻运行器实例(计划任务 `GH-Runner-01`…`GH-Runner-32`)。标签:`[self-hosted, dsh-win-ci, windows]`。镜像必须预装 Node 24、pnpm、Git(Git Bash 在 `PATH` 上,即 `C:\Program Files\Git\bin`——`bash` 工具按名称 spawn `bash`)、PowerShell 7、带 x64 MSVC 工具链与 Windows SDK 的 Visual Studio C++ Build Tools,并为符号链接支持启用开发人员模式。切换前先看 `serial / windows (self-hosted standby)` 最近一次运行:该通道会在完整聚合前编译并运行与 `windows-native` 相同的两份 Win32 header ABI probe,因此绿色热备会同时验证编译器前置条件与 `check:ci:windows-complete` 端到端流程。 +`dsh-win-ci`:公司内部 Windows CI 服务器(一台 96 核 / 580 GB 机器)上 32 个常驻运行器实例(计划任务 `GH-Runner-01`…`GH-Runner-32`)。标签:`[self-hosted, dsh-win-ci, windows]`。镜像必须预装 Node 24、pnpm、Git(Git Bash 在 `PATH` 上,即 `C:\Program Files\Git\bin`——`bash` 工具按名称 spawn `bash`)、PowerShell 7,并为符号链接支持启用开发人员模式。切换前先看 `serial / windows (self-hosted standby)` 最近一次运行:绿色热备验证该池能端到端执行 `check:ci:windows-complete`。 ### 切换步骤(任何具备写权限的协作者,约 1 分钟,无需合并) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 002de361b8..741a6c4d5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -506,10 +506,6 @@ jobs: shell: pwsh run: pnpm install --frozen-lockfile - - name: Compile and run Win32 header ABI probes - shell: pwsh - run: ./scripts/verify-win32-abi.ps1 - - name: Run complete native Windows gate inventory shell: pwsh run: pnpm run check:ci:windows-complete @@ -645,10 +641,6 @@ jobs: shell: pwsh run: pnpm install --frozen-lockfile - - name: Compile and run Win32 header ABI probes - shell: pwsh - run: ./scripts/verify-win32-abi.ps1 - - name: Run complete unsharded Windows gate inventory serially shell: pwsh env: diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 989f31d86a..9e4568c726 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -42,7 +42,7 @@ import { existsSync, statSync } from 'node:fs' import { resolve } from 'node:path' -import { closeHandleChecked, Win32Error } from '@deepseek-ai/dsh-win32-process' +import { Win32Error } from '@deepseek-ai/dsh-win32-process' import { grantWrite, revokeWrite } from './acl.ts' import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi.ts' @@ -356,88 +356,34 @@ export class AclSandbox { if (options.stdio === 'inherit') { const native = spawnSandboxedInherited(api, token, { command: options.command, args, cwd }) - let settlement: Promise | undefined + let exitCodePromise: Promise | undefined return { pid: native.pid, - wait: () => (settlement ??= new Promise((resolveResult) => { - const failures: unknown[] = [] - let exitCode = 0 - try { - exitCode = waitForExit(api, native.process) - } catch (error) { - failures.push(error) - } - try { - closeHandleChecked(api, native.job, 'kill-on-close job') - } catch (error) { - failures.push(error) - } - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'inherited child settlement failed') - resolveResult({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode }) - })), + wait: async () => { + exitCodePromise ??= Promise.resolve(waitForExit(api, native.process)) + const exitCode = await exitCodePromise + if (api.closeHandle(native.job) === 0) throwLastError(api, 'CloseHandle', 'kill-on-close job') + return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode } + }, } } const native = spawnSandboxed(api, token, { command: options.command, args, cwd }) - const drainAbort = new AbortController() - const drainCancellation = new Error('piped child drain cancelled after peer failure') - const stdout = drainPipe(api, native.stdoutRead, drainAbort.signal) - const stderr = drainPipe(api, native.stderrRead, drainAbort.signal) - // WaitForSingleObject blocks the thread, so settlement starts it only after - // both drains settle. Successful drains mean the child closed its pipe ends - // and the wait returns immediately. A failed drain cancels its sibling and - // terminates the child before waiting, so inherited pipe writers cannot pin - // the event loop after settlement. - let settlement: Promise | undefined + const stdout = drainPipe(api, native.stdoutRead) + const stderr = drainPipe(api, native.stderrRead) + // waitForExit is deliberately NOT started here: WaitForSingleObject blocks + // the thread and would starve the drains while the child is still running + // (pipe-buffer deadlock). The drains resolve only after the child closed + // its pipe ends — by then the wait returns immediately. + let exitCodePromise: Promise | undefined return { pid: native.pid, - wait: () => (settlement ??= (async () => { - let drains: PromiseSettledResult[] - try { - const [stdoutBuffer, stderrBuffer] = await Promise.all([stdout, stderr]) - drains = [ - { status: 'fulfilled', value: stdoutBuffer }, - { status: 'fulfilled', value: stderrBuffer }, - ] - } catch { - const terminated = api.terminateProcess(native.process, 1) - const terminationCode = terminated === 0 ? api.getLastError() : 0 - drainAbort.abort(drainCancellation) - const settledDrains = await Promise.allSettled([stdout, stderr]) - if (terminated === 0) { - const failures = settledDrains.flatMap(outcome => - outcome.status === 'rejected' && outcome.reason !== drainCancellation - ? [outcome.reason as unknown] - : []) - try { - closeHandleChecked(api, native.process, 'piped child after drain failure') - } catch (error) { - failures.push(error) - } - failures.push(new Win32Error('TerminateProcess', terminationCode, `pid ${native.pid} after drain failure`)) - throw new AggregateError(failures, 'piped child settlement failed') - } - drains = settledDrains - } - const failures = drains.flatMap(outcome => - outcome.status === 'rejected' && outcome.reason !== drainCancellation - ? [outcome.reason as unknown] - : []) - let exitCode = 0 - try { - exitCode = waitForExit(api, native.process) - } catch (error) { - failures.push(error) - } - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'piped child settlement failed') - return { - stdout: (drains[0] as PromiseFulfilledResult).value, - stderr: (drains[1] as PromiseFulfilledResult).value, - exitCode, - } - })()), + wait: async () => { + const stdoutBuffer = await stdout + const stderrBuffer = await stderr + exitCodePromise ??= Promise.resolve(waitForExit(api, native.process)) + return { stdout: stdoutBuffer, stderr: stderrBuffer, exitCode: await exitCodePromise } + }, } } diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts index 3336a309f1..a36b0253c4 100644 --- a/packages/sandbox/sandbox-windows-acl/src/spawn.ts +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -39,7 +39,7 @@ export function spawnSandboxed( * @param api - ACL/token binding table. * @param token - restricted primary token. * @param options - command, args, and working directory. - * @returns process and Job handles after atomic attachment during creation. + * @returns process and Job handles after assignment and resume. */ export function spawnSandboxedInherited( api: Win32Bindings, diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 5cad94742a..64899d9db8 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -145,15 +145,9 @@ function happyStubs(): HappyStubs { }) const createJobObjectW = vi.fn(() => fresh()) const setInformationJobObject = vi.fn(() => 1) - const initializeProcThreadAttributeList = vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - koffi.encode(size, 'size_t', 64) - return 0 - } - return 1 - }) - const updateProcThreadAttribute = vi.fn(() => 1) - const deleteProcThreadAttributeList = vi.fn() + const assignProcessToJobObject = vi.fn(() => 1) + const resumeThread = vi.fn(() => 0) + const terminateProcess = vi.fn(() => 1) const getStdHandle = vi.fn(() => fresh()) const localFree = vi.fn(() => 0n) const closeHandle = vi.fn(() => 1) @@ -167,8 +161,8 @@ function happyStubs(): HappyStubs { getLengthSid, copySid, createWellKnownSid, isValidSid, createRestrictedToken, setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW, peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW, - setInformationJobObject, initializeProcThreadAttributeList, updateProcThreadAttribute, - deleteProcThreadAttributeList, getStdHandle, + setInformationJobObject, assignProcessToJobObject, resumeThread, terminateProcess, + getStdHandle, localFree, closeHandle, getLastError, formatMessageW, } as unknown as Win32Bindings return { @@ -403,140 +397,6 @@ describe('AclSandbox spawn', () => { jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr await expect(child.wait()).rejects.toMatchObject({ api: 'CloseHandle' }) }) - - it('inherit spawn caches one failing settlement and closes the Job once', async () => { - const { api, closeHandle, createJobObjectW } = state.stubs as HappyStubs - api.waitForSingleObject = vi.fn(() => 0xFFFFFFFF) - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-1', mode: 'workspace-write' }) - await sandbox.init() - const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' }) - const jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr - await expect(child.wait()).rejects.toMatchObject({ api: 'WaitForSingleObject' }) - await expect(child.wait()).rejects.toMatchObject({ api: 'WaitForSingleObject' }) - expect(closeHandle.mock.calls.filter(([handle]) => handle === jobHandle)).toHaveLength(1) - }) - - it('inherit spawn aggregates wait and Job-close failures', async () => { - const { api, closeHandle, createJobObjectW } = state.stubs as HappyStubs - api.waitForSingleObject = vi.fn(() => 0xFFFFFFFF) - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-1-1', mode: 'workspace-write' }) - await sandbox.init() - let jobHandle = 0n - closeHandle.mockImplementation((handle: NativePtr) => (handle === jobHandle ? 0 : 1)) - const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' }) - jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr - await expect(child.wait()).rejects.toMatchObject({ - errors: [ - expect.objectContaining({ api: 'WaitForSingleObject' }), - expect.objectContaining({ api: 'CloseHandle' }), - ], - }) - }) - - it('pipe spawn reports a wait failure after successful drains', async () => { - const { api } = state.stubs as HappyStubs - api.waitForSingleObject = vi.fn(() => 0xFFFFFFFF) - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-1-2', mode: 'workspace-write' }) - await sandbox.init() - const child = sandbox.spawn({ command: 'probe.exe' }) - await expect(child.wait()).rejects.toMatchObject({ api: 'WaitForSingleObject' }) - }) - - it('pipe spawn still closes the process after a drain failure', async () => { - const { api } = state.stubs as HappyStubs - api.getLastError = vi.fn(() => 5) - const terminateProcess = vi.fn(() => 1) - api.terminateProcess = terminateProcess - const waitForSingleObject = vi.fn(() => 0) - api.waitForSingleObject = waitForSingleObject - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-2', mode: 'workspace-write' }) - await sandbox.init() - const child = sandbox.spawn({ command: 'probe.exe' }) - await expect(child.wait()).rejects.toMatchObject({ - errors: [ - expect.objectContaining({ api: 'PeekNamedPipe' }), - expect.objectContaining({ api: 'PeekNamedPipe' }), - ], - }) - expect(terminateProcess).toHaveBeenCalledOnce() - expect(waitForSingleObject).toHaveBeenCalledOnce() - }) - - it('pipe spawn terminates promptly when one drain fails and the sibling remains open', async () => { - const { api } = state.stubs as HappyStubs - let peekCount = 0 - api.peekNamedPipe = vi.fn((_handle, _buffer, _size, _read, totalAvail: NativePtr) => { - peekCount += 1 - if (peekCount === 1) return 0 - koffi.encode(totalAvail, 'uint32', 0) - return 1 - }) - api.getLastError = vi.fn(() => 5) - const terminateProcess = vi.fn(() => 1) - api.terminateProcess = terminateProcess - const waitForSingleObject = vi.fn(() => 0) - api.waitForSingleObject = waitForSingleObject - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-2-1', mode: 'workspace-write' }) - await sandbox.init() - const child = sandbox.spawn({ command: 'probe.exe' }) - await expect(child.wait()).rejects.toMatchObject({ api: 'PeekNamedPipe' }) - const settledPeekCount = peekCount - await new Promise(resolve => setTimeout(resolve, 5)) - expect(peekCount).toBe(settledPeekCount) - expect(terminateProcess).toHaveBeenCalledOnce() - expect(waitForSingleObject).toHaveBeenCalledOnce() - }) - - it('pipe spawn closes the process without waiting when termination after a drain failure fails', async () => { - const { api, closeHandle } = state.stubs as HappyStubs - let peekCount = 0 - api.peekNamedPipe = vi.fn((_handle, _buffer, _size, _read, totalAvail: NativePtr) => { - peekCount += 1 - if (peekCount === 1) return 0 - koffi.encode(totalAvail, 'uint32', 0) - return 1 - }) - api.getLastError = vi.fn(() => 5) - api.terminateProcess = vi.fn(() => 0) - const waitForSingleObject = vi.fn(() => { throw new Error('must not wait') }) - api.waitForSingleObject = waitForSingleObject - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-3', mode: 'workspace-write' }) - await sandbox.init() - const child = sandbox.spawn({ command: 'probe.exe' }) - await expect(child.wait()).rejects.toBeInstanceOf(AggregateError) - const settledPeekCount = peekCount - await new Promise(resolve => setTimeout(resolve, 5)) - expect(peekCount).toBe(settledPeekCount) - expect(waitForSingleObject).not.toHaveBeenCalled() - expect(closeHandle).toHaveBeenCalled() - }) - - it('pipe spawn aggregates process-handle closure failure after termination failure', async () => { - const { api } = state.stubs as HappyStubs - api.getLastError = vi.fn(() => 5) - api.terminateProcess = vi.fn(() => 0) - const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14-4', mode: 'workspace-write' }) - await sandbox.init() - const child = sandbox.spawn({ command: 'probe.exe' }) - api.closeHandle = vi.fn(() => 0) - const settlement = child.wait() - await expect(settlement).rejects.toBeInstanceOf(AggregateError) - const failure = await settlement.catch((error: unknown): unknown => error) - if (!(failure instanceof AggregateError)) throw new Error('expected AggregateError') - const errors = failure.errors as unknown[] - const apis = errors - .filter((error): error is Win32Error => error instanceof Win32Error) - .map(error => error.api) - expect(apis.filter(api => api === 'PeekNamedPipe')).toHaveLength(2) - expect(apis).toEqual(expect.arrayContaining(['CloseHandle', 'TerminateProcess'])) - }) }) describe('AclSandbox dispose', () => { diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index 62da073509..32344c63b8 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/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/README.md -README.md: 56d6c04af92fa07673e3f8881bf20e47358fd001 -README.zh.md: 8b7db95e0196dbc98a67657478e4e242b4f7bca9 +README.md: 790db2c3c82fc9e359cae6a0ff1eab156b2776b5 +README.zh.md: e6ac837e0c0408720d46609edf154d423a96c11b diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index 56d6c04af9..790db2c3c8 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -8,7 +8,7 @@ The shared process substrate for one execution world: executable lookup, fully-s |---|---|---| | [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition: executable lookup, ordinary managed spawns, the terminal-process primitive, handle lifecycles, and shared environment/output vocabulary | | [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | Local Service Provider: detached process trees, bounded collection/spill, `node-pty`, foreground/session inspection, tree signalling, and terminate-and-join disposal | -| [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for restricted process creation, inherited/anonymous-pipe stdio, atomic Job attachment, waits, and handle cleanup | +| [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for restricted process creation, inherited/anonymous-pipe stdio, suspended Job assignment, waits, and handle cleanup | The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index 8b7db95e01..e6ac837e0c 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -8,7 +8,7 @@ |---|---|---| | [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition:可执行文件查找、普通受管 spawn、终端进程原语、句柄生命周期,以及共享的环境/输出词汇 | | [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:detached 进程树、有界收集/spill、`node-pty`、前台/会话检查、进程树信号发送,以及先终止再等待退出的 dispose(资源释放) | -| [`win32-process`](win32-process/README.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:restricted process creation、继承/匿名管道 stdio、原子 Job 附加、wait 与句柄清理的唯一 Koffi owner | +| [`win32-process`](win32-process/README.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:restricted process creation、继承/匿名管道 stdio、suspended Job 分配、wait 与句柄清理的唯一 Koffi owner | 即使消费方重载,进程生命周期仍由服务负责管理;消费方负责定义进程的含义(一条 bash 命令、未来的非 shell 运行器),以及决定塑造该进程的每一项默认值。 diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 24f7b1f607..d9bb79be51 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/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/win32-process/README.md -README.md: 3e82c10b7894b15d970b794429c69c6923632bb9 -README.zh.md: b1afc1a7222189329cbd84d9729fbafc3ecd3acb +README.md: 0005416bdfac6101090a3dc87defd71e15ec7537 +README.zh.md: 2c505ea5a1ec2fe2a930eca035b8a64ca3d4ba4f diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 3e82c10b78..0005416bdf 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -6,11 +6,11 @@ Low-level Win32 process library consumed by the Windows ACL sandbox. It owns the ## Behavior -- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by the sandbox process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW`, `STARTUPINFOEXW`, and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. +- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by the sandbox process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. -- **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, and attaches that Job through `STARTUPINFOEXW` while creating the restricted child. The child is Job-owned before any user code can run; attribute setup or creation failure closes every owned resource, and no successful process creation can leave an unowned child. -- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes the process handle; `drainPipe()` reuses one fixed native out-parameter set while draining, accepts cancellation that stops polling, and frees its allocation before closing the pipe read handle; `closeHandleChecked()` closes a caller-owned Job or other handle and reports a labelled Win32 error. The sandbox decides when these operations compose into public child settlement and disposal. +- **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. +- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes the process handle. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. The sandbox retains its existing scheduling, result composition, and caller-owned Job closure. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. @@ -36,4 +36,5 @@ The package contributes no stable request prefix, so it does not invalidate mode - **No public process service** — the package intentionally does not wrap its primitives in Cordis or Node streams. A consumer must own its policy, async scheduling, output limits, cancellation, and final handle closure. - **Inherited environment only** — process creation passes a null environment block. The sandbox establishes changes through `SetEnvironmentVariableW` first because passing an explicit block through Koffi makes `CreateProcessAsUserW` fail with `ERROR_INVALID_PARAMETER`. Other callers that need environment changes must establish them before invoking the primitive or use their own runner process. - **Restricted-token consumer only** — ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement are absent until an ordinary process consumer requires them. +- **Create-to-assignment interruption** — the target starts suspended and cannot execute before Job assignment, but an external termination of the runner in the narrow interval between process creation and assignment can leave the suspended target behind. The package does not claim atomic Job attachment. - **Header evidence is architecture-specific** — the committed ABI probe and layout constants cover the repository's current 64-bit Windows targets. A new pointer width or incompatible Windows ABI requires updating the probe before support is claimed. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index b1afc1a722..2c505ea5a1 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -6,11 +6,11 @@ ## Behavior -- **唯一可复用 ABI owner** — `abi.ts` 拥有 sandbox process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW`、`STARTUPINFOEXW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 +- **唯一可复用 ABI owner** — `abi.ts` 拥有 sandbox process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 -- **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,并在创建 restricted child 时通过 `STARTUPINFOEXW` 附加该 Job。child 会在任何用户代码运行前归属 Job;attribute 设置或创建失败都会关闭全部已拥有资源,成功创建进程后不会留下无 owner 的 child。 -- **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄;`drainPipe()` 在排空期间复用一组固定原生输出槽,接受停止轮询的取消信号,并在关闭管道读取句柄前释放原生分配;`closeHandleChecked()` 关闭调用方拥有的 Job 或其他句柄,并报告带操作标签的 Win32 错误。sandbox 决定这些操作何时组成公共 child 的结算与 dispose。 +- **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 +- **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。sandbox 保留既有调度、result 组合与调用方拥有的 Job 关闭行为。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 @@ -36,4 +36,5 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公 - **没有公共进程服务** — 本包刻意不把原语包装成 Cordis 或 Node streams。消费方必须拥有自己的策略、异步调度、输出上限、取消与最终句柄关闭。 - **只继承环境** — 进程创建传入空环境块。sandbox 会先通过 `SetEnvironmentVariableW` 建立改动,因为经 Koffi 传入显式环境块会使 `CreateProcessAsUserW` 以 `ERROR_INVALID_PARAMETER` 失败。其他需要改写环境的调用方必须在调用原语前建立环境,或使用自己的 runner 进程。 - **只有 restricted-token 消费方** — ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process 消费方出现前均不提供。 +- **创建到分配之间的中断** — 目标以 suspended 状态启动,不能在 Job 分配前执行,但 runner 若在进程创建到分配之间的极窄区间被外力终止,可能留下 suspended target。本包不声明原子 Job 附加保证。 - **header 证据限定架构** — 已提交的 ABI probe 与布局常量覆盖仓库当前 64 位 Windows 目标。支持新的指针宽度或不兼容 Windows ABI 前,必须先更新 probe。 diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index 9409e9025b..fbdda9059f 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -6,10 +6,8 @@ export const STARTF_USESTDHANDLES = 0x00000100 export const HANDLE_FLAG_INHERIT = 0x1 /** Infinite WaitForSingleObject timeout. */ export const INFINITE = 0xFFFFFFFF -/** CreateProcess flag selecting STARTUPINFOEXW and its process attributes. */ -export const EXTENDED_STARTUPINFO_PRESENT = 0x00080000 -/** Process-thread attribute that assigns the new process to a caller-supplied Job atomically. */ -export const PROC_THREAD_ATTRIBUTE_JOB_LIST = 0x0002000D +/** CreateProcess flag that prevents user code from running before resume. */ +export const CREATE_SUSPENDED = 0x4 /** GetStdHandle selector for standard input. */ export const STD_INPUT_HANDLE = -10 /** GetStdHandle selector for standard output. */ @@ -36,9 +34,5 @@ export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144 export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16 /** x64 STARTUPINFOW byte size verified by the native probe. */ export const STARTUPINFOW_SIZE = 104 -/** x64 STARTUPINFOEXW byte size verified by the native probe. */ -export const STARTUPINFOEXW_SIZE = 112 -/** x64 pointer and HANDLE byte size. */ -export const POINTER_SIZE = 8 /** x64 PROCESS_INFORMATION byte size verified by the native probe. */ export const PROCESS_INFORMATION_SIZE = 24 diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index 4abea75c5e..a2171d0023 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -81,22 +81,6 @@ export interface Win32ProcessBindings { startupInfo: NativePtr, processInfo: NativePtr, ): number - initializeProcThreadAttributeList( - attributeList: Buffer | null, - attributeCount: number, - flags: number, - size: NativePtr, - ): number - updateProcThreadAttribute( - attributeList: Buffer, - flags: number, - attribute: number, - value: NativePtr, - size: number, - previousValue: null, - returnSize: null, - ): number - deleteProcThreadAttributeList(attributeList: Buffer): void readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number peekNamedPipe( pipe: NativePtr, @@ -110,6 +94,8 @@ export interface Win32ProcessBindings { getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number createJobObjectW(attributes: null, name: null): NativePtr setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number + assignProcessToJobObject(job: NativePtr, process: NativePtr): number + resumeThread(thread: NativePtr): number terminateProcess(process: NativePtr, exitCode: number): number getStdHandle(stdHandle: number): NativePtr } @@ -255,13 +241,6 @@ function bindings(): Win32ProcessBindings { PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION), ]), - initializeProcThreadAttributeList: bind(kernel32, 'InitializeProcThreadAttributeList', 'int', [ - PVOID, 'uint32', 'uint32', koffi.pointer('size_t'), - ]), - updateProcThreadAttribute: bind(kernel32, 'UpdateProcThreadAttribute', 'int', [ - PVOID, 'uint32', 'size_t', PVOID, 'size_t', PVOID, PVOID, - ]), - deleteProcThreadAttributeList: bind(kernel32, 'DeleteProcThreadAttributeList', 'void', [PVOID]), readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]), peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [ PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32'), @@ -270,6 +249,8 @@ function bindings(): Win32ProcessBindings { getExitCodeProcess: bind(kernel32, 'GetExitCodeProcess', 'int', [PVOID, koffi.pointer('uint32')]), createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), + assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]), + resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]), terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), } as unknown as Win32ProcessBindings diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index fa7f2dd992..d6dee59d58 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -17,7 +17,6 @@ export type { Win32ProcessBindings, } from './ffi.ts' export { - closeHandleChecked, drainPipe, spawnInheritedJobProcess, spawnPipedProcess, diff --git a/packages/subprocess/win32-process/src/job-attribute.ts b/packages/subprocess/win32-process/src/job-attribute.ts deleted file mode 100644 index 7798cc6eea..0000000000 --- a/packages/subprocess/win32-process/src/job-attribute.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** Package-private STARTUPINFOEXW ownership for atomic Job attachment. */ - -import koffi from 'koffi' -import * as abi from './abi.ts' -import { STARTUPINFOW, throwWin32 } from './ffi.ts' -import type { NativePtr, StartupInfoInput, Win32ProcessBindings } from './ffi.ts' - -type Ptr = ReturnType -const PVOID: Ptr = koffi.pointer('void') - -const STARTUPINFOEXW = koffi.struct('DSH_STARTUPINFOEXW', { - StartupInfo: STARTUPINFOW, - lpAttributeList: PVOID, -}) - -/* v8 ignore start -- the native header probe pins this x64 layout. */ -if (STARTUPINFOEXW.size !== abi.STARTUPINFOEXW_SIZE) { - throw new Error(`STARTUPINFOEXW layout mismatch: koffi computed ${STARTUPINFOEXW.size}, expected ${abi.STARTUPINFOEXW_SIZE}`) -} -/* v8 ignore stop */ - -/** One extended startup record whose attribute list remains valid through CreateProcess. */ -export interface JobStartupInfo { - /** STARTUPINFOEXW pointer passed to CreateProcessAsUserW. */ - readonly pointer: NativePtr - /** Release the initialized process attribute list after CreateProcessAsUserW returns. */ - dispose(): void -} - -function queryAttributeListSize(api: Win32ProcessBindings): number { - const sizeSlot = koffi.alloc('size_t', 1) as NativePtr - try { - api.initializeProcThreadAttributeList(null, 1, 0, sizeSlot) - const attributeBytes = koffi.decode(sizeSlot, 'size_t') as number - if (attributeBytes === 0) { - throwWin32( - api, - 'InitializeProcThreadAttributeList', - api.getLastError(), - 'process-attribute size query', - ) - } - return attributeBytes - } finally { - koffi.free(sizeSlot) - } -} - -/** - * Build a STARTUPINFOEXW that assigns the restricted child to `job` during creation. - * @param api - active binding table. - * @param fields - inherited stdio fields for the nested STARTUPINFOW. - * @param job - caller-owned Job attached before any child thread exists. - * @returns extended startup pointer and its post-CreateProcess disposer. - */ -export function createJobStartupInfo( - api: Win32ProcessBindings, - fields: Omit, - job: NativePtr, -): JobStartupInfo { - const attributeList = Buffer.alloc(queryAttributeListSize(api)) - const sizeSlot = koffi.alloc('size_t', 1) as NativePtr - let initialized = false - let jobList: NativePtr | undefined - try { - koffi.encode(sizeSlot, 'size_t', attributeList.length) - if (api.initializeProcThreadAttributeList(attributeList, 1, 0, sizeSlot) === 0) { - throwWin32( - api, - 'InitializeProcThreadAttributeList', - api.getLastError(), - 'process-attribute initialization', - ) - } - initialized = true - jobList = koffi.alloc(PVOID, 1) as NativePtr - koffi.encode(jobList, PVOID, job) - if (api.updateProcThreadAttribute( - attributeList, - 0, - abi.PROC_THREAD_ATTRIBUTE_JOB_LIST, - jobList, - abi.POINTER_SIZE, - null, - null, - ) === 0) { - throwWin32( - api, - 'UpdateProcThreadAttribute', - api.getLastError(), - 'PROC_THREAD_ATTRIBUTE_JOB_LIST', - ) - } - const pointer = koffi.alloc(STARTUPINFOEXW, 1) as NativePtr - try { - koffi.encode(pointer, STARTUPINFOEXW, { - StartupInfo: { ...fields, cb: abi.STARTUPINFOEXW_SIZE }, - lpAttributeList: attributeList, - }) - } catch (error) { - /* v8 ignore start -- staging a STARTUPINFOEXW encode failure requires replacing Koffi's encoder. */ - koffi.free(pointer) - throw error - /* v8 ignore stop */ - } - return { - pointer, - dispose: () => { - try { - api.deleteProcThreadAttributeList(attributeList) - } finally { - koffi.free(jobList) - koffi.free(pointer) - } - }, - } - } catch (error) { - if (initialized) api.deleteProcThreadAttributeList(attributeList) - if (jobList !== undefined) koffi.free(jobList) - throw error - } finally { - koffi.free(sizeSlot) - } -} diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index 4b948e1849..7c676e1f7f 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -15,7 +15,6 @@ import { throwLastError, throwWin32, } from './ffi.ts' -import { createJobStartupInfo } from './job-attribute.ts' import type { NativePtr, Win32ProcessBindings } from './ffi.ts' /** @@ -78,7 +77,7 @@ export interface SpawnedPipedProcess { stderrRead: NativePtr } -/** Child atomically attached to one caller-owned kill-on-close Job during creation. */ +/** Suspended child assigned to one caller-owned kill-on-close Job before resume. */ export interface SpawnedJobProcess { /** Direct child process id. */ pid: number @@ -240,21 +239,18 @@ export function spawnPipedProcess( * Drain one anonymous pipe until the writer closes it. * @param api - active binding table. * @param handle - caller-owned pipe read end. - * @param signal - optional cancellation that stops polling and closes the read end. * @returns complete bytes read before EOF; the handle is always closed. - * @throws when the drain is cancelled or a Win32 pipe operation fails. + * @throws when a Win32 pipe operation fails. */ export async function drainPipe( api: Win32ProcessBindings, handle: NativePtr, - signal?: AbortSignal, ): Promise { const chunks: Buffer[] = [] let countSlot: NativePtr | undefined try { countSlot = allocUint32() for (;;) { - signal?.throwIfAborted() const peeked = api.peekNamedPipe(handle, null, 0, null, countSlot, null) if (peeked === 0) { const win32Code = api.getLastError() @@ -321,10 +317,10 @@ function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { } /** - * Spawn atomically attached to a kill-on-close Job. + * Spawn suspended, assign the child to a kill-on-close Job, then resume it. * @param api - active binding table. * @param options - command, cwd, args, and restricted primary token. - * @returns caller-owned process and Job handles after successful creation. + * @returns caller-owned process and Job handles after successful resume. * @remarks Node clears stdio handle inheritability at startup through * uv_disable_stdio_inheritance. This operation temporarily restores the bits * required by STARTF_USESTDHANDLES. Restoring them afterward is best-effort: @@ -346,6 +342,7 @@ export function spawnInheritedJobProcess( const stdOut = getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout') const stdErr = getStdHandle(abi.STD_ERROR_HANDLE, 'stderr') const enabled: NativePtr[] = [] + let startupInfo: NativePtr | undefined let processInfo: NativePtr | undefined let created = 0 let createFailureCode = 0 @@ -360,31 +357,30 @@ export function spawnInheritedJobProcess( } enabled.push(handle) } - const startupInfo = createJobStartupInfo(api, { + startupInfo = allocStartupInfo() + encodeStartupInfo(startupInfo, { + cb: abi.STARTUPINFOW_SIZE, dwFlags: abi.STARTF_USESTDHANDLES, hStdInput: stdIn, hStdOutput: stdOut, hStdError: stdErr, - }, job) - try { - processInfo = allocProcessInfo() - created = createRestrictedProcess( - api, - options, - buildCommandLine(options.command, options.args), - abi.EXTENDED_STARTUPINFO_PRESENT, - startupInfo.pointer, - processInfo, - ) - if (created === 0) createFailureCode = api.getLastError() - } finally { - startupInfo.dispose() - } + }) + processInfo = allocProcessInfo() + created = createRestrictedProcess( + api, + options, + buildCommandLine(options.command, options.args), + abi.CREATE_SUSPENDED, + startupInfo, + processInfo, + ) + if (created === 0) createFailureCode = api.getLastError() } catch (error) { freeNative(processInfo) api.closeHandle(job) throw error } finally { + freeNative(startupInfo) for (const handle of enabled) { // The runner spawns nothing else; cleanup failure must not mask the child. api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0) @@ -407,25 +403,27 @@ export function spawnInheritedJobProcess( freeNative(processInfo) } if (info.hProcess === null || info.hThread === null) { + if (info.hProcess !== null) api.terminateProcess(info.hProcess, 1) api.closeHandle(job) closeBestEffort(api, info.hThread) closeBestEffort(api, info.hProcess) throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) } + if (api.assignProcessToJobObject(job, info.hProcess) === 0) { + const win32Code = api.getLastError() + api.terminateProcess(info.hProcess, 1) + closeBestEffort(api, info.hThread) + closeBestEffort(api, info.hProcess) + api.closeHandle(job) + throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`) + } + if (api.resumeThread(info.hThread) === 0xFFFFFFFF) { + const win32Code = api.getLastError() + closeBestEffort(api, info.hThread) + closeBestEffort(api, info.hProcess) + api.closeHandle(job) + throwWin32(api, 'ResumeThread', win32Code, `pid ${info.dwProcessId}`) + } closeBestEffort(api, info.hThread) return { pid: info.dwProcessId, process: info.hProcess, job } } - -/** - * Close a handle and surface a failure without losing its operation label. - * @param api - active binding table. - * @param handle - caller-owned handle to close. - * @param detail - lifecycle label included in a failure. - */ -export function closeHandleChecked( - api: Win32ProcessBindings, - handle: NativePtr, - detail: string, -): void { - if (api.closeHandle(handle) === 0) throwLastError(api, 'CloseHandle', detail) -} diff --git a/packages/subprocess/win32-process/tests/job-attribute.spec.ts b/packages/subprocess/win32-process/tests/job-attribute.spec.ts deleted file mode 100644 index 793cb62bdd..0000000000 --- a/packages/subprocess/win32-process/tests/job-attribute.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import koffi from 'koffi' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createJobStartupInfo } from '../src/job-attribute.ts' -import type { NativePtr, Win32ProcessBindings } from '../src/ffi.ts' - -afterEach(() => { - vi.restoreAllMocks() -}) - -function bindings(): { - api: Win32ProcessBindings - deleteProcThreadAttributeList: ReturnType -} { - const deleteProcThreadAttributeList = vi.fn() - const api = { - initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - koffi.encode(size, 'size_t', 64) - return 0 - } - return 1 - }), - updateProcThreadAttribute: vi.fn(() => 1), - deleteProcThreadAttributeList, - getLastError: vi.fn(() => 5), - formatMessageW: vi.fn(() => 0), - } as unknown as Win32ProcessBindings - return { api, deleteProcThreadAttributeList } -} - -const fields = { - dwFlags: 0x100, - hStdInput: 1n as NativePtr, - hStdOutput: 2n as NativePtr, - hStdError: 3n as NativePtr, -} - -describe('createJobStartupInfo allocation cleanup', () => { - it('frees the size slot when attribute-list buffer allocation throws', () => { - const { api, deleteProcThreadAttributeList } = bindings() - const free = vi.spyOn(koffi, 'free') - vi.spyOn(Buffer, 'alloc').mockImplementationOnce(() => { throw new Error('buffer allocation failed') }) - expect(() => createJobStartupInfo(api, fields, 50n as NativePtr)).toThrow('buffer allocation failed') - expect(free).toHaveBeenCalledOnce() - expect(deleteProcThreadAttributeList).not.toHaveBeenCalled() - }) - - it('deletes the initialized list and frees the Job value when attachment fails', () => { - const { api, deleteProcThreadAttributeList } = bindings() - api.updateProcThreadAttribute = vi.fn(() => 0) - const free = vi.spyOn(koffi, 'free') - expect(() => createJobStartupInfo(api, fields, 50n as NativePtr)).toThrow('PROC_THREAD_ATTRIBUTE_JOB_LIST') - expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() - expect(free).toHaveBeenCalledTimes(3) - }) - - it('frees every native allocation after the caller disposes the startup record', () => { - const { api, deleteProcThreadAttributeList } = bindings() - const free = vi.spyOn(koffi, 'free') - const startup = createJobStartupInfo(api, fields, 50n as NativePtr) - startup.dispose() - expect(free).toHaveBeenCalledTimes(4) - expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() - }) -}) diff --git a/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts b/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts index f9e115e8d0..fe3c603c02 100644 --- a/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts +++ b/packages/subprocess/win32-process/tests/process-allocation-failure.spec.ts @@ -20,21 +20,11 @@ afterEach(() => { describe('spawnInheritedJobProcess allocation cleanup', () => { it('frees startup info when process-info allocation throws', () => { - const deleteProcThreadAttributeList = vi.fn() const api = { createJobObjectW: vi.fn(() => 50n), setInformationJobObject: vi.fn(() => 1), getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), setHandleInformation: vi.fn(() => 1), - initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - koffi.encode(size, 'size_t', 64) - return 0 - } - return 1 - }), - updateProcThreadAttribute: vi.fn(() => 1), - deleteProcThreadAttributeList, closeHandle: vi.fn(() => 1), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0), @@ -47,8 +37,7 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { cwd: 'C:\\', token: 70n as NativePtr, })).toThrow('process-info allocation failed') - expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() - expect(free).toHaveBeenCalledTimes(4) + expect(free).toHaveBeenCalledOnce() }) it('frees process info after a successful inherited spawn', () => { @@ -57,15 +46,6 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { setInformationJobObject: vi.fn(() => 1), getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), setHandleInformation: vi.fn(() => 1), - initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - koffi.encode(size, 'size_t', 64) - return 0 - } - return 1 - }), - updateProcThreadAttribute: vi.fn(() => 1), - deleteProcThreadAttributeList: vi.fn(), createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { koffi.encode(info, PROCESS_INFORMATION, { hProcess: 60n, @@ -75,6 +55,8 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { }) return 1 }), + assignProcessToJobObject: vi.fn(() => 1), + resumeThread: vi.fn(() => 0), closeHandle: vi.fn(() => 1), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0), @@ -86,7 +68,7 @@ describe('spawnInheritedJobProcess allocation cleanup', () => { cwd: 'C:\\', token: 70n as NativePtr, })).toEqual({ pid: 1234, process: 60n, job: 50n }) - expect(free).toHaveBeenCalledTimes(5) + expect(free).toHaveBeenCalledTimes(2) }) }) diff --git a/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts index 93a501c197..2f7f021348 100644 --- a/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts +++ b/packages/subprocess/win32-process/tests/process-failure-paths.spec.ts @@ -21,23 +21,6 @@ import { PROCESS_INFORMATION } from '../src/ffi.ts' const PVOID = koffi.pointer('void') -function jobAttributeStubs(): Pick< - Win32ProcessBindings, - 'initializeProcThreadAttributeList' | 'updateProcThreadAttribute' | 'deleteProcThreadAttributeList' -> { - return { - initializeProcThreadAttributeList: vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - koffi.encode(size, 'size_t', 64) - return 0 - } - return 1 - }), - updateProcThreadAttribute: vi.fn(() => 1), - deleteProcThreadAttributeList: vi.fn(), - } -} - /** The stub the CreateProcessAsUserW failure branch needs: pipes "succeed", the spawn fails with Win32 5. */ function pipeFailureApi(): { api: Win32ProcessBindings; closed: bigint[]; closeHandle: ReturnType } { const closed: bigint[] = [] @@ -205,7 +188,9 @@ describe('spawnInheritedJobProcess failure paths', () => { koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 }) return 1 }), - ...jobAttributeStubs(), + assignProcessToJobObject: vi.fn(() => 1), + resumeThread: vi.fn(() => 0), + terminateProcess: vi.fn(() => 1), getLastError: vi.fn(() => 5), closeHandle, formatMessageW: vi.fn(() => 0), @@ -267,34 +252,11 @@ describe('spawnInheritedJobProcess failure paths', () => { expect(closeHandle).toHaveBeenCalledWith(100n) }) - it('closes the job when the attribute-list size query returns no size', () => { + it('terminates the suspended child before closing handles when Job assignment fails', () => { + const terminateProcess = vi.fn(() => 1) const { api, closeHandle } = inheritedApi({ - initializeProcThreadAttributeList: vi.fn(() => 0), - }) - expect(() => spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })) - .toThrow(Win32Error) - expect(closeHandle).toHaveBeenCalledWith(100n) - }) - - it('closes the job when attribute-list initialization fails', () => { - const initializeProcThreadAttributeList = vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - koffi.encode(size, 'size_t', 64) - return 0 - } - return 0 - }) - const { api, closeHandle } = inheritedApi({ initializeProcThreadAttributeList }) - expect(() => spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })) - .toThrow(Win32Error) - expect(closeHandle).toHaveBeenCalledWith(100n) - }) - - it('deletes the attribute list and closes the job when atomic Job attachment fails', () => { - const deleteProcThreadAttributeList = vi.fn() - const { api, closeHandle } = inheritedApi({ - updateProcThreadAttribute: vi.fn(() => 0), - deleteProcThreadAttributeList, + assignProcessToJobObject: vi.fn(() => 0), + terminateProcess, }) let caught: unknown try { @@ -302,8 +264,24 @@ describe('spawnInheritedJobProcess failure paths', () => { } catch (error) { caught = error } - expect(caught).toMatchObject({ api: 'UpdateProcThreadAttribute', win32Code: 5 }) - expect(deleteProcThreadAttributeList).toHaveBeenCalledOnce() + expect(caught).toMatchObject({ api: 'AssignProcessToJobObject', win32Code: 5 }) + expect(terminateProcess).toHaveBeenCalledWith(200n, 1) + expect(closeHandle).toHaveBeenCalledWith(201n) + expect(closeHandle).toHaveBeenCalledWith(200n) + expect(closeHandle).toHaveBeenCalledWith(100n) + }) + + it('closes the assigned child and Job when ResumeThread fails', () => { + const { api, closeHandle } = inheritedApi({ resumeThread: vi.fn(() => 0xFFFFFFFF) }) + let caught: unknown + try { + spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }) + } catch (error) { + caught = error + } + expect(caught).toMatchObject({ api: 'ResumeThread', win32Code: 5 }) + expect(closeHandle).toHaveBeenCalledWith(201n) + expect(closeHandle).toHaveBeenCalledWith(200n) expect(closeHandle).toHaveBeenCalledWith(100n) }) @@ -342,6 +320,8 @@ describe('spawnInheritedJobProcess failure paths', () => { expect(closeHandle).toHaveBeenCalledWith(201n) expect(closeHandle).not.toHaveBeenCalledWith(200n) expect(closeHandle).not.toHaveBeenCalledWith(100n) + expect(api.assignProcessToJobObject).toHaveBeenCalledWith(100n, 200n) + expect(api.resumeThread).toHaveBeenCalledWith(201n) }) }) diff --git a/packages/subprocess/win32-process/tests/process.spec.ts b/packages/subprocess/win32-process/tests/process.spec.ts index e2f151c8e1..3fbaa570fe 100644 --- a/packages/subprocess/win32-process/tests/process.spec.ts +++ b/packages/subprocess/win32-process/tests/process.spec.ts @@ -2,16 +2,11 @@ import koffi from 'koffi' import { describe, expect, it, vi } from 'vitest' import { Win32Error, - closeHandleChecked, drainPipe, spawnInheritedJobProcess, spawnPipedProcess, } from '../src/index.ts' -import { - EXTENDED_STARTUPINFO_PRESENT, - POINTER_SIZE, - PROC_THREAD_ATTRIBUTE_JOB_LIST, -} from '../src/abi.ts' +import { CREATE_SUSPENDED } from '../src/abi.ts' import { PROCESS_INFORMATION } from '../src/ffi.ts' import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' @@ -21,12 +16,10 @@ function inheritedApi(overrides: Partial = {}): { api: Win32ProcessBindings events: string[] createProcessAsUserW: ReturnType - initializeProcThreadAttributeList: ReturnType - updateProcThreadAttribute: ReturnType - attachedJob: () => NativePtr | null + assignProcessToJobObject: ReturnType + resumeThread: ReturnType } { const events: string[] = [] - let attachedJob: NativePtr | null = null const createProcessAsUserWImpl: Win32ProcessBindings['createProcessAsUserW'] = overrides.createProcessAsUserW ?? ((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { @@ -40,20 +33,12 @@ function inheritedApi(overrides: Partial = {}): { return 1 }) const createProcessAsUserW = vi.fn(createProcessAsUserWImpl) - const initializeProcThreadAttributeList = vi.fn((list: Buffer | null, _count: number, _flags: number, size: NativePtr) => { - if (list === null) { - events.push('attribute-size') - koffi.encode(size, 'size_t', 64) - return 0 - } - events.push('attribute-init') + const assignProcessToJobObject = vi.fn(() => { + events.push('assign') return 1 }) - const updateProcThreadAttribute = vi.fn((_list, _flags, attribute: number, value: NativePtr) => { - if (attribute === PROC_THREAD_ATTRIBUTE_JOB_LIST) { - attachedJob = koffi.decode(value, PVOID) as NativePtr - events.push('attach-job') - } + const resumeThread = vi.fn(() => { + events.push('resume') return 1 }) const api = { @@ -64,9 +49,8 @@ function inheritedApi(overrides: Partial = {}): { events.push(flags === 0 ? 'restore' : 'inherit') return 1 }), - initializeProcThreadAttributeList, - updateProcThreadAttribute, - deleteProcThreadAttributeList: vi.fn(() => { events.push('attribute-delete') }), + assignProcessToJobObject, + resumeThread, terminateProcess: vi.fn(() => 1), closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }), getLastError: vi.fn(() => 5), @@ -78,23 +62,21 @@ function inheritedApi(overrides: Partial = {}): { api, events, createProcessAsUserW, - initializeProcThreadAttributeList, - updateProcThreadAttribute, - attachedJob: () => attachedJob, + assignProcessToJobObject, + resumeThread, } } describe('spawnInheritedJobProcess', () => { const token = 70n as NativePtr - it('attaches a restricted child to the Job inside CreateProcessAsUserW', () => { + it('creates suspended, assigns the Job, then resumes the restricted child', () => { const { api, events, createProcessAsUserW, - initializeProcThreadAttributeList, - updateProcThreadAttribute, - attachedJob, + assignProcessToJobObject, + resumeThread, } = inheritedApi() const child = spawnInheritedJobProcess(api, { command: 'cmd.exe', @@ -103,20 +85,10 @@ describe('spawnInheritedJobProcess', () => { token, }) expect(child).toEqual({ pid: 1234, process: 60n, job: 50n }) - expect(events.indexOf('attach-job')).toBeLessThan(events.indexOf('create')) - expect(events.indexOf('attribute-delete')).toBeGreaterThan(events.indexOf('create')) - expect(initializeProcThreadAttributeList).toHaveBeenNthCalledWith(1, null, 1, 0, expect.anything()) - expect(initializeProcThreadAttributeList).toHaveBeenNthCalledWith(2, expect.any(Buffer), 1, 0, expect.anything()) - expect(updateProcThreadAttribute).toHaveBeenCalledWith( - expect.any(Buffer), - 0, - PROC_THREAD_ATTRIBUTE_JOB_LIST, - expect.anything(), - POINTER_SIZE, - null, - null, - ) - expect(attachedJob()).toBe(50n) + expect(events.indexOf('create')).toBeLessThan(events.indexOf('assign')) + expect(events.indexOf('assign')).toBeLessThan(events.indexOf('resume')) + expect(assignProcessToJobObject).toHaveBeenCalledWith(50n, 60n) + expect(resumeThread).toHaveBeenCalledWith(61n) expect(createProcessAsUserW).toHaveBeenCalledWith( token, null, @@ -124,7 +96,7 @@ describe('spawnInheritedJobProcess', () => { null, null, 1, - EXTENDED_STARTUPINFO_PRESENT, + CREATE_SUSPENDED, null, 'C:\\work', expect.anything(), @@ -187,10 +159,12 @@ describe('spawnInheritedJobProcess', () => { expect(caught).toMatchObject({ api: 'CreateProcessAsUserW', win32Code: 87 }) }) - it('closes the atomic Job when CreateProcessAsUserW returns a null thread handle', () => { + it('terminates the suspended process and closes the Job when CreateProcessAsUserW returns a null thread handle', () => { const closeHandle = vi.fn(() => 1) + const terminateProcess = vi.fn(() => 1) const { api } = inheritedApi({ closeHandle, + terminateProcess, createProcessAsUserW: vi.fn((_token, _app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { koffi.encode(info, PROCESS_INFORMATION, { hProcess: 60n, @@ -207,9 +181,43 @@ describe('spawnInheritedJobProcess', () => { cwd: 'C:\\work', token, })).toThrow('null process/thread handles') + expect(terminateProcess).toHaveBeenCalledWith(60n, 1) expect(closeHandle).toHaveBeenCalledWith(50n) expect(closeHandle).toHaveBeenCalledWith(60n) }) + + it('terminates the suspended child before closing handles when Job assignment fails', () => { + const closeHandle = vi.fn(() => 1) + const terminateProcess = vi.fn(() => 1) + const { api } = inheritedApi({ + assignProcessToJobObject: vi.fn(() => 0), + terminateProcess, + closeHandle, + }) + expect(() => spawnInheritedJobProcess(api, { + command: 'cmd.exe', + args: [], + cwd: 'C:\\work', + token, + })).toThrow(Win32Error) + expect(terminateProcess).toHaveBeenCalledWith(60n, 1) + expect(closeHandle.mock.calls.map(([handle]) => handle)).toEqual([61n, 60n, 50n]) + }) + + it('closes the assigned Job and process when ResumeThread fails', () => { + const closeHandle = vi.fn(() => 1) + const { api } = inheritedApi({ + resumeThread: vi.fn(() => 0xFFFFFFFF), + closeHandle, + }) + expect(() => spawnInheritedJobProcess(api, { + command: 'cmd.exe', + args: [], + cwd: 'C:\\work', + token, + })).toThrow(Win32Error) + expect(closeHandle.mock.calls.map(([handle]) => handle)).toEqual([61n, 60n, 50n]) + }) }) describe('wait and pipe cleanup', () => { @@ -234,39 +242,6 @@ describe('wait and pipe cleanup', () => { expect(closeHandle).toHaveBeenCalledWith(80n) }) - it('stops polling and closes the read end when cancelled', async () => { - const controller = new AbortController() - const closeHandle = vi.fn(() => 1) - const peekNamedPipe = vi.fn((_handle, _buffer, _size, _read, available) => { - koffi.encode(available, 'uint32', 0) - return 1 - }) - const api = { - peekNamedPipe, - closeHandle, - } as unknown as Win32ProcessBindings - const draining = drainPipe(api, 80n as NativePtr, controller.signal) - const cancellation = new Error('stop pipe drain') - controller.abort(cancellation) - await expect(draining).rejects.toBe(cancellation) - expect(peekNamedPipe).toHaveBeenCalledOnce() - expect(closeHandle).toHaveBeenCalledWith(80n) - }) - - it('checks caller-owned handle closure', () => { - const closeHandle = vi.fn(() => 1) - const api = { closeHandle } as unknown as Win32ProcessBindings - expect(() => { closeHandleChecked(api, 80n as NativePtr, 'sandbox Job') }).not.toThrow() - expect(closeHandle).toHaveBeenCalledWith(80n) - - const failing = { - closeHandle: vi.fn(() => 0), - getLastError: vi.fn(() => 6), - formatMessageW: vi.fn(() => 0), - } as unknown as Win32ProcessBindings - expect(() => { closeHandleChecked(failing, 81n as NativePtr, 'sandbox Job') }).toThrow(Win32Error) - }) - it('terminates a piped child when CreateProcess returns a null thread handle', () => { let nextPipe = 10n const terminateProcess = vi.fn(() => 1) diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index 50452bd514..1c9480105d 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -13,14 +13,11 @@ int wmain() P(offsetof(STARTUPINFOW, hStdInput)); P(offsetof(STARTUPINFOW, hStdOutput)); P(offsetof(STARTUPINFOW, hStdError)); - P(sizeof(STARTUPINFOEXW)); - P(offsetof(STARTUPINFOEXW, lpAttributeList)); P(sizeof(PROCESS_INFORMATION)); P(offsetof(PROCESS_INFORMATION, hProcess)); P(offsetof(PROCESS_INFORMATION, hThread)); P(offsetof(PROCESS_INFORMATION, dwProcessId)); - P(EXTENDED_STARTUPINFO_PRESENT); - P(PROC_THREAD_ATTRIBUTE_JOB_LIST); + P(CREATE_SUSPENDED); P(STARTF_USESTDHANDLES); P(HANDLE_FLAG_INHERIT); P(INFINITE); @@ -38,11 +35,8 @@ int wmain() P(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE); static_assert(sizeof(STARTUPINFOW) == 104, "STARTUPINFOW size"); - static_assert(sizeof(STARTUPINFOEXW) == 112, "STARTUPINFOEXW size"); - static_assert(offsetof(STARTUPINFOEXW, lpAttributeList) == 104, "STARTUPINFOEXW attribute offset"); static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size"); - static_assert(EXTENDED_STARTUPINFO_PRESENT == 0x00080000, "extended startup flag"); - static_assert(PROC_THREAD_ATTRIBUTE_JOB_LIST == 0x0002000D, "Job-list attribute"); + static_assert(CREATE_SUSPENDED == 0x4, "suspended process flag"); static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag"); static_assert(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144, "job extended limit size"); diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index e4ab756424..cc43e06f55 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -49,8 +49,8 @@ describe('CI workflow', () => { const node24Coverage = workflow.jobs['node-24-coverage'] const node24Consumers = workflow.jobs['node-24-consumers'] const aggregate = workflow.jobs['all-checks-passed'] - if (!Array.isArray(windows.steps) || !Array.isArray(serialWindows.steps) || !Array.isArray(aggregate.needs)) { - throw new TypeError('Windows jobs must define steps and the aggregate must define needs') + if (!Array.isArray(windows.steps) || !Array.isArray(aggregate.needs)) { + throw new TypeError('Windows job must define steps and the aggregate must define needs') } const commandSteps = windows.steps.filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' @@ -78,7 +78,6 @@ describe('CI workflow', () => { const nativeCommandSteps = (windowsNative.steps as unknown[]).filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' )) - expect(nativeCommandSteps.map(step => step.run)).toContain('./scripts/verify-win32-abi.ps1') expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') // wine-apt-cache: master-only, seeds the Wine apt cache. @@ -89,16 +88,6 @@ describe('CI workflow', () => { expect(serialWindows.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'") expect(serialWindows['runs-on']).toEqual(['self-hosted', 'dsh-win-ci', 'windows']) expect(serialWindows.name).toBe('serial / windows (self-hosted standby)') - const serialWindowsCommandSteps = serialWindows.steps.filter((step): step is Record & { run: string } => ( - isRecord(step) && typeof step.run === 'string' - )) - expect(serialWindowsCommandSteps.map(step => step.run)).toContain('./scripts/verify-win32-abi.ps1') - expect(serialWindowsCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') - const abiProbeScript = readFileSync(resolve(root, 'scripts/verify-win32-abi.ps1'), 'utf8') - expect(abiProbeScript).toContain('vswhere.exe') - expect(abiProbeScript).toContain('vcvars64.bat') - expect(abiProbeScript).toContain('packages/subprocess/win32-process/verify/abi-probe.cpp') - expect(abiProbeScript).toContain('packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp') // Aggregate: Wine `windows` required, native `windows-native` excluded. expect(aggregate.needs).toContain('windows') diff --git a/scripts/verify-win32-abi.ps1 b/scripts/verify-win32-abi.ps1 deleted file mode 100644 index c0125c9eab..0000000000 --- a/scripts/verify-win32-abi.ps1 +++ /dev/null @@ -1,25 +0,0 @@ -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -$temporaryRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [IO.Path]::GetTempPath() } -$probeRoot = Join-Path $temporaryRoot 'dsh-win32-abi-probes' -New-Item -ItemType Directory -Force -Path $probeRoot | Out-Null - -$vswhere = Join-Path ([Environment]::GetFolderPath('ProgramFilesX86')) 'Microsoft Visual Studio\Installer\vswhere.exe' -if (-not (Test-Path $vswhere)) { throw "Visual Studio locator not found: $vswhere" } -$vsInstall = (& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath).Trim() -if (-not $vsInstall) { throw 'Visual Studio C++ build tools not found' } -$vcvars = Join-Path $vsInstall 'VC\Auxiliary\Build\vcvars64.bat' -if (-not (Test-Path $vcvars)) { throw "MSVC environment script not found: $vcvars" } - -$processProbe = Join-Path $probeRoot 'win32-process.exe' -$processObject = Join-Path $probeRoot 'win32-process.obj' -$processSource = Join-Path $repoRoot 'packages/subprocess/win32-process/verify/abi-probe.cpp' -$sandboxProbe = Join-Path $probeRoot 'sandbox-windows-acl.exe' -$sandboxObject = Join-Path $probeRoot 'sandbox-windows-acl.obj' -$sandboxSource = Join-Path $repoRoot 'packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp' - -$probeCommand = "call `"$vcvars`" && cl /nologo /std:c++20 /EHsc /W4 /Fo:`"$processObject`" /Fe:`"$processProbe`" `"$processSource`" && `"$processProbe`" && cl /nologo /std:c++20 /EHsc /W4 /Fo:`"$sandboxObject`" /Fe:`"$sandboxProbe`" `"$sandboxSource`" advapi32.lib && `"$sandboxProbe`"" -& cmd.exe /d /s /c $probeCommand -if ($LASTEXITCODE -ne 0) { throw 'Win32 ABI probe compilation or execution failed' }