Ver Fonte

test(subprocess): align native evidence with plan

pku-xht há 1 mês atrás
pai
commit
0c80f9e7a1

+ 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: 19fc275370e6262a7d0a31042d41f1c1fa0c9477
-2026-08-20-subprocess-native-containment.zh.md: ca96b6e30094a5d4277e9fb9acd2a1e49fec1757
+2026-08-20-subprocess-native-containment.md: 334a5a89056eb4fbc2a22874c81530dc0e6c8a8c
+2026-08-20-subprocess-native-containment.zh.md: fec48b641f286a026fc7e78b03e3a45a24d74c61

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

@@ -14,13 +14,13 @@ 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, termination scheduling, and host-exit registration. Linux scope and POSIX process-group owners deliver TERM and then KILL after the configured grace; Windows Job and `taskkill` owners force-terminate on the first request. `.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. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; if scope KILL prevents a final target event, `.done` rejects rather than inventing an outcome. On Windows the parent creates private named-pipe endpoints for non-inherited streams, while the runner opens only the target-side handles. That runner creates the target suspended, assigns it to its unnamed kill-on-close Job, resumes it, closes its pipe handles before publishing startup, and retains the original target process handle and Job until it has reported direct exit and `QueryInformationJobObject` reports zero active members. The parent never opens the target process or Job; IPC termination and disconnect remain the only control path into the runner.
+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. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target. If scope KILL prevents a final target event, the Linux launch reports `SIGKILL` only after that KILL was attempted and the owner proves the scope empty; an unrelated runner or manager failure still rejects. On Windows the parent creates private named-pipe endpoints for non-inherited streams, while the runner opens only the target-side handles. That runner creates the target suspended, assigns it to its unnamed kill-on-close Job, resumes it, closes its pipe handles before publishing startup, and retains the original target process handle and Job until it has reported direct exit and `QueryInformationJobObject` reports zero active members. The parent never opens the target process or Job; IPC termination and disconnect remain the only control path into the runner.
 
 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.
 
 ## Verification
 
-Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with systemd 255.4 and covers a real `setsid` descendant, a double-fork daemon whose direct parent exits first, and Node-shaped spawn failures without replay. Windows native evidence covers default Job inheritance, raw stdin after startup, direct stdout/stderr EOF while a descendant remains, direct result versus Job quiescence, and target spawn failures. Shared tests pin literal argv, one-time fallback warnings, unreadable-owner rejection, no post-stop signals, abort and host-exit routing, and source, built, and packaged-executable runner entries.
+Linux native evidence on Ubuntu 24.04 x86_64 with systemd 255.4 runs one real `setsid` and reparenting scenario plus Node-shaped spawn failures without replay. Windows native evidence covers one default-inheritance descendant scenario plus raw stdin, direct stdout/stderr EOF, direct result versus Job quiescence, and target spawn failures. Shared tests pin literal argv, one-time fallback warnings, unreadable-owner rejection, no post-stop signals, abort and host-exit routing, and source, built, and packaged-executable runner entries.
 
 ## Alternatives considered
 

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

@@ -14,13 +14,13 @@ Status: implemented
 
 common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、termination scheduling 与 host-exit 注册。Linux scope 与 POSIX 进程组 owner 先投递 TERM,并在配置的 grace 后投递 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`.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。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;scope KILL 阻止最终 target event 时,`.done` 会拒绝,而不会虚构 outcome。Windows parent 为非继承流创建 private named-pipe endpoint,runner 只打开 target 侧 handle。该 runner 以 suspended 状态创建目标,把目标分配给自身 unnamed kill-on-close Job,恢复目标,在发布启动事实前关闭自身 pipe handle,并保留原始 target process handle 与 Job,直到报告 direct exit 且 `QueryInformationJobObject` 报告 active member 归零。parent 不打开 target process 或 Job;IPC termination 与 disconnect 是进入 runner 的唯一控制路径。
+Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标。若 scope KILL 阻止最终 target event,Linux launch 只会在该 KILL 已尝试且 owner 证明 scope 为空后报告 `SIGKILL`;无关的 runner 或 manager failure 仍会拒绝。Windows parent 为非继承流创建 private named-pipe endpoint,runner 只打开 target 侧 handle。该 runner 以 suspended 状态创建目标,把目标分配给自身 unnamed kill-on-close Job,恢复目标,在发布启动事实前关闭自身 pipe handle,并保留原始 target process handle 与 Job,直到报告 direct exit 且 `QueryInformationJobObject` 报告 active member 归零。parent 不打开 target process 或 Job;IPC termination 与 disconnect 是进入 runner 的唯一控制路径。
 
 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。
 
 ## Verification
 
-Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager 上运行,覆盖真实 `setsid` descendant、direct parent 先退出的 double-fork daemon,以及不重放的 Node-shaped spawn failure。Windows native 证据覆盖默认 Job inheritance、启动后的 raw stdin、descendant 仍存活时 direct stdout/stderr EOF、direct result 与 Job quiescence 的区别,以及 target spawn failure。shared tests 固定 literal argv、一次性 fallback warning、owner 不可读时拒绝、停稳后不再发 signal、abort 与 host-exit 路由,以及 source、built 和 packaged-executable runner entry。
+Linux native 证据在 Ubuntu 24.04 x86_64、systemd 255.4 环境运行一个真实 `setsid` 与 reparenting 场景,并覆盖不重放的 Node-shaped spawn failure。Windows native 证据运行一个默认继承 descendant 场景,并覆盖 raw stdin、direct stdout/stderr EOF、direct result 与 Job quiescence 的区别,以及 target spawn failure。shared tests 固定 literal argv、一次性 fallback warning、owner 不可读时拒绝、停稳后不再发 signal、abort 与 host-exit 路由,以及 source、built 和 packaged-executable runner entry。
 
 ## Alternatives considered
 

+ 0 - 21
packages/subprocess/subprocess-local/tests/native-containment.spec.ts

@@ -70,27 +70,6 @@ describe.skipIf(!linuxNative)('Linux user-systemd native containment', () => {
     await waitGone(descendant)
   })
 
-  it('keeps direct outcome separate from a double-fork descendant, then reaps the range', async () => {
-    const pidFile = join(scratch, `double-fork-${Date.now()}.pid`)
-    const script = [
-      'import os, signal, time',
-      'if os.fork() > 0: os._exit(0)',
-      'os.setsid()',
-      'if os.fork() > 0: os._exit(0)',
-      `open(${JSON.stringify(pidFile)}, 'w').write(str(os.getpid()))`,
-      'signal.signal(signal.SIGTERM, signal.SIG_IGN)',
-      'while True: time.sleep(60)',
-    ].join('\n')
-    const request = spec(['python3', '-c', script], 80)
-    const handle = bindManagedProcess(request, launchLinuxScope(request))
-    const descendant = await waitForPid(pidFile)
-    await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
-    await expect(handle.waitForExit(AbortSignal.timeout(30))).resolves.toBe(false)
-    handle.terminate()
-    await expect(handle.waitForExit()).resolves.toBe(true)
-    await waitGone(descendant)
-  })
-
   it('preserves Node-shaped ENOENT and EACCES spawn failures without replay', async () => {
     const missing = spec([`missing-native-target-${Date.now()}`])
     const missingHandle = bindManagedProcess(missing, launchLinuxScope(missing))

+ 1 - 23
packages/subprocess/subprocess-local/tests/native-windows.spec.ts

@@ -96,29 +96,7 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => {
     expect(readFileSync(output, 'utf8')).toBe('after-handshake')
   })
 
-  it('terminates the direct target and its default-inheritance descendant', async () => {
-    const pidFile = join(scratch, `job-child-${Date.now()}.pid`)
-    const script = `
-      const { spawn } = require('node:child_process')
-      const { writeFileSync } = require('node:fs')
-      const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore', detached: true })
-      writeFileSync(${JSON.stringify(pidFile)}, String(child.pid))
-      setInterval(() => {}, 1000)
-    `
-    const request = spec([process.execPath, '-e', script])
-    const handle = bindManagedProcess(request, launchWindowsJob(request))
-    const descendant = await waitForPid(pidFile)
-    try {
-      handle.terminate()
-      await handle.done
-      await expect(handle.waitForExit()).resolves.toBe(true)
-      await waitGone(descendant)
-    } finally {
-      cleanup(descendant)
-    }
-  })
-
-  it('reports direct exit before the inherited descendant leaves the Job', async () => {
+  it('reports direct exit before terminating its default-inheritance descendant', async () => {
     const pidFile = join(scratch, `job-survivor-${Date.now()}.pid`)
     const factsFile = join(scratch, `job-facts-${Date.now()}.json`)
     const script = `

+ 0 - 10
vitest.config.ts

@@ -72,15 +72,6 @@ const windowsOnlyCoverageExclusions = process.platform !== 'win32'
       // executes only on win32; its decision logic is unit-pinned on every
       // host through the injected-internals suites.
       'packages/subprocess/subprocess-local/src/windows-inspector.ts',
-      'packages/subprocess/subprocess-local/src/windows-job.ts',
-    ]
-  : []
-
-const linuxOnlyCoverageExclusions = process.platform !== 'linux'
-  ? [
-      // Native scope ownership executes only on Linux; its command and result
-      // decisions are unit-pinned on every host through injected runners.
-      'packages/subprocess/subprocess-local/src/linux-scope.ts',
     ]
   : []
 
@@ -311,7 +302,6 @@ export default defineConfig({
         ...windowsUnsupportedCoveragePackages.map(path => `${path}/src/**/*.ts`),
         ...windowsOnlyCoverageExclusions,
         ...windowsRunnerCoverageExclusions,
-        ...linuxOnlyCoverageExclusions,
         ...pwshCoverageExclusions,
       ],
       // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome).