Browse Source

fix(subprocess): harden native range settlement

pku-xht 1 month ago
parent
commit
eb60d74155
29 changed files with 333 additions and 67 deletions
  1. 2 2
      .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml
  2. 2 2
      .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md
  3. 2 2
      .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md
  4. 2 2
      docs/subsystems/subprocess.i18n.yaml
  5. 3 2
      docs/subsystems/subprocess.md
  6. 3 2
      docs/subsystems/subprocess.zh.md
  7. 2 2
      packages/subprocess/subprocess-local/README.i18n.yaml
  8. 2 1
      packages/subprocess/subprocess-local/README.md
  9. 2 1
      packages/subprocess/subprocess-local/README.zh.md
  10. 67 9
      packages/subprocess/subprocess-local/src/linux-scope.ts
  11. 1 1
      packages/subprocess/subprocess-local/src/managed-owner.ts
  12. 30 5
      packages/subprocess/subprocess-local/src/runner-launch.ts
  13. 25 3
      packages/subprocess/subprocess-local/src/runner-protocol.ts
  14. 7 1
      packages/subprocess/subprocess-local/src/spawn.ts
  15. 45 6
      packages/subprocess/subprocess-local/tests/linux-scope.spec.ts
  16. 48 0
      packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts
  17. 12 2
      packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts
  18. 2 2
      packages/subprocess/subprocess/README.i18n.yaml
  19. 2 2
      packages/subprocess/subprocess/README.md
  20. 2 2
      packages/subprocess/subprocess/README.zh.md
  21. 2 1
      packages/subprocess/subprocess/src/types.ts
  22. 2 2
      packages/subprocess/win32-process/README.i18n.yaml
  23. 3 3
      packages/subprocess/win32-process/README.md
  24. 3 3
      packages/subprocess/win32-process/README.zh.md
  25. 6 0
      packages/subprocess/win32-process/src/abi.ts
  26. 10 0
      packages/subprocess/win32-process/src/ffi.ts
  27. 12 5
      packages/subprocess/win32-process/src/process.ts
  28. 28 4
      packages/subprocess/win32-process/tests/ordinary-process.spec.ts
  29. 6 0
      packages/subprocess/win32-process/verify/abi-probe.cpp

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md
-2026-08-20-subprocess-native-containment.md: bbe36a39580316e7bee34e6e22321d6eb46c9580
-2026-08-20-subprocess-native-containment.zh.md: 9b882f6369d3c7ebc1a2595fc23fbd37cc9eec6c
+2026-08-20-subprocess-native-containment.md: 3ccf78e158458de786ee424badcd5fdf4ff88676
+2026-08-20-subprocess-native-containment.zh.md: 2d223575ad2663ff581b4bc8c04c9c5533263a3f

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md

@@ -14,7 +14,7 @@ The local subprocess provider treated a POSIX process group or a Windows direct-
 
 The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, TERM-to-KILL escalation, and host-exit registration. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux or Windows runner report Node-shaped target spawn failures and the target exit independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals.
 
-Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; scope KILL is itself authoritative when no runner result can survive. Windows target descendants inherit the Job by default, while the runner remains until both the direct result is reported and the Job is empty. Parent IPC disconnect terminates the Job during JavaScript-observable host exit.
+Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; scope KILL is itself authoritative when no runner result can survive. Windows target descendants inherit the Job by default, while the runner remains until both the direct result is reported and `QueryInformationJobObject` reports zero active Job members. Parent IPC disconnect terminates the Job during JavaScript-observable host exit.
 
 When native capability is unavailable before target execution, the provider warns once and uses the existing PGID or `taskkill /T` fallback. macOS always takes that path because it has no supported public persistent process owner. After native launch is selected, any runner, manager, or result-transport failure is reported; the user command is never replayed through fallback.
 
@@ -34,4 +34,4 @@ Linux native evidence covers a real `setsid` descendant and a double-fork daemon
 
 ## Consequences
 
-Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee.
+Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The synchronous public spawn contract then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports, and each native range retains one runner process until settlement. After publication, event-file reads use asynchronous 100 ms polling and systemd state reads use asynchronous 200 ms polling rather than blocking the host event loop. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee.

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、TERM-to-KILL 升级与 host-exit 注册。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux 或 Windows runner 分别报告 Node-shaped target spawn failure 与 target exit,不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。
 
-Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;scope KILL 无法保留 runner result 时,该 KILL 事实本身就是权威结果。Windows target descendant 默认继承 Job;runner 会一直存活到 direct result 已报告且 Job 为空。parent IPC 断开会在 JavaScript-observable host exit 期间终止 Job。
+Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;scope KILL 无法保留 runner result 时,该 KILL 事实本身就是权威结果。Windows target descendant 默认继承 Job;runner 会一直存活到 direct result 已报告且 `QueryInformationJobObject` 报告 Job active member 归零。parent IPC 断开会在 JavaScript-observable host exit 期间终止 Job。
 
 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。
 
@@ -34,4 +34,4 @@ Linux native 证据覆盖真实 `setsid` descendant,以及 direct parent 先
 
 ## Consequences
 
-受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。
+受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。同步公共 spawn 合同随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒,每个 native range 还会保留一个 runner process 直到 settlement。handle 发布后,event file 使用异步 100 ms 轮询,systemd state 使用异步 200 ms 轮询,不再阻塞宿主事件循环。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。

+ 2 - 2
docs/subsystems/subprocess.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 docs/subsystems/subprocess.md
-subprocess.md: 918c44c723d880a813837fcde47289cca8746fb4
-subprocess.zh.md: e94b6b0e96e22aa67419a7b08acb9dfe20524eb9
+subprocess.md: a2b0a5575b28d3a0ba859fc56d0a2ae7be008175
+subprocess.zh.md: b923cad98bb3dd999c6f4ca8fa5c7e160cebe774

+ 3 - 2
docs/subsystems/subprocess.md

@@ -131,7 +131,7 @@ interface SubprocessSpawnSpec {
 
 ## Handles: streams, readers, and managed-range termination
 
-A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. `terminate()` and `waitForExit()` use one managed range: supported local Linux and Windows providers use an OS-owned scope or Job, while weaker fallbacks are disclosed. The only termination verb escalates SIGTERM→grace→SIGKILL, so a consumer can build its own teardown ladder (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template).
+A spawn returns a live handle synchronously after any provider-specific setup needed to publish its target pid. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. `terminate()` and `waitForExit()` use one managed range: supported local Linux and Windows providers use an OS-owned scope or Job, while weaker fallbacks are disclosed. The only termination verb escalates SIGTERM→grace→SIGKILL, so a consumer can build its own teardown ladder (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template).
 
 ```ts type-equiv
 /**
@@ -153,7 +153,7 @@ interface SubprocessHandle {
   readonly stderr: Readable | undefined
   /** Offset-based readers for collect-mode streams (also readable after exit). */
   readonly collected: SubprocessCollectedOutputs
-  /** Resolves at process close with exit facts; rejects only for spawn-level failures. */
+  /** Resolves with direct-process exit facts; rejects for spawn or selected native-runner failures. */
   readonly done: Promise<SubprocessOutcome>
   /**
    * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree
@@ -167,6 +167,7 @@ interface SubprocessHandle {
    * child, so a still-running helper is observable before teardown returns.
    * @param signal - optional bound for the wait.
    * @returns `true` when the tree exited, `false` when the signal aborted first.
+   * @throws when the selected provider can no longer observe its managed range.
    */
   waitForExit(signal?: AbortSignal): Promise<boolean>
 }

+ 3 - 2
docs/subsystems/subprocess.zh.md

@@ -131,7 +131,7 @@ interface SubprocessSpawnSpec {
 
 ## 句柄:流、读取器与 managed-range 终止
 
-spawn 会立即返回一个活动句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立读取器不会抢走彼此的增量;管道化的流归调用方所有。`terminate()` 与 `waitForExit()` 使用同一个 managed range:受支持的本地 Linux 与 Windows provider 使用 OS-owned scope 或 Job,并明确披露较弱 fallback。唯一的终止动词执行 SIGTERM→宽限期→SIGKILL 升级,因此消费方可以构建自己的分级清理流程;ACP 后端先关闭 stdin 的 `disposeAcpChild` 是参考实现。
+spawn 会在完成发布 target pid 所需的 provider-specific setup 后同步返回活动句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立读取器不会抢走彼此的增量;管道化的流归调用方所有。`terminate()` 与 `waitForExit()` 使用同一个 managed range:受支持的本地 Linux 与 Windows provider 使用 OS-owned scope 或 Job,并明确披露较弱 fallback。唯一的终止动词执行 SIGTERM→宽限期→SIGKILL 升级,因此消费方可以构建自己的分级清理流程;ACP 后端先关闭 stdin 的 `disposeAcpChild` 是参考实现。
 
 ```ts type-equiv
 /**
@@ -153,7 +153,7 @@ interface SubprocessHandle {
   readonly stderr: Readable | undefined
   /** Offset-based readers for collect-mode streams (also readable after exit). */
   readonly collected: SubprocessCollectedOutputs
-  /** Resolves at process close with exit facts; rejects only for spawn-level failures. */
+  /** Resolves with direct-process exit facts; rejects for spawn or selected native-runner failures. */
   readonly done: Promise<SubprocessOutcome>
   /**
    * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree
@@ -167,6 +167,7 @@ interface SubprocessHandle {
    * child, so a still-running helper is observable before teardown returns.
    * @param signal - optional bound for the wait.
    * @returns `true` when the tree exited, `false` when the signal aborted first.
+   * @throws when the selected provider can no longer observe its managed range.
    */
   waitForExit(signal?: AbortSignal): Promise<boolean>
 }

+ 2 - 2
packages/subprocess/subprocess-local/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md
-README.md: d7986dbb3ef436cc572090fe89b8a2ee62916f0d
-README.zh.md: bd710d5427fa62ecb1af37a20762079aea2727ca
+README.md: 8cb0a4a6eae741c8073462ca4f861149e2a82597
+README.zh.md: e9726758761e969aa70feb595753ca37863783fc

+ 2 - 1
packages/subprocess/subprocess-local/README.md

@@ -6,7 +6,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA
 
 ## Behavior
 
-- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state; Windows starts the target suspended in a kill-on-close Job before resuming it. `terminate()` sends TERM then KILL through that owner, while `waitForExit()` succeeds only after the same scope or Job is empty. `.done` remains the direct command result: a private runner reports target start failure and exit separately from range lifetime, and still-open collected pipes retain the existing bounded drain grace.
+- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state; Windows starts the target suspended in a kill-on-close Job before resuming it. `terminate()` sends TERM then KILL through that owner, while `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure and exit separately from range lifetime, only collected pipes retain the existing bounded drain grace, and raw/inherited stdio does not delay direct settlement.
 - **Explicit weaker fallback** — macOS, old or unavailable user-systemd, and unavailable Windows native support keep the existing detached PGID or `taskkill /T` path. The provider warns once before the first affected command. It never retries through fallback after a native runner may have started the user command.
 - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory.
 - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
@@ -27,6 +27,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
 ## Known Limitations and Deferred Work
 
 - **Native ownership has explicit host requirements** — Linux needs a readable user manager and `systemd-run --expand-environment=no`; older systemd versions use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists.
+- **Native launch has a synchronous setup cost** — the first ordinary spawn probes host capability once for that provider instance, with a 5-second bound on each probe command. The public `spawn()` contract returns a numeric target pid, so each native launch then waits synchronously for its per-spawn runner to report target start or spawn failure. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. Each supported native command also keeps one runner process alive until the OS-owned range is empty. After publication, runner events are polled asynchronously every 100 ms and Linux scope state every 200 ms.
 - **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind.
 - **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest.
 - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor.

+ 2 - 1
packages/subprocess/subprocess-local/README.zh.md

@@ -6,7 +6,7 @@
 
 ## 行为
 
-- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope;Windows 以 suspended 状态创建目标,把它加入 kill-on-close Job 后才恢复。`terminate()` 通过该 owner 发送 TERM 再发送 KILL,`waitForExit()` 只在同一 scope 或 Job 为空后成功。`.done` 仍是 direct command result:private runner 分别报告目标启动失败与退出,不把 range 生命周期冒充目标结果;仍打开的 collected pipe 保留既有有界排空宽限期。
+- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope;Windows 以 suspended 状态创建目标,把它加入 kill-on-close Job 后才恢复。`terminate()` 通过该 owner 发送 TERM 再发送 KILL;`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 分别报告目标启动失败与退出,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期,raw/inherited stdio 不会延迟 direct settlement。
 - **明确披露较弱 fallback**:macOS、旧版或不可用的 user-systemd,以及不可用的 Windows native 支持继续使用既有 detached PGID 或 `taskkill /T` 路径。provider 会在首个受影响命令前只告警一次。native runner 可能已经启动用户命令后绝不通过 fallback 重试。
 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。
 - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。
@@ -27,6 +27,7 @@
 ## 已知限制与暂缓事项
 
 - **native ownership 有明确宿主条件**:Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。
+- **native launch 有同步 setup 成本**:首条 ordinary spawn 会为该 provider instance 探测一次宿主能力,每条 probe command 的上限为 5 秒。公共 `spawn()` 合同返回数值 target pid,因此每次 native launch 随后会同步等待 per-spawn runner 报告 target start 或 spawn failure。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。每条受支持的 native command 还会保留一个 runner process,直到 OS-owned range 为空。handle 发布后,runner event 每 100 ms、Linux scope state 每 200 ms 异步轮询。
 - **Windows Job inheritance 有明确排除项**:普通 descendant 默认继承 Job,但 breakaway process 不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。
 - **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。
 - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。

+ 67 - 9
packages/subprocess/subprocess-local/src/linux-scope.ts

@@ -1,7 +1,7 @@
 /** Linux user-systemd scope launch and managed-range ownership. */
 
 import { randomBytes } from 'node:crypto'
-import { spawn, spawnSync } from 'node:child_process'
+import { execFile, spawn, spawnSync } from 'node:child_process'
 import type { ChildProcess } from 'node:child_process'
 import { setTimeout as sleepMs } from 'node:timers/promises'
 import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
@@ -20,11 +20,50 @@ import {
 export interface LinuxScopeInternals {
   spawn?: typeof spawn
   spawnSync?: typeof spawnSync
+  systemctlQuery?: (command: string, args: readonly string[]) => Promise<SystemctlResult>
   systemdRun?: string
   systemctl?: string
   runnerInvocation?: string[]
 }
 
+interface SystemctlResult {
+  status: number | null
+  stdout: string
+  stderr: string
+  error?: Error
+}
+
+const SYSTEMCTL_TIMEOUT_MS = 5_000
+const SCOPE_POLL_INTERVAL_MS = 200
+
+function querySystemctl(command: string, args: readonly string[]): Promise<SystemctlResult> {
+  return new Promise((resolve) => {
+    execFile(command, [...args], { encoding: 'utf8', timeout: SYSTEMCTL_TIMEOUT_MS }, (error, stdout, stderr) => {
+      const code = error === null ? 0 : (error as Error & { code?: string | number }).code
+      resolve({
+        status: typeof code === 'number' ? code : error === null ? 0 : null,
+        stdout,
+        stderr,
+        ...error === null ? {} : { error },
+      })
+    })
+  })
+}
+
+function syncQuerySystemctl(
+  runSync: typeof spawnSync,
+  command: string,
+  args: readonly string[],
+): Promise<SystemctlResult> {
+  const result = runSync(command, [...args], { encoding: 'utf8', timeout: SYSTEMCTL_TIMEOUT_MS })
+  return Promise.resolve({
+    status: result.status,
+    stdout: typeof result.stdout === 'string' ? result.stdout : '',
+    stderr: typeof result.stderr === 'string' ? result.stderr : '',
+    ...result.error === undefined ? {} : { error: result.error },
+  })
+}
+
 function unitStem(prefix: string): string {
   return `${prefix}-${process.pid}-${randomBytes(6).toString('hex')}`
 }
@@ -69,11 +108,13 @@ class SystemdScopeOwner implements BoundProcessOwner {
   private stopped = false
   private observation: Promise<void> | undefined
   private killConfirmed = false
+  private killFailure: Error | undefined
 
   constructor(
     private readonly unit: string,
     private readonly systemctl: string,
     private readonly runSync: typeof spawnSync,
+    private readonly query: (command: string, args: readonly string[]) => Promise<SystemctlResult>,
     private readonly runner: ChildProcess,
   ) {}
 
@@ -85,24 +126,38 @@ class SystemdScopeOwner implements BoundProcessOwner {
       '--kill-whom=all',
       `--signal=${signal}`,
       this.unit,
-    ], { stdio: 'ignore', timeout: 5_000 })
-    if (signal === 'SIGKILL' && result.error === undefined && result.status === 0) this.killConfirmed = true
+    ], { encoding: 'utf8', timeout: SYSTEMCTL_TIMEOUT_MS })
+    if (result.error === undefined && result.status === 0) {
+      if (signal === 'SIGKILL') this.killConfirmed = true
+      return
+    }
+    const output = `${result.stdout}\n${result.stderr}`
+    if (/not found|could not be found|no such/iu.test(output)) {
+      this.stopped = true
+      return
+    }
+    if (signal === 'SIGKILL') {
+      this.killFailure = result.error ?? new Error(
+        `systemctl could not signal ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`,
+      )
+    }
   }
 
-  private active(): boolean {
-    const result = this.runSync(this.systemctl, [
+  private async active(): Promise<boolean> {
+    if (this.killFailure !== undefined) throw this.killFailure
+    const result = await this.query(this.systemctl, [
       '--user',
       'show',
       this.unit,
       '--property=ActiveState',
       '--value',
-    ], { encoding: 'utf8', timeout: 5_000 })
-    if (result.error !== undefined) throw result.error
+    ])
     const output = `${result.stdout}\n${result.stderr}`
     if (result.status !== 0) {
       if (/not found|could not be found|no such/iu.test(output)) {
         return this.runner.exitCode === null && this.runner.signalCode === null
       }
+      if (result.error !== undefined) throw result.error
       throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`)
     }
     const state = result.stdout.trim()
@@ -114,7 +169,7 @@ class SystemdScopeOwner implements BoundProcessOwner {
   async waitForExit(signal?: AbortSignal): Promise<boolean> {
     if (this.stopped) return true
     this.observation ??= (async () => {
-      while (this.active()) await sleepMs(15)
+      while (await this.active()) await sleepMs(SCOPE_POLL_INTERVAL_MS)
       this.stopped = true
     })()
     return waitWithAbort(this.observation, signal)
@@ -137,6 +192,9 @@ export function launchLinuxScope(
 ): ManagedProcessLaunch {
   const run = internals.spawn ?? spawn
   const runSync = internals.spawnSync ?? spawnSync
+  const query = internals.systemctlQuery ?? (internals.spawnSync === undefined
+    ? querySystemctl
+    : (command, args) => syncQuerySystemctl(runSync, command, args))
   const systemdRun = internals.systemdRun ?? 'systemd-run'
   const systemctl = internals.systemctl ?? 'systemctl'
   const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
@@ -163,7 +221,7 @@ export function launchLinuxScope(
     stdio: runnerStdio(spec),
   })
   const closed = observeChildClose(child)
-  const owner = new SystemdScopeOwner(`${unitBase}.scope`, systemctl, runSync, child)
+  const owner = new SystemdScopeOwner(`${unitBase}.scope`, systemctl, runSync, query, child)
   const result = runnerDirectResult(child, files, closed, () => owner.forcedOutcome())
   cleanupAfterRunner(files, result.direct, closed)
   return { child, pid: result.pid, direct: result.direct, closed, owner }

+ 1 - 1
packages/subprocess/subprocess-local/src/managed-owner.ts

@@ -7,7 +7,7 @@ import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
 export interface BoundProcessOwner {
   /** Signal the established managed range; a confirmed-stopped owner stays inert. */
   signal(signal: NodeJS.Signals): void
-  /** Wait for the same managed range to become empty. */
+  /** Wait for the same managed range to become empty; reject when its owner cannot be observed. */
   waitForExit(signal?: AbortSignal): Promise<boolean>
 }
 

+ 30 - 5
packages/subprocess/subprocess-local/src/runner-launch.ts

@@ -1,7 +1,7 @@
 /** Parent-side launch and direct-result transport for native runners. */
 
 import type { ChildProcess, StdioOptions } from 'node:child_process'
-import { existsSync } from 'node:fs'
+import { existsSync, readFileSync } from 'node:fs'
 import { fileURLToPath } from 'node:url'
 import { setTimeout as sleepMs } from 'node:timers/promises'
 import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
@@ -10,11 +10,14 @@ import {
   createRunnerFiles,
   deserializeSpawnError,
   readRunnerEvents,
+  readRunnerEventsAsync,
 } from './runner-protocol.ts'
 import type { RunnerEvent, RunnerFiles, RunnerRequest } from './runner-protocol.ts'
 import { childEnv } from './spawn.ts'
 
 const handshakeWait = new Int32Array(new SharedArrayBuffer(4))
+const RUNNER_HANDSHAKE_TIMEOUT_MS = 10_000
+const RUNNER_EVENT_POLL_MS = 100
 
 /**
  * Resolve the built runner in production or its source entry in repository execution.
@@ -61,18 +64,40 @@ interface RunnerHandshake {
   events: RunnerEvent[]
 }
 
+/** Observe wrapper death without waiting for Node's blocked event loop to emit close. */
+function runnerExited(child: ChildProcess): boolean {
+  if (child.exitCode !== null || child.signalCode !== null) return true
+  if (child.pid === undefined) return true
+  if (process.platform === 'linux') {
+    try {
+      const stat = readFileSync(`/proc/${String(child.pid)}/stat`, 'utf8')
+      const suffix = stat.slice(stat.lastIndexOf(')') + 2)
+      if (suffix.startsWith('Z') || suffix.startsWith('X')) return true
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true
+    }
+  }
+  try {
+    process.kill(child.pid, 0)
+    return false
+  } catch (error) {
+    return (error as NodeJS.ErrnoException).code === 'ESRCH'
+  }
+}
+
 /** Wait synchronously only until the runner reports target start or spawn failure. */
 function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): RunnerHandshake {
-  const deadline = Date.now() + 10_000
+  const deadline = Date.now() + RUNNER_HANDSHAKE_TIMEOUT_MS
   while (Date.now() < deadline) {
     const events = readRunnerEvents(files.eventsPath)
     const terminal = events.find(event => event.type === 'started' || event.type === 'spawn-error' || event.type === 'runner-error')
     if (terminal?.type === 'started') return { pid: terminal.pid, events }
     if (terminal?.type === 'spawn-error' || terminal?.type === 'runner-error') return { pid: -1, events }
     if (child.pid === undefined) throw new Error('native subprocess runner failed to start')
+    if (runnerExited(child)) throw new Error('native subprocess runner exited before reporting target start')
     Atomics.wait(handshakeWait, 0, 0, 5)
   }
-  throw new Error('native subprocess runner did not report target start within 10000ms')
+  throw new Error(`native subprocess runner did not report target start within ${String(RUNNER_HANDSHAKE_TIMEOUT_MS)}ms`)
 }
 
 async function waitForDirectResult(
@@ -85,7 +110,7 @@ async function waitForDirectResult(
   let wrapperClosed = false
   void closed.then(() => { wrapperClosed = true })
   for (;;) {
-    const events = readRunnerEvents(files.eventsPath)
+    const events = await readRunnerEventsAsync(files.eventsPath)
     for (const event of events.slice(seen)) {
       if (event.type === 'exit') return { exitCode: event.exitCode, signal: event.signal }
       if (event.type === 'spawn-error' || event.type === 'runner-error') throw deserializeSpawnError(event.error)
@@ -97,7 +122,7 @@ async function waitForDirectResult(
       if (known !== undefined) return known
       throw new Error('native subprocess runner exited without a direct-command result')
     }
-    await sleepMs(10)
+    await sleepMs(RUNNER_EVENT_POLL_MS)
   }
 }
 

+ 25 - 3
packages/subprocess/subprocess-local/src/runner-protocol.ts

@@ -8,6 +8,7 @@ import {
   unlinkSync,
   writeFileSync,
 } from 'node:fs'
+import { readFile } from 'node:fs/promises'
 import { constants as osConstants, tmpdir } from 'node:os'
 import { join } from 'node:path'
 
@@ -136,6 +137,13 @@ export function appendRunnerEvent(eventsPath: string, event: RunnerEvent): void
   appendFileSync(eventsPath, `${JSON.stringify(event)}\n`, { mode: 0o600 })
 }
 
+/** Parse complete newline-terminated runner records. */
+function parseRunnerEvents(content: string): RunnerEvent[] {
+  const lines = content.split('\n')
+  if (lines.at(-1) !== '') lines.pop()
+  return lines.filter(line => line.length > 0).map(parseRunnerEvent)
+}
+
 /**
  * Parse every complete event record currently present.
  * @param eventsPath - private event file.
@@ -149,9 +157,23 @@ export function readRunnerEvents(eventsPath: string): RunnerEvent[] {
     if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
     throw error
   }
-  const lines = content.split('\n')
-  if (lines.at(-1) !== '') lines.pop()
-  return lines.filter(line => line.length > 0).map(parseRunnerEvent)
+  return parseRunnerEvents(content)
+}
+
+/**
+ * Asynchronously parse every complete event record currently present.
+ * @param eventsPath - private event file.
+ * @returns complete records in append order.
+ */
+export async function readRunnerEventsAsync(eventsPath: string): Promise<RunnerEvent[]> {
+  let content: string
+  try {
+    content = await readFile(eventsPath, 'utf8')
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
+    throw error
+  }
+  return parseRunnerEvents(content)
 }
 
 /**

+ 7 - 1
packages/subprocess/subprocess-local/src/spawn.ts

@@ -466,7 +466,9 @@ export function bindManagedProcess(
 
   const terminate = (): void => {
     if (rangeExitObserved || graceTimer !== undefined) return
-    void observeRangeExit()
+    // Keep the shared observation rejection available to waitForExit() without
+    // leaking an unhandled rejection when a caller only invokes terminate().
+    void observeRangeExit().catch(() => {})
     kill('SIGTERM')
     graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs)
   }
@@ -504,6 +506,10 @@ export function bindManagedProcess(
     }
     launch.direct.then((outcome) => {
       directOutcome = outcome
+      if (stdoutCollector === undefined && stderrCollector === undefined) {
+        settle(outcome)
+        return
+      }
       pipeDrainTimer = setTimeout(() => { settle(outcome) }, spec.graceMs)
       if (wrapperClosed) settle(outcome)
     }, (error: unknown) => {

+ 45 - 6
packages/subprocess/subprocess-local/tests/linux-scope.spec.ts

@@ -231,10 +231,43 @@ describe('Linux systemd scope adapter', () => {
     await expect(launch.owner.waitForExit()).resolves.toBe(true)
   })
 
+  it('reports a failed scope KILL through the shared wait', async () => {
+    let wrapper: ReturnType<typeof spawn> | undefined
+    const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
+      const separator = args.indexOf('--')
+      wrapper = spawn(args[separator + 1] as string, args.slice(separator + 2), options)
+      return wrapper
+    }) as unknown as typeof spawn
+    const runSync = vi.fn((command: string, args: readonly string[]) => {
+      if (command === 'systemctl' && args[1] === 'kill') {
+        return { status: 1, stdout: '', stderr: 'Failed to connect to bus', error: undefined }
+      }
+      return { status: 0, stdout: 'active\n', stderr: '', error: undefined }
+    }) as unknown as typeof spawnSync
+    const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), {
+      spawn: run,
+      spawnSync: runSync,
+      runnerInvocation: spawnRunnerInvocation(),
+    })
+    void launch.direct.catch(() => {})
+    try {
+      launch.owner.signal('SIGKILL')
+      await expect(launch.owner.waitForExit()).rejects.toThrow('Failed to connect to bus')
+      expect(runSync).toHaveBeenCalledWith(
+        'systemctl',
+        expect.arrayContaining(['kill', '--kill-whom=all', '--signal=SIGKILL']),
+        expect.any(Object),
+      )
+    } finally {
+      wrapper?.kill('SIGKILL')
+    }
+  })
+
   it('uses the production command defaults when no Linux internals are supplied', async () => {
     let wrapper: ReturnType<typeof spawn> | undefined
     const run = vi.fn()
     const runSync = vi.fn()
+    const runAsync = vi.fn()
     vi.resetModules()
     vi.doMock('node:child_process', async (importOriginal) => {
       const actual = await importOriginal<typeof import('node:child_process')>()
@@ -243,14 +276,19 @@ describe('Linux systemd scope adapter', () => {
         wrapper = actual.spawn(args[separator + 1] as string, args.slice(separator + 2), options)
         return wrapper
       })
-      runSync.mockImplementation((command: string, args: readonly string[]) => {
-        if (command === 'systemctl' && args[1] === 'show') {
-          const active = wrapper?.exitCode === null && wrapper.signalCode === null
-          return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined }
-        }
+      runSync.mockImplementation((_command: string, _args: readonly string[]) => {
         return { status: 0, stdout: '', stderr: '', error: undefined }
       })
-      return { ...actual, spawn: run, spawnSync: runSync }
+      runAsync.mockImplementation((
+        _command: string,
+        args: readonly string[],
+        _options: unknown,
+        callback: (error: Error | null, stdout: string, stderr: string) => void,
+      ) => {
+        const active = wrapper?.exitCode === null && wrapper.signalCode === null
+        callback(null, args[1] === 'show' && active ? 'active\n' : 'inactive\n', '')
+      })
+      return { ...actual, execFile: runAsync, spawn: run, spawnSync: runSync }
     })
     try {
       const defaults = await import('../src/linux-scope.ts')
@@ -260,6 +298,7 @@ describe('Linux systemd scope adapter', () => {
       await expect(launch.owner.waitForExit()).resolves.toBe(true)
       expect(run).toHaveBeenCalledWith('systemd-run', expect.any(Array), expect.any(Object))
       expect(runSync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object))
+      expect(runAsync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object), expect.any(Function))
     } finally {
       vi.doUnmock('node:child_process')
       vi.resetModules()

+ 48 - 0
packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts

@@ -117,6 +117,54 @@ describe('managed process binding', () => {
     }
   })
 
+  it('publishes direct outcome immediately when no collected stream needs draining', async () => {
+    const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
+      stdio: ['ignore', 'ignore', 'ignore'],
+    })
+    const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
+    const handle = bindManagedProcess({
+      ...spec(1_000),
+      stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' },
+    }, {
+      child: wrapper,
+      pid: wrapper.pid as number,
+      direct: direct.promise,
+      closed: new Promise<void>(() => {}),
+      owner: { signal: vi.fn(), waitForExit: async () => true },
+    })
+    try {
+      direct.resolve({ exitCode: 23, signal: null })
+      const outcome = await Promise.race([
+        handle.done,
+        new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 50)),
+      ])
+      expect(outcome).toEqual({ exitCode: 23, signal: null })
+    } finally {
+      wrapper.kill('SIGKILL')
+    }
+  })
+
+  it('contains background range-observation rejection until waitForExit observes it', async () => {
+    const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
+      stdio: ['ignore', 'pipe', 'pipe'],
+    })
+    const failure = new Error('range observation failed')
+    const handle = bindManagedProcess(spec(), {
+      child: wrapper,
+      pid: wrapper.pid as number,
+      direct: new Promise(() => {}),
+      closed: new Promise<void>(() => {}),
+      owner: { signal: vi.fn(), waitForExit: async () => { throw failure } },
+    })
+    try {
+      handle.terminate()
+      await new Promise(resolve => setImmediate(resolve))
+      await expect(handle.waitForExit()).rejects.toBe(failure)
+    } finally {
+      wrapper.kill('SIGKILL')
+    }
+  })
+
   it('normalizes a non-Error direct rejection', async () => {
     const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
       stdio: ['ignore', 'pipe', 'pipe'],

+ 12 - 2
packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts

@@ -17,6 +17,7 @@ import {
   createRunnerFiles,
   deserializeSpawnError,
   readRunnerEvents,
+  readRunnerEventsAsync,
   serializeSpawnError,
 } from '../src/runner-protocol.ts'
 
@@ -126,7 +127,7 @@ describe('spawn runner transport', () => {
     }
   })
 
-  it('reads only complete known event records and propagates file errors', () => {
+  it('reads only complete known event records and propagates file errors', async () => {
     const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
     try {
       expect(readRunnerEvents(files.eventsPath)).toEqual([])
@@ -165,6 +166,7 @@ describe('spawn runner transport', () => {
           },
         },
       ])
+      await expect(readRunnerEventsAsync(files.eventsPath)).resolves.toEqual(readRunnerEvents(files.eventsPath))
 
       writeFileSync(files.eventsPath, '{"type":"started","pid":123}\n{"type":"exit"')
       expect(readRunnerEvents(files.eventsPath)).toEqual([{ type: 'started', pid: 123 }])
@@ -174,7 +176,9 @@ describe('spawn runner transport', () => {
       }
       writeFileSync(files.eventsPath, '{"type":"unknown"}\n')
       expect(() => readRunnerEvents(files.eventsPath)).toThrow('emitted unknown event')
+      await expect(readRunnerEventsAsync(files.eventsPath)).rejects.toThrow('emitted unknown event')
       expect(() => readRunnerEvents(files.directory)).toThrow()
+      await expect(readRunnerEventsAsync(files.directory)).rejects.toThrow()
     } finally {
       cleanupRunnerFiles(files)
     }
@@ -287,10 +291,16 @@ describe('spawn runner transport', () => {
     await expect(missingResult.direct).rejects.toThrow('runner failed to start')
     expect(existsSync(missingChild.directory)).toBe(false)
 
+    const exitedChild = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
+    const exitedResult = runnerDirectResult(fakeChild(2_147_483_647), exitedChild, new Promise<void>(() => {}))
+    expect(exitedResult.pid).toBe(-1)
+    await expect(exitedResult.direct).rejects.toThrow('exited before reporting target start')
+    expect(existsSync(exitedChild.directory)).toBe(false)
+
     const timedOut = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
     const now = vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(10_001)
     try {
-      const timedOutResult = runnerDirectResult(fakeChild(123), timedOut, new Promise<void>(() => {}))
+      const timedOutResult = runnerDirectResult(fakeChild(process.pid), timedOut, new Promise<void>(() => {}))
       expect(timedOutResult.pid).toBe(-1)
       await expect(timedOutResult.direct).rejects.toThrow('did not report target start')
       expect(existsSync(timedOut.directory)).toBe(false)

+ 2 - 2
packages/subprocess/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/subprocess/README.md
-README.md: 5e80bc7205c2f06b3528bbcfcb775941d2fa4545
-README.zh.md: 4744b4aeea07e18a8d149865050f7487f8816061
+README.md: 7972d933cfbb70627cf29e8e44d1f41d873f3075
+README.zh.md: a55ef79e664e4b837e8326bd4b513bfc6a844b75

+ 2 - 2
packages/subprocess/subprocess/README.md

@@ -6,11 +6,11 @@ The subprocess seam (`ctx.subprocess`) is the process half of one execution worl
 
 ## Contract
 
-- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures.
+- `spawn(spec)` returns a live handle synchronously; a native provider may first complete its bounded setup handshake so the handle exposes the target pid. `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects for spawn-level or selected native-runner failures.
 - Spawn working directories and executable paths belong to the provider's execution world. `resolveExecutable(command, env?, signal?)` verifies absolute commands or resolves bare names against that world's scrubbed PATH plus explicit overrides.
 - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the caller's config, not to a hidden subprocess-service default (the `dsh-shell` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself.
 - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement.
-- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence — the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification).
+- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence. The wait rejects when a selected native owner can no longer observe its range; the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification).
 - `spawnTerminal(spec)` is the only non-pipe primitive. Its handle owns a real PTY, UTF-8 text I/O, foreground-process-group inspection/signalling, and one awaited `terminate()` operation that reaches quiescence for every session member the provider can still observe and settles in-flight handle calls; providers document substrate-specific observability limits. The spec signal cancels allocation only; the published handle owns its lifetime. The output stream ends after queued output when the top-level process exits, and a live transport failure rejects `done`. These operations remain one substrate primitive because ordinary pipes cannot allocate a controlling terminal or clean terminal-session members; readiness, scrollback, and owner policy remain in the PTY consumer.
 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and explicit `env` merges after the scrub. The local ordinary and terminal spawns both apply it; SDK-managed transports that own their spawn may import it directly.
 - Disposal of the service terminates all still-running managed processes and awaits their exit.

+ 2 - 2
packages/subprocess/subprocess/README.zh.md

@@ -6,11 +6,11 @@
 
 ## 约定
 
-- `spawn(spec)` 立即返回一个活动句柄;`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。
+- `spawn(spec)` 同步返回活动句柄;native provider 可以先完成有界 setup handshake,使该句柄公开 target pid。`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),并在 spawn 层面或所选 native runner 失败时 reject。
 - spawn 工作目录和可执行文件路径属于提供方的执行世界。`resolveExecutable(command, env?, signal?)` 验证绝对命令,或根据该执行世界清理后的 PATH 加显式覆盖来解析裸名称。
 - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方的配置,而不属于某个隐藏的子进程服务默认值(`dsh-shell` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。
 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。
-- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。
+- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。所选 native owner 不再能观察范围时,该等待会 reject;管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。
 - `spawnTerminal(spec)` 是唯一的非管道原语。其句柄负责真实 PTY、UTF-8 文本 I/O、前台进程组检查/信号发送,以及一项须等待的 `terminate()` 操作;该操作会使提供方仍可观察到的每个会话成员完全停稳,并结算在途句柄调用;提供方会记录执行基底特有的可观察性限制。spec 信号只取消分配;句柄一经发布,便负责自身生命周期。顶层进程退出时,输出流在已排队输出之后结束;仍处于活动状态的传输若发生故障,会使 `done` 拒绝。这些操作保留为一项执行基底原语,因为普通管道无法分配控制终端或清理终端会话成员;就绪状态、scrollback 和所有者策略仍归 PTY 消费方所有。
 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并。本地的普通 spawn 与终端 spawn 都应用该定义;拥有自身 spawn 的 SDK 管理传输可直接导入它。
 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。

+ 2 - 1
packages/subprocess/subprocess/src/types.ts

@@ -174,7 +174,7 @@ export interface SubprocessHandle {
   readonly stderr: Readable | undefined
   /** Offset-based readers for collect-mode streams (also readable after exit). */
   readonly collected: SubprocessCollectedOutputs
-  /** Resolves at process close with exit facts; rejects only for spawn-level failures. */
+  /** Resolves with direct-process exit facts; rejects for spawn or selected native-runner failures. */
   readonly done: Promise<SubprocessOutcome>
   /**
    * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree
@@ -188,6 +188,7 @@ export interface SubprocessHandle {
    * child, so a still-running helper is observable before teardown returns.
    * @param signal - optional bound for the wait.
    * @returns `true` when the tree exited, `false` when the signal aborted first.
+   * @throws when the selected provider can no longer observe its managed range.
    */
   waitForExit(signal?: AbortSignal): Promise<boolean>
 }

+ 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: d541b90448a4f42879a62a015374d139aa502395
-README.zh.md: 69bc93ee93d825a03a337976df7302e8f6fffcb6
+README.md: 5d14ead5a8d9b6b5d00ee298f274a3d4a1a9aae8
+README.zh.md: faa2dc829db4e4772384bb8a58ca56cebb12dd5c

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

@@ -10,8 +10,8 @@ Low-level Win32 process library consumed by the Windows ACL sandbox and the ordi
 - **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, 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.
-- **Ordinary Job runner primitive** — `spawnOrdinaryJobProcess()` applies the same suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW`. Zero-time process and Job probes let the runner publish the direct exit separately and stay alive until default-inheritance descendants leave the Job.
-- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner polling and checked Job termination/closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles.
+- **Ordinary Job runner primitive** — `spawnOrdinaryJobProcess()` applies the same suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW`. A zero-time process wait publishes the direct exit separately, while `QueryInformationJobObject(JobObjectBasicAccountingInformation)` keeps the runner alive until `ActiveProcesses` reaches zero.
+- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling, Job accounting, and checked Job termination/closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles.
 
 The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives.
 
@@ -23,7 +23,7 @@ The process, stdio, and Job constants plus selected structure sizes and offsets
 g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe
 ```
 
-The Koffi `STARTUPINFOW` and `PROCESS_INFORMATION` definitions also assert their 64-bit sizes at module load. The probe remains the evidence for the other recorded offsets and constants.
+The Koffi `STARTUPINFOW` and `PROCESS_INFORMATION` definitions also assert their 64-bit sizes at module load. The probe additionally fixes the basic Job accounting record size and `ActiveProcesses` offset used to determine quiescence; it remains the evidence for the other recorded offsets and constants.
 
 ## Model Experience
 

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

@@ -10,8 +10,8 @@
 - **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 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。
-- **ordinary Job runner 原语** — `spawnOrdinaryJobProcess()` 通过 `CreateProcessW` 应用相同的 suspended-create、Job-assignment 与 resume 生命周期。process 与 Job 的 zero-time probe 让 runner 分别发布 direct exit,并一直存活到默认继承 descendant 离开 Job。
-- **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner polling 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。
+- **ordinary Job runner 原语** — `spawnOrdinaryJobProcess()` 通过 `CreateProcessW` 应用相同的 suspended-create、Job-assignment 与 resume 生命周期。process 的 zero-time wait 单独发布 direct exit,`QueryInformationJobObject(JobObjectBasicAccountingInformation)` 则让 runner 一直存活到 `ActiveProcesses` 归零。
+- **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner 的 process polling、Job accounting 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。
 
 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。
 
@@ -25,7 +25,7 @@ process、stdio 与 Job 的常量以及选定结构体的大小和偏移由 [`ve
 g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe
 ```
 
-Koffi 的 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 定义还会在模块加载时断言各自的 64 位大小;其余已记录偏移和常量由该探针提供证据。
+Koffi 的 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 定义还会在模块加载时断言各自的 64 位大小。该探针还固定用于判断停稳的基础 Job accounting record 大小与 `ActiveProcesses` 偏移;其余已记录偏移和常量也由该探针提供证据。
 
 ## Model Experience
 

+ 6 - 0
packages/subprocess/win32-process/src/abi.ts

@@ -28,8 +28,14 @@ export const ERROR_BROKEN_PIPE = 109
 export const ERROR_NO_DATA = 232
 /** Job limit that terminates every member when the final Job handle closes. */
 export const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
+/** QueryInformationJobObject class for basic accounting and active-process count. */
+export const JobObjectBasicAccountingInformation = 1
 /** SetInformationJobObject class for JOBOBJECT_EXTENDED_LIMIT_INFORMATION. */
 export const JobObjectExtendedLimitInformation = 9
+/** x64 JOBOBJECT_BASIC_ACCOUNTING_INFORMATION byte size. */
+export const JOBOBJECT_BASIC_ACCOUNTING_SIZE = 48
+/** Byte offset of ActiveProcesses in JOBOBJECT_BASIC_ACCOUNTING_INFORMATION. */
+export const JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET = 40
 /** x64 JOBOBJECT_EXTENDED_LIMIT_INFORMATION byte size. */
 export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144
 /** Byte offset of BasicLimitInformation.LimitFlags in the extended Job record. */

+ 10 - 0
packages/subprocess/win32-process/src/ffi.ts

@@ -106,6 +106,13 @@ 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
+  queryInformationJobObject(
+    job: NativePtr,
+    cls: number,
+    information: Buffer,
+    length: number,
+    returnLength: null,
+  ): number
   assignProcessToJobObject(job: NativePtr, process: NativePtr): number
   resumeThread(thread: NativePtr): number
   terminateProcess(process: NativePtr, exitCode: number): number
@@ -266,6 +273,9 @@ 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']),
+    queryInformationJobObject: bind(kernel32, 'QueryInformationJobObject', 'int', [
+      PVOID, 'int', PVOID, 'uint32', PVOID,
+    ]),
     assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]),
     resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]),
     terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']),

+ 12 - 5
packages/subprocess/win32-process/src/process.ts

@@ -493,13 +493,20 @@ export function pollProcessExit(api: Win32ProcessBindings, process: NativePtr):
  * Return whether a Job has no active processes.
  * @param api - active binding table.
  * @param job - caller-owned Job handle.
- * @returns true once the Job object is signalled.
+ * @returns true once the Job reports zero active processes.
  */
 export function isJobEmpty(api: Win32ProcessBindings, job: NativePtr): boolean {
-  const waitResult = api.waitForSingleObject(job, 0)
-  if (waitResult === abi.WAIT_TIMEOUT) return false
-  if (waitResult === 0xFFFFFFFF) throwLastError(api, 'WaitForSingleObject', 'Job object')
-  return true
+  const information = Buffer.alloc(abi.JOBOBJECT_BASIC_ACCOUNTING_SIZE)
+  if (api.queryInformationJobObject(
+    job,
+    abi.JobObjectBasicAccountingInformation,
+    information,
+    information.length,
+    null,
+  ) === 0) {
+    throwLastError(api, 'QueryInformationJobObject', 'active process count')
+  }
+  return information.readUInt32LE(abi.JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET) === 0
 }
 
 /**

+ 28 - 4
packages/subprocess/win32-process/tests/ordinary-process.spec.ts

@@ -8,7 +8,13 @@ import {
   terminateJob,
   Win32Error,
 } from '../src/index.ts'
-import { CREATE_SUSPENDED, WAIT_TIMEOUT } from '../src/abi.ts'
+import {
+  CREATE_SUSPENDED,
+  JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET,
+  JOBOBJECT_BASIC_ACCOUNTING_SIZE,
+  JobObjectBasicAccountingInformation,
+  WAIT_TIMEOUT,
+} from '../src/abi.ts'
 import { PROCESS_INFORMATION } from '../src/ffi.ts'
 import type { NativePtr, Win32ProcessBindings } from '../src/index.ts'
 
@@ -16,6 +22,10 @@ function api(overrides: Partial<Win32ProcessBindings> = {}): Win32ProcessBinding
   return {
     createJobObjectW: vi.fn(() => 50n),
     setInformationJobObject: vi.fn(() => 1),
+    queryInformationJobObject: vi.fn((_job: NativePtr, _cls: number, information: Buffer) => {
+      information.writeUInt32LE(0, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET)
+      return 1
+    }),
     getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)),
     setHandleInformation: vi.fn(() => 1),
     createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => {
@@ -102,9 +112,23 @@ describe('ordinary Job process operations', () => {
   })
 
   it('polls direct exit and Job emptiness without blocking', () => {
-    const running = api({ waitForSingleObject: vi.fn(() => WAIT_TIMEOUT) })
+    const queryInformationJobObject = vi.fn((_job: NativePtr, _cls: number, information: Buffer) => {
+      information.writeUInt32LE(1, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET)
+      return 1
+    })
+    const running = api({
+      waitForSingleObject: vi.fn(() => WAIT_TIMEOUT),
+      queryInformationJobObject,
+    })
     expect(pollProcessExit(running, 60n as NativePtr)).toBeUndefined()
     expect(isJobEmpty(running, 50n as NativePtr)).toBe(false)
+    expect(queryInformationJobObject).toHaveBeenCalledWith(
+      50n,
+      JobObjectBasicAccountingInformation,
+      expect.objectContaining({ length: JOBOBJECT_BASIC_ACCOUNTING_SIZE }),
+      JOBOBJECT_BASIC_ACCOUNTING_SIZE,
+      null,
+    )
 
     const exited = api()
     expect(pollProcessExit(exited, 60n as NativePtr)).toBe(42)
@@ -118,8 +142,8 @@ describe('ordinary Job process operations', () => {
     const exitCode = api({ getExitCodeProcess: vi.fn(() => 0) })
     expect(() => pollProcessExit(exitCode, 60n as NativePtr)).toThrow(Win32Error)
 
-    const jobWait = api({ waitForSingleObject: vi.fn(() => 0xFFFFFFFF) })
-    expect(() => isJobEmpty(jobWait, 50n as NativePtr)).toThrow(Win32Error)
+    const jobQuery = api({ queryInformationJobObject: vi.fn(() => 0) })
+    expect(() => isJobEmpty(jobQuery, 50n as NativePtr)).toThrow(Win32Error)
   })
 
   it('checks Job termination and caller-owned handle closure', () => {

+ 6 - 0
packages/subprocess/win32-process/verify/abi-probe.cpp

@@ -30,6 +30,9 @@ int wmain()
   P(ERROR_INSUFFICIENT_BUFFER);
   P(ERROR_BROKEN_PIPE);
   P(ERROR_NO_DATA);
+  P(sizeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION));
+  P(offsetof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, ActiveProcesses));
+  P((int)JobObjectBasicAccountingInformation);
   P(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION));
   P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags));
   P((int)JobObjectExtendedLimitInformation);
@@ -41,6 +44,9 @@ int wmain()
   static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag");
   static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag");
   static_assert(WAIT_TIMEOUT == 258, "zero-time wait timeout");
+  static_assert(sizeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION) == 48, "job accounting size");
+  static_assert(offsetof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, ActiveProcesses) == 40, "active process offset");
+  static_assert(JobObjectBasicAccountingInformation == 1, "basic accounting class");
   static_assert(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144, "job extended limit size");
   static_assert(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags) == 16, "job LimitFlags offset");
   static_assert(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE == 0x2000, "kill on job close flag");