Просмотр исходного кода

fix(subprocess): confirm direct SIGKILL after group delivery

Tianyi Cui 1 неделя назад
Родитель
Сommit
c07df5aa65

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-09-12-linux-scope-direct-kill-settlement.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-09-12-linux-scope-direct-kill-settlement.md
-2026-09-12-linux-scope-direct-kill-settlement.md: fa127cb56d1aa561abe2843ab3ff8e65ffdcb848
-2026-09-12-linux-scope-direct-kill-settlement.zh.md: 50d8231e15d81dd0e4bcfa6cb67d08170b46776e
+2026-09-12-linux-scope-direct-kill-settlement.md: d6e24d92338dc2e4a808b558b7e0b1f32614f6d3
+2026-09-12-linux-scope-direct-kill-settlement.zh.md: 0cc9ab4a4b52dc01faa899ee0de1dfb4e08b80d0

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-09-12-linux-scope-direct-kill-settlement.md

@@ -10,7 +10,7 @@ A failed scope signal can precede the exit notification of a direct process that
 
 ## Decision
 
-The [Linux scope owner](../../../../packages/subprocess/subprocess-local/src/linux-scope.ts) retains a failed final scope signal. Successful direct `SIGKILL` submission or independently proven direct-process absence permits one wait for the direct process's exit or launch-error settlement before a fresh scope observation. This event is independent of output draining, startup-error interpretation, and managed-range completion. After rejected direct signaling, a signal-zero probe must report `ESRCH` to establish absence; a surviving process or another probe error permits no such wait.
+The [Linux scope owner](../../../../packages/subprocess/subprocess-local/src/linux-scope.ts) retains a failed final scope signal. A successful process-group signal proves delivery to at least one member, so the direct PID requires its own signal acknowledgement or absence proof. Successful direct `SIGKILL` submission or independently proven direct-process absence permits one wait for the direct process's exit or launch-error settlement before a fresh scope observation. This event is independent of output draining, startup-error interpretation, and managed-range completion. After rejected direct signaling, a signal-zero probe must report `ESRCH` to establish absence; a surviving process or another probe error permits no such wait.
 
 Existing scope-emptiness proofs remain sufficient before direct settlement. When an active scope observation began before direct exit and cannot prove emptiness, the owner consumes the direct settlement wait once and then queries the scope again. An observation begun after direct exit requires no extra wait or query. A surviving range or unknown process count retains the original signal failure. State-query and parsing errors remain failures.
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-09-12-linux-scope-direct-kill-settlement.zh.md

@@ -10,7 +10,7 @@ scope 信号发送失败时,已接受 fallback `SIGKILL` 或已不存在的直
 
 ## 决策
 
-[Linux scope owner](../../../../packages/subprocess/subprocess-local/src/linux-scope.ts) 保留最终 scope 信号的失败。直接 `SIGKILL` 成功提交或直接进程被独立确认已不存在后,owner 可以等待一次直接进程退出或启动错误的完成事件,再取得新的 scope 状态。该事件独立于输出排空、启动错误解释和受管范围的完成。直接信号发送失败后,信号零探测必须报告 `ESRCH` 才能证明进程不存在;进程仍存活或探测报告其他错误时,不允许此等待。
+[Linux scope owner](../../../../packages/subprocess/subprocess-local/src/linux-scope.ts) 保留最终 scope 信号的失败。进程组信号发送成功只证明至少一个成员收到了信号,因此还必须单独确认直接 PID 的信号投递或证明它已不存在。直接 `SIGKILL` 成功提交或直接进程被独立确认已不存在后,owner 可以等待一次直接进程退出或启动错误的完成事件,再取得新的 scope 状态。该事件独立于输出排空、启动错误解释和受管范围的完成。直接信号发送失败后,信号零探测必须报告 `ESRCH` 才能证明进程不存在;进程仍存活或探测报告其他错误时,不允许此等待。
 
 在直接进程停稳前,既有的 scope 为空证明仍足以完成清理。如果 active scope 查询开始时直接进程尚未退出,且该状态不能证明范围为空,owner 仅等待一次直接进程停稳,随后重新查询 scope。直接进程退出后才开始的查询不需要额外等待或再次查询。仍有进程存活或进程数未知时,保留原始信号失败。状态查询和解析错误仍然报错。
 

+ 2 - 2
.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.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/testing/2026-09-10-hosted-image-test-assumptions.md
-2026-09-10-hosted-image-test-assumptions.md: b43f85c5b4f78ef0799290fe51a614cf021ab28a
-2026-09-10-hosted-image-test-assumptions.zh.md: fcee22046faabe97da85d57a4c695fb6a47eebba
+2026-09-10-hosted-image-test-assumptions.md: 40b8f76bf58946ebfe354c9b2d6ea0f32ce83717
+2026-09-10-hosted-image-test-assumptions.zh.md: 4fe8bf2a3764b0f95268e20eb379a69c7e44ced6

+ 2 - 2
.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md

@@ -12,7 +12,7 @@ The [failover leg](../process/2026-09-09-blacksmith-failover-leg.md) runs this s
 
 The cases declare the host properties they depend on.
 
-Terminal cases that drive a mocked PTY exit pin the containment they need (`internals = { platform: 'darwin' }` in `packages/subprocess/subprocess-local/tests/local.spec.ts`); under the host's native scope the mocked exit races the scope bootstrap and fails as `terminal scope exited before its bootstrap consumed the launch request`. Mocking the `linux-scope.ts` probes to reach the same path was removed: the platform pin skips both probes, so the mock could not change the selected path.
+Terminal tests with mocked PTY exits in `packages/subprocess/subprocess-local/tests/local.spec.ts` select fallback containment explicitly. Cases that do not exercise platform selection use `internals = { platform: 'darwin' }`. The terminal-release lifecycle case leaves the platform unset and makes `probeLinuxNative` return false to exercise the host’s default platform selection without starting a real Linux scope. Both arrangements prevent a mocked exit from racing the scope bootstrap. A probe mock alongside the platform pin is redundant because the pin bypasses that probe.
 
 `disposal contains a spawn-failure rejection that races teardown` asserts the settlement contract instead of one winner of the race: a bootstrap that published its pre-exec failure rejects with that failure, and a teardown that stopped the bootstrap first settles as the requested `SIGTERM`. Only the Linux scope records the stopped arm, because the win32 job owner turns a cancelled start into a rejection and the fallback launcher rejects the missing directory.
 
@@ -32,7 +32,7 @@ The Windows folder-dialog smoke probes `CoCreateInstance(CLSID_FileOpenDialog)`
 
 **Raising only the lane's per-test budget.** Rejected: a wider budget does not change the cases whose cost or behaviour is deterministic — a trapped ACP child still waits out both graces, and a coalesced reader still inflates the measured peak.
 
-**Keeping the `linux-scope.ts` probe mocks.** Rejected as inert: the platform pin selects the fallback path before either probe is called, so the mock changed no execution path.
+**Combining a platform pin with probe mocks.** Rejected as redundant: the pin selects fallback before the native probe is called. A the host’s default platform selection test instead leaves the platform unset and controls the probe result.
 
 **Cutting the illegal-UTF-8 payloads to keep the cases fast.** Rejected: below the 2048 bound the assertion can no longer fail for the undercount it names, which leaves the regression unguarded.
 

+ 2 - 2
.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 这些用例明确声明各自依赖的宿主属性。
 
-驱动被 mock 的 PTY 退出的终端用例钉死自己需要的 containment(`packages/subprocess/subprocess-local/tests/local.spec.ts` 中的 `internals = { platform: 'darwin' }`);在宿主的原生 scope 下,被 mock 的退出会与 scope 的 bootstrap 竞争,并以 `terminal scope exited before its bootstrap consumed the launch request` 失败。为走到同一路径而 mock `linux-scope.ts` 的探针已被删除:平台钉死会让两个探针都不被调用,因此该 mock 无法改变选中的路径
+`packages/subprocess/subprocess-local/tests/local.spec.ts` 中模拟 PTY 退出的用例明确选择 fallback containment。不验证平台选择的用例使用 `internals = { platform: 'darwin' }`。终端释放生命周期用例不设置平台,并让 `probeLinuxNative` 返回 false,以验证宿主默认平台的选择,同时避免启动真实 Linux scope。两种安排都避免了模拟退出与 scope bootstrap 竞争。平台固定时再 mock 探针属于冗余,因为固定平台会绕过该探针
 
 `disposal contains a spawn-failure rejection that races teardown` 断言结算契约,而不是这场竞争的某一方获胜:已经发布其 pre-exec 失败的 bootstrap 以该失败 reject,先停住 bootstrap 的 teardown 则以被请求的 `SIGTERM` 结算。只有 Linux scope 会记录停止这一支,因为 win32 job owner 会把被取消的启动转成 rejection,fallback 启动器则因目录缺失而 reject。
 
@@ -32,7 +32,7 @@ Windows 文件夹对话框冒烟测试改为通过 PowerShell 探测 `CoCreateIn
 
 **只抬高通道的每用例预算。** 否决:更宽的预算改变不了那些成本或行为确定的用例——被 trap 的 ACP 子进程仍然会等满两个宽限,被合并的读端仍然会抬高测得的峰值。
 
-**保留 `linux-scope.ts` 的探针 mock。** 因无效而否决:平台钉死会在任一探针被调用前就选中 fallback 路径,因此该 mock 没有改变任何执行路径
+**同时固定平台并 mock 探针。** 因冗余而否决:固定平台会在原生探针被调用前选择 fallback。验证宿主默认平台选择的测试则不设置平台,并控制探针结果
 
 **削减非法 UTF-8 的载荷以让用例更快。** 否决:低于 2048 的界之后,断言再也无法为它所点名的少计而失败,等于让该回归失去守护。
 

+ 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: 7a5d6ecabc759f7f64260698d167e8bcd412d976
-README.zh.md: 808df295e26775b6b319d3d7cc4dd514c08fa435
+README.md: fde2fc413c8f35df3ee633b0def5df8239cdeae1
+README.zh.md: 1e4a065b4879f3418118b90f3ff1153cca9bf5b4

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

@@ -62,6 +62,8 @@ Linux ordinary and terminal cancellation preserves the observed termination sign
 
 ### What can go wrong
 
+After an acknowledged Linux direct-process `SIGKILL`, waiting for its exit has no independent deadline. A process blocked indefinitely in uninterruptible kernel I/O can keep disposal pending; `graceMs` and scope-query polling budgets do not bound this wait.
+
 An executable that cannot be resolved fails loud with a stable error. `done` rejects when spawn or provider failure prevents a direct outcome, and that rejection does not prove whether target execution began. `waitForExit()` rejects if the selected owner can no longer prove its range empty, and cleanup still attempts termination. A read past the retained tail is `lossy` and points at the spill file when one exists. A fallback process group or observed terminal session can miss a descendant that escapes before observation — see the limitations below.
 
 -----

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

@@ -62,6 +62,8 @@ Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取
 
 ### 可能出错的地方
 
+Linux 直接进程的 `SIGKILL` 获得确认后,等待其退出没有独立的截止时间。若进程无限期阻塞在不可中断的内核 I/O 中,dispose 也可能一直等待;`graceMs` 和 scope 查询的轮询预算不限制此等待。
+
 无法解析的可执行文件会明确报出稳定错误。当 spawn 或提供方故障使 direct outcome 无法产生时,`done` 会 reject;该 rejection 不能证明 target 是否已经开始执行。若所选 owner 无法再证明其范围为空,`waitForExit()` 会 reject,清理仍会尝试终止。越过保留尾部的读取是 `lossy` 的,并在 spill 文件存在时指向它。fallback 进程组或已观察终端 session 可能遗漏在观察前逃逸的后代——见下文限制。
 
 -----

+ 1 - 0
packages/subprocess/subprocess-local/src/index.ts

@@ -299,6 +299,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
     )
     this.terminals.add(handle)
     const release = async (): Promise<void> => {
+      // terminate() can wait on this direct-exit promise.
       directSettlement.resolve()
       await handle.terminate()
       this.terminals.delete(handle)

+ 6 - 4
packages/subprocess/subprocess-local/src/linux-scope.ts

@@ -487,11 +487,13 @@ export function signalLinuxDirectProcess(pid: number, send: () => boolean): bool
 }
 
 function signalChildGroup(child: ReturnType<typeof spawn>, signal: 'SIGTERM' | 'SIGKILL'): boolean {
+  let groupSignalled = false
   try {
-    return process.kill(-(child.pid as number), signal)
-  } catch {
-    return signalLinuxDirectProcess(child.pid as number, () => child.kill(signal))
-  }
+    groupSignalled = process.kill(-(child.pid as number), signal)
+  } catch { /* A missing or inaccessible group still permits a direct-process attempt. */ }
+  if (groupSignalled && signal === 'SIGTERM') return true
+  // Group success can reflect another member; joining direct exit requires its own SIGKILL submission.
+  return signalLinuxDirectProcess(child.pid as number, () => child.kill(signal))
 }
 
 /** Linux PTY invocation and owner for the exact one-shot scope/bootstrap. */

+ 48 - 15
packages/subprocess/subprocess-local/tests/linux-scope.spec.ts

@@ -293,7 +293,7 @@ describe('Linux scope establishment and quiescence', () => {
     expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
   })
 
-  it('uses the scope alone after establishment and the direct range only when scope signalling fails', async () => {
+  it.each(['SIGTERM', 'SIGKILL'] as const)('uses the scope alone after establishment and the direct range only when scope %s fails', async (signal) => {
     const spawnSync = vi.fn()
       .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' })
       .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'scope signal failed' })
@@ -306,12 +306,13 @@ describe('Linux scope establishment and quiescence', () => {
     result.owner.signal('SIGTERM')
     expect(processKill).not.toHaveBeenCalled()
 
-    result.owner.signal('SIGKILL')
-    expect(processKill).toHaveBeenCalledExactlyOnceWith(-321, 'SIGKILL')
+    result.owner.signal(signal)
+    expect(processKill).toHaveBeenCalledExactlyOnceWith(-321, signal)
+    expect(child.kills).toEqual(signal === 'SIGKILL' ? ['SIGKILL'] : [])
     expect(spawnSync).toHaveBeenCalledTimes(2)
 
-    child.exit(null, 'SIGKILL')
-    await expect(result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
+    child.exit(null, signal)
+    await expect(result.direct).resolves.toEqual({ exitCode: null, signal })
     result.owner.cleanup?.()
   })
 
@@ -519,9 +520,13 @@ describe('Linux scope establishment and quiescence', () => {
     { state: 'inactive', fresh: activeUnit('inactive'), settles: true },
     { state: 'populated', fresh: activeUnitWithTasks('1'), settles: false },
     { state: 'unknown', fresh: activeUnitWithTasks('[not set]'), settles: false },
-  ].flatMap(value => ['delivered', 'already absent'].map(delivery => ({ ...value, delivery }))))(
-    'joins a $delivery fallback kill before deciding a $state scope', async ({ fresh, settles, delivery }) => {
-      denyProcessGroups()
+  ].flatMap(value => ['delivered', 'already absent'].map(delivery => ({ ...value, delivery })))
+    .flatMap(value => [false, true].map(groupAccepted => ({ ...value, groupAccepted }))))(
+    'joins a $delivery direct kill with groupAccepted=$groupAccepted before deciding a $state scope', async ({ fresh, settles, delivery, groupAccepted }) => {
+      vi.spyOn(process, 'kill').mockImplementation((pid) => {
+        if (pid < 0 && groupAccepted) return true
+        throw Object.assign(new Error('absent'), { code: 'ESRCH' })
+      })
       const firstRead = Promise.withResolvers<ReturnType<typeof activeUnit>>()
       const queried = Promise.withResolvers<undefined>()
       const query = vi.fn()
@@ -533,10 +538,8 @@ describe('Linux scope establishment and quiescence', () => {
       const launched = launch(query, { sleep, spawnSync: spawnSync as never }, {
         ...spec(), stdio: { ...spec().stdio, control: 'pipe' },
       })
-      if (delivery === 'already absent') {
-        vi.spyOn(launched.child, 'kill').mockReturnValue(false)
-        vi.spyOn(process, 'kill').mockImplementation(() => { throw Object.assign(new Error('absent'), { code: 'ESRCH' }) })
-      }
+      const directKill = vi.spyOn(launched.child, 'kill')
+      if (delivery === 'already absent') directKill.mockReturnValue(false)
       consumeLinuxLaunchRequest(launched.requestPath)
       launched.result.owner.signal('SIGKILL')
       let completed = false
@@ -547,6 +550,7 @@ describe('Linux scope establishment and quiescence', () => {
         firstRead.resolve(activeUnitWithTasks('1'))
         await new Promise<void>(resolve => setImmediate(resolve))
         expect(completed).toBe(false)
+        expect(directKill).toHaveBeenCalledExactlyOnceWith('SIGKILL')
         expect(query).toHaveBeenCalledOnce()
         expect(sleep).not.toHaveBeenCalled()
         launched.child.exit(null, 'SIGKILL')
@@ -588,6 +592,35 @@ describe('Linux scope establishment and quiescence', () => {
     }
   })
 
+  it.each(['live', 'permission denied'])('reports scope failure when another group member accepts the kill and the direct PID probe is %s', async (probe) => {
+    const processKill = vi.spyOn(process, 'kill').mockImplementation((pid) => {
+      if (pid < 0 || probe === 'live') return true
+      throw Object.assign(new Error('direct process permission denied'), { code: 'EPERM' })
+    })
+    const query = vi.fn(async () => activeUnitWithTasks('1'))
+    const launched = launch(query, {
+      spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'scope permission denied' })) as never,
+    })
+    const directKill = vi.spyOn(launched.child, 'kill').mockReturnValue(false)
+    consumeLinuxLaunchRequest(launched.requestPath)
+    launched.result.owner.signal('SIGKILL')
+    let failure: unknown
+    const waiting = launched.result.owner.waitForExit().catch((error: unknown) => { failure = error })
+    try {
+      await new Promise<void>(resolve => setImmediate(resolve))
+      expect(failure).toHaveProperty('message', expect.stringContaining('scope permission denied'))
+      expect(launched.child.signalCode).toBeNull()
+      expect(query).toHaveBeenCalledOnce()
+      expect(directKill).toHaveBeenCalledExactlyOnceWith('SIGKILL')
+      expect(processKill.mock.calls).toEqual([[-321, 'SIGKILL'], [321, 0]])
+    } finally {
+      launched.child.exit(null, 'SIGKILL')
+      await launched.result.direct
+      await waiting
+      launched.result.owner.cleanup?.()
+    }
+  })
+
   it('reports a fresh surviving range immediately when direct exit precedes its query', async () => {
     denyProcessGroups()
     const query = vi.fn(async () => activeUnitWithTasks('1'))
@@ -876,15 +909,15 @@ describe('Linux scope establishment and quiescence', () => {
     result.owner.cleanup?.()
   })
 
-  it('runs direct fallback before the exact synchronous scope kill on host exit', () => {
+  it('signals the group and direct process before the exact synchronous scope kill on host exit', () => {
     const events: string[] = []
     const { child, result } = launch(async () => missingUnit(), {
       spawnSync: vi.fn(() => { events.push('scope'); return { status: 0 } }) as never,
     })
     child.kill = vi.fn(() => { events.push('direct'); return true })
-    vi.spyOn(process, 'kill').mockImplementation(() => { events.push('direct'); return true })
+    vi.spyOn(process, 'kill').mockImplementation(() => { events.push('group'); return true })
     result.owner.terminateForHostExit()
-    expect(events).toEqual(['direct', 'scope'])
+    expect(events).toEqual(['group', 'direct', 'scope'])
     result.owner.cleanup?.()
   })
 })