Explorar el Código

fix(sandbox): cancel sibling drain on termination failure

pku-xht hace 1 mes
padre
commit
60587b4901

+ 2 - 2
.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: 60e9b5b76154a4833979b014dffb4015cb0c5c36
-2026-08-19-shared-win32-process-primitives.zh.md: 83bb71ddbe182084097c54325172a3f923b33138
+2026-08-19-shared-win32-process-primitives.md: ae7720273333f675b9fb4178405bedbc32982a59
+2026-08-19-shared-win32-process-primitives.zh.md: 2d8b9d3421fa4eb4100f4f016018301e21de1eef

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md

@@ -14,7 +14,7 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p
 
 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 terminates the child before its synchronous wait, or closes the process handle and reports the termination failure without blocking. 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. 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 terminates the child before its synchronous wait. When termination itself fails, settlement cancels and joins the sibling drain before closing the process handle and reporting the failure, so rejection leaves no polling timer alive. 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.
 
 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.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md

@@ -14,7 +14,7 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p
 
 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 会在同步 wait 前终止 child,若终止本身失败则关闭 process handle 并报告该失败,不阻塞事件循环。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。
+每项 native allocation 与 HANDLE 都只有一个 owner。process operation 会释放 Koffi out-parameter,并在失败前关闭已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox;任一 drain 失败时,sandbox settlement 会在同步 wait 前终止 child。若终止本身失败,settlement 会先取消并等待 sibling drain 结束,再关闭 process handle 并报告失败,因此 rejection 不会留下持续轮询的 timer。inherited-stdio 创建会把 kill-on-close Job 放进 `STARTUPINFOEXW`,因此 child 在任何用户代码运行前已经归属 Job;attribute 或创建失败都有唯一且确定的 cleanup owner。sandbox 在 wait 或 disposal 前拥有返回的 process、pipe 与 Job handles。
 
 该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。
 

+ 2 - 0
packages/sandbox/sandbox-windows-acl/src/ffi.ts

@@ -139,6 +139,8 @@ export function allocBytes(length: number): NativePtr {
 /**
  * Allocate one zeroed x64 OVERLAPPED record.
  * @returns allocated pointer.
+ * @remarks Koffi 3.1.1 crashes when LockFileEx or UnlockFileEx receives NULL;
+ * a zeroed OVERLAPPED is equivalent for the synchronous lock-file handle.
  */
 export function allocOverlapped(): NativePtr {
   return allocBytes(32)

+ 5 - 3
packages/sandbox/sandbox-windows-acl/src/index.ts

@@ -380,8 +380,9 @@ export class AclSandbox {
     }
 
     const native = spawnSandboxed(api, token, { command: options.command, args, cwd })
-    const stdout = drainPipe(api, native.stdoutRead)
-    const stderr = drainPipe(api, native.stderrRead)
+    const drainAbort = new AbortController()
+    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 terminates the child
@@ -402,13 +403,14 @@ export class AclSandbox {
           if (api.terminateProcess(native.process, 1) === 0) {
             const failures: unknown[] = [firstDrainFailure]
             const terminationCode = api.getLastError()
+            drainAbort.abort()
+            await Promise.allSettled([stdout, stderr])
             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`))
-            void Promise.allSettled([stdout, stderr])
             throw new AggregateError(failures, 'piped child settlement failed')
           }
           drains = await Promise.allSettled([stdout, stderr])

+ 3 - 3
packages/sandbox/sandbox-windows-acl/src/token.ts

@@ -180,9 +180,9 @@ export interface RestrictingSidSet {
  * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — documented in
  * README. INTERACTIVE/LOCAL are absent from BOTH lists too — the host's
  * Public tree grants write to INTERACTIVE, so removing it closes that
-   * escape. S-1-2-1 (console logon) is intentionally absent: the package
-   * README's "Console isolation is unavailable" entry records the verified
-   * failure modes. FAILS CLOSED: any failure throws — never
+ * escape. S-1-2-1 (console logon) is intentionally absent: the package
+ * README's "Console isolation is unavailable" entry records the verified
+ * failure modes. FAILS CLOSED: any failure throws — never
  * spawn unrestricted.
  * @param api - the binding table.
  * @param currentToken - the process token to restrict.

+ 10 - 0
packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts

@@ -500,6 +500,13 @@ describe('AclSandbox spawn', () => {
 
   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') })
@@ -509,6 +516,9 @@ describe('AclSandbox spawn', () => {
     await sandbox.init()
     const child = sandbox.spawn({ command: 'probe.exe' })
     await expect(child.wait()).rejects.toBeInstanceOf(AggregateError)
+    const settledPeekCount = peekCount
+    await new Promise<void>(resolve => setTimeout(resolve, 5))
+    expect(peekCount).toBe(settledPeekCount)
     expect(waitForSingleObject).not.toHaveBeenCalled()
     expect(closeHandle).toHaveBeenCalled()
   })

+ 2 - 2
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: c3bc0d74c3c5a375d289e9a3e4037648341f4906
-README.zh.md: 6763f2b24633d7dd250eecdafe5e1724ffa29fa6
+README.md: 3e82c10b7894b15d970b794429c69c6923632bb9
+README.zh.md: b1afc1a7222189329cbd84d9729fbafc3ecd3acb

+ 1 - 1
packages/subprocess/win32-process/README.md

@@ -10,7 +10,7 @@ Low-level Win32 process library consumed by the Windows ACL sandbox. It owns the
 - **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 and frees it 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.
+- **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.
 
 The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives.
 

+ 1 - 1
packages/subprocess/win32-process/README.zh.md

@@ -10,7 +10,7 @@
 - **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。
+- **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄;`drainPipe()` 在排空期间复用一组固定原生输出槽,接受停止轮询的取消信号,并在关闭管道读取句柄前释放原生分配;`closeHandleChecked()` 关闭调用方拥有的 Job 或其他句柄,并报告带操作标签的 Win32 错误。sandbox 决定这些操作何时组成公共 child 的结算与 dispose。
 
 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。
 

+ 1 - 1
packages/subprocess/win32-process/src/errors.ts

@@ -2,7 +2,7 @@
 export class Win32Error extends Error {
   /** Win32 function whose checked result failed. */
   readonly api: string
-  /** Exact GetLastError value captured before cleanup changed it. */
+  /** Exact GetLastError value or direct Win32 API error code. */
   readonly win32Code: number
 
   constructor(api: string, win32Code: number, detail?: string) {

+ 8 - 1
packages/subprocess/win32-process/src/process.ts

@@ -240,14 +240,21 @@ 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 cancellation or a Win32 pipe operation fails.
  */
-export async function drainPipe(api: Win32ProcessBindings, handle: NativePtr): Promise<Buffer> {
+export async function drainPipe(
+  api: Win32ProcessBindings,
+  handle: NativePtr,
+  signal?: AbortSignal,
+): Promise<Buffer> {
   const chunks: Buffer[] = []
   let countSlot: NativePtr | undefined
   try {
     countSlot = allocUint32()
     for (;;) {
+      if (signal?.aborted === true) throw new Error('pipe drain aborted')
       const peeked = api.peekNamedPipe(handle, null, 0, null, countSlot, null)
       if (peeked === 0) {
         const win32Code = api.getLastError()

+ 18 - 0
packages/subprocess/win32-process/tests/process.spec.ts

@@ -234,6 +234,24 @@ 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)
+    controller.abort()
+    await expect(draining).rejects.toThrow('pipe drain aborted')
+    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

+ 3 - 0
packages/subprocess/win32-process/tsconfig.json

@@ -6,6 +6,9 @@
   },
   "include": ["src"],
   "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
     {
       "path": "../../runtime-diagnostics/invariants"
     }