Răsfoiți Sursa

fix(subprocess): preserve post-start runner failures

pku-xht 1 lună în urmă
părinte
comite
eb17028720

+ 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: 3fec4469657ea5a98ed37c8a72f727afce08c7b3
-README.zh.md: f615e8f6e31a81b711bf58d51da2601cccbf443c
+README.md: 30dc28a0be9db966ef8ffc1286ffa07858c8f81d
+README.zh.md: 1a5a6c17efad9ea2a1e0fb2980b23e61679a68f5

+ 1 - 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. On Windows the parent creates private named-pipe endpoints for non-inherited streams; the runner opens only the target-side handles, creates the target suspended, assigns it to its kill-on-close Job, resumes it, and closes those pipe handles before publishing startup. The runner alone retains the original target process handle and Job, reports the direct result, and exits successfully only after `ActiveProcesses` reaches zero; the parent never opens either native object. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after the selected owner proves the range empty and rejects when that proof is unavailable. `.done` remains the direct command result, and only 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. On Windows the parent creates private named-pipe endpoints for non-inherited streams; the runner opens only the target-side handles, creates the target suspended, assigns it to its kill-on-close Job, resumes it, publishes startup, and then closes those pipe handles. The runner alone retains the original target process handle and Job, reports the direct result, and exits successfully only after `ActiveProcesses` reaches zero; the parent never opens either native object. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after the selected owner proves the range empty and rejects when that proof is unavailable. After the direct result arrives, `.done` waits up to `graceMs` for every non-inherited output stream to close; at that bound, only collected streams are force-closed while raw pipes remain caller-owned.
 - **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).

+ 1 - 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 parent 为非继承流创建 private named-pipe endpoint;runner 只打开 target 侧 handle,以 suspended 状态创建目标,把它分配给自身的 kill-on-close Job,恢复目标,并在发布启动事实前关闭这些 pipe handle。只有 runner 保留原始 target process handle 与 Job,报告 direct result,并只在 `ActiveProcesses` 归零后成功退出;parent 不打开这两个 native object。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在所选 owner 证明范围为空后成功,无法取得该证明时则拒绝。`.done` 仍是 direct command result;只有 collected pipe 保留既有有界排空宽限期。
+- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope。Windows parent 为非继承流创建 private named-pipe endpoint;runner 只打开 target 侧 handle,以 suspended 状态创建目标,把它分配给自身的 kill-on-close Job,恢复目标,发布启动事实,然后关闭这些 pipe handle。只有 runner 保留原始 target process handle 与 Job,报告 direct result,并只在 `ActiveProcesses` 归零后成功退出;parent 不打开这两个 native object。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在所选 owner 证明范围为空后成功,无法取得该证明时则拒绝。direct result 到达后,`.done` 会等待所有非继承输出流关闭,最长不超过 `graceMs`;到达该界限时仅强制关闭 collected stream,raw pipe 仍归调用方所有。
 - **明确披露较弱 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)。

+ 4 - 4
packages/subprocess/subprocess-local/src/spawn-runner.ts

@@ -165,7 +165,6 @@ async function runWin32(
     // Match Node's cwd-relative executable lookup and spawn-error attribution.
     const runnerCwd = process.cwd()
     process.chdir(request.cwd)
-    let targetPid: number
     try {
       const [command, ...args] = request.argv
       const spawned = spawnOrdinaryJobProcess(
@@ -175,11 +174,10 @@ async function runWin32(
       )
       processHandle = spawned.process
       jobHandle = spawned.job
-      targetPid = spawned.pid
+      appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid })
     } finally {
       process.chdir(runnerCwd)
     }
-    appendRunnerEvent(eventsPath, { type: 'started', pid: targetPid })
     closeStdioHandles(api, openedStdio, true)
 
     await new Promise<void>((resolve, reject) => {
@@ -233,7 +231,9 @@ async function runWin32(
     })
   } catch (error) {
     const targetSpawnFailed = (error instanceof Win32Error && error.api === 'CreateProcessW')
-      || (error instanceof Error && (error as NodeJS.ErrnoException).syscall === 'chdir')
+      || (processHandle === undefined
+        && error instanceof Error
+        && (error as NodeJS.ErrnoException).syscall === 'chdir')
     appendRunnerEvent(eventsPath, {
       type: targetSpawnFailed ? 'spawn-error' : 'runner-error',
       error: targetSpawnFailed ? win32SpawnError(error, request) : serializeSpawnError(error),

+ 6 - 2
packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts

@@ -80,7 +80,7 @@ describe('managed process binding', () => {
     const stderr = new PassThrough()
     const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
     const request = {
-      ...spec(),
+      ...spec(1_000),
       stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 1024 } } as const,
     }
     const handle = bindManagedProcess(request, {
@@ -91,6 +91,7 @@ describe('managed process binding', () => {
       direct: direct.promise,
       owner: { signal: vi.fn(), waitForExit: async () => {} },
     })
+    stdout.resume()
     let doneSettled = false
     void handle.done.then(() => { doneSettled = true })
     direct.resolve({ exitCode: 23, signal: null })
@@ -100,7 +101,10 @@ describe('managed process binding', () => {
     await Promise.resolve()
     expect(doneSettled).toBe(false)
     stderr.end()
-    await expect(handle.done).resolves.toEqual({ exitCode: 23, signal: null })
+    await expect(Promise.race([
+      handle.done,
+      new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 100)),
+    ])).resolves.toEqual({ exitCode: 23, signal: null })
   })
 
   it('publishes direct outcome immediately when no collected stream needs draining', async () => {