fix(subprocess): preserve direct outcomes on signal failure
This commit is contained in:
1 parent
771dd49a06
commit
71334b9c78
10 files changed
+66
-45
No files matched your search
+2
-2
@@ -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: d6e24d92338dc2e4a808b558b7e0b1f32614f6d3
|
||||
2026-09-12-linux-scope-direct-kill-settlement.zh.md: 0cc9ab4a4b52dc01faa899ee0de1dfb4e08b80d0
|
||||
2026-09-12-linux-scope-direct-kill-settlement.md: d66582ad080c4b22c07d4a78f708d95aaf286da3
|
||||
2026-09-12-linux-scope-direct-kill-settlement.zh.md: a50cfeb89ec641f0696590a5092029b5898ae4d8
|
||||
@@ -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. 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.
|
||||
The [Linux scope owner](../../../../packages/subprocess/subprocess-local/src/linux-scope.ts) retains a failed final scope signal. A successful process-group `SIGKILL` proves delivery to at least one member, so the direct PID requires its own signal acknowledgement or absence proof. A successful group `SIGTERM` remains a single delivery to avoid repeating a catchable TERM handler; its acknowledgement does not authorize the final-kill settlement wait. 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
@@ -10,7 +10,7 @@ scope 信号发送失败时,已接受 fallback `SIGKILL` 或已不存在的直
|
||||
|
||||
## 决策
|
||||
|
||||
[Linux scope owner](../../../../packages/subprocess/subprocess-local/src/linux-scope.ts) 保留最终 scope 信号的失败。进程组信号发送成功只证明至少一个成员收到了信号,因此还必须单独确认直接 PID 的信号投递或证明它已不存在。直接 `SIGKILL` 成功提交或直接进程被独立确认已不存在后,owner 可以等待一次直接进程退出或启动错误的完成事件,再取得新的 scope 状态。该事件独立于输出排空、启动错误解释和受管范围的完成。直接信号发送失败后,信号零探测必须报告 `ESRCH` 才能证明进程不存在;进程仍存活或探测报告其他错误时,不允许此等待。
|
||||
[Linux scope owner](../../../../packages/subprocess/subprocess-local/src/linux-scope.ts) 保留最终 scope 信号的失败。进程组 `SIGKILL` 发送成功只证明至少一个成员收到了信号,因此还必须单独确认直接 PID 的信号投递或证明它已不存在。进程组 `SIGTERM` 成功后保持单次投递,避免重复触发可捕获的 TERM handler;该确认不允许进入最终 kill 的停稳等待。直接 `SIGKILL` 成功提交或直接进程被独立确认已不存在后,owner 可以等待一次直接进程退出或启动错误的完成事件,再取得新的 scope 状态。该事件独立于输出排空、启动错误解释和受管范围的完成。直接信号发送失败后,信号零探测必须报告 `ESRCH` 才能证明进程不存在;进程仍存活或探测报告其他错误时,不允许此等待。
|
||||
|
||||
在直接进程停稳前,既有的 scope 为空证明仍足以完成清理。如果 active scope 查询开始时直接进程尚未退出,且该状态不能证明范围为空,owner 仅等待一次直接进程停稳,随后重新查询 scope。直接进程退出后才开始的查询不需要额外等待或再次查询。仍有进程存活或进程数未知时,保留原始信号失败。状态查询和解析错误仍然报错。
|
||||
|
||||
|
||||
+1
-1
@@ -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: 40b8f76bf58946ebfe354c9b2d6ea0f32ce83717
|
||||
2026-09-10-hosted-image-test-assumptions.md: e6fe4e35848c48462e7bb9c99bc898b6ebc907c9
|
||||
2026-09-10-hosted-image-test-assumptions.zh.md: 4fe8bf2a3764b0f95268e20eb379a69c7e44ced6
|
||||
@@ -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 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.
|
||||
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.
|
||||
|
||||
**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.
|
||||
**Combining a platform pin with probe mocks.** Rejected as redundant: the pin selects fallback before the native probe is called. A test of the host's default platform selection 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,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: fde2fc413c8f35df3ee633b0def5df8239cdeae1
|
||||
README.zh.md: 1e4a065b4879f3418118b90f3ff1153cca9bf5b4
|
||||
README.md: 7673b72a3afeef7f411c7e11bfacdc65ead299be
|
||||
README.zh.md: 7960e9e75cf99444a91bed421621a9df36872cc7
|
||||
@@ -62,8 +62,6 @@ 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.
|
||||
|
||||
-----
|
||||
@@ -139,6 +137,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
|
||||
|
||||
These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog.
|
||||
|
||||
- **Linux direct exit has no independent deadline** — after direct `SIGKILL` is acknowledged, uninterruptible kernel I/O can keep disposal pending indefinitely; `graceMs` and scope-query polling budgets do not bound this wait.
|
||||
- **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 selection has bounded per-spawn costs** — Linux repeats the bootstrap entry, libc `execve`/`fcntl` bindings, live user manager, and literal-argv scope probe until it first succeeds; later eligible ordinary or terminal spawns recheck only the live user manager. Windows rechecks the runner entry, bindings, and current Job support before every ordinary spawn. Successful Linux deep-probe state and fallback-warning de-duplication persist for the provider lifetime. All probes finish before the user command can run, and child-process probes have a 5-second timeout. Each Linux launch creates a private request directory, checks unresolved scope establishment every 50 milliseconds, then exponentially backs off an established active scope to at most 5 seconds between queries; a Windows ordinary launch keeps one runner and IPC channel until the Job reports zero active processes. Target standard handles are inherited directly, with no named-pipe stdio or result files.
|
||||
- **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.
|
||||
|
||||
@@ -62,8 +62,6 @@ Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取
|
||||
|
||||
### 可能出错的地方
|
||||
|
||||
Linux 直接进程的 `SIGKILL` 获得确认后,等待其退出没有独立的截止时间。若进程无限期阻塞在不可中断的内核 I/O 中,dispose 也可能一直等待;`graceMs` 和 scope 查询的轮询预算不限制此等待。
|
||||
|
||||
无法解析的可执行文件会明确报出稳定错误。当 spawn 或提供方故障使 direct outcome 无法产生时,`done` 会 reject;该 rejection 不能证明 target 是否已经开始执行。若所选 owner 无法再证明其范围为空,`waitForExit()` 会 reject,清理仍会尝试终止。越过保留尾部的读取是 `lossy` 的,并在 spill 文件存在时指向它。fallback 进程组或已观察终端 session 可能遗漏在观察前逃逸的后代——见下文限制。
|
||||
|
||||
-----
|
||||
@@ -139,6 +137,7 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目
|
||||
|
||||
这些限制说明本提供方何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用平台对比或任务积压。
|
||||
|
||||
- **Linux 直接进程退出没有独立截止时间**——直接 `SIGKILL` 获得确认后,不可中断的内核 I/O 可能让 dispose 无限期等待;`graceMs` 和 scope 查询的轮询预算不限制此等待。
|
||||
- **native ownership 有明确宿主要求**——Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。
|
||||
- **native 选择具有有界的每次 spawn 成本**——Linux 会重复检查 bootstrap 入口、libc `execve`/`fcntl` bindings、存活的 user manager 与 literal-argv scope 支持,直到这套完整探测首次成功;后续符合条件的普通命令或终端 spawn 只重新检查存活的 user manager。Windows 会在每次普通 spawn 前重新检查 runner 入口、bindings 与当前 Job 支持。Linux 深度探测的成功状态与 fallback 告警去重会在提供方生命周期内持续保留。所有探测都会在用户命令可能运行前完成,子进程探测的超时为 5 秒。每次 Linux 启动都会创建私有请求目录,以 50 毫秒间隔检查尚未确定的 scope 建立状态;scope 已建立且仍 active 后,查询间隔按指数增长,最多为 5 秒。Windows 普通命令会保留一个 runner 与一条 IPC 通道,直到 Job 报告活动进程数为零。目标会直接继承标准句柄,不使用 named-pipe stdio 或结果文件。
|
||||
- **Windows Job inheritance 有明确排除项**——普通后代默认继承 Job,但 breakaway 进程不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。
|
||||
|
||||
@@ -157,7 +157,7 @@ export function probeLinuxNative(internals: LinuxScopeInternals = {}): boolean {
|
||||
|
||||
interface DirectRange {
|
||||
running(): boolean
|
||||
/** Report successful delivery or proven direct-process absence. */
|
||||
/** Acknowledge TERM delivery; KILL must confirm direct-PID delivery or absence. */
|
||||
signal(signal: 'SIGTERM' | 'SIGKILL'): boolean
|
||||
/** Direct exit/error settlement, independent of output drain and managed-range completion. */
|
||||
settled: Promise<unknown>
|
||||
@@ -493,7 +493,7 @@ function signalChildGroup(child: ReturnType<typeof spawn>, signal: 'SIGTERM' | '
|
||||
} 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))
|
||||
return signalLinuxDirectProcess(child.pid as number, () => process.kill(child.pid as number, signal))
|
||||
}
|
||||
|
||||
/** Linux PTY invocation and owner for the exact one-shot scope/bootstrap. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { existsSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import {
|
||||
launchLinuxScope,
|
||||
@@ -62,6 +62,8 @@ class FakeChild extends EventEmitter {
|
||||
|
||||
const directories: string[] = []
|
||||
|
||||
beforeEach(() => { denyProcessGroups() })
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of directories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
@@ -92,9 +94,12 @@ function activeUnitWithTasks(tasks: string) {
|
||||
return { status: 0, stdout: `LoadState=loaded\nActiveState=active\nTasksCurrent=${tasks}\n`, stderr: '' }
|
||||
}
|
||||
|
||||
/** Deny every real process-group signal so a fake child never reaches a live host group. */
|
||||
/** Reject group delivery and accept direct-PID signals without reaching the host. */
|
||||
function denyProcessGroups(): void {
|
||||
vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('missing process group') })
|
||||
vi.spyOn(process, 'kill').mockImplementation((pid) => {
|
||||
if (pid < 0) throw new Error('missing process group')
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** Record the systemctl invocations a scope owner makes, succeeding unless a case overrides it. */
|
||||
@@ -239,11 +244,12 @@ describe('Linux native capability selection', () => {
|
||||
describe('Linux scope establishment and quiescence', () => {
|
||||
it.each(['abort', 'terminate'] as const)('settles and cleans a managed handle after early %s', async (action) => {
|
||||
const { child, result, requestPath } = launch(async () => missingUnit())
|
||||
const processKill = vi.spyOn(process, 'kill')
|
||||
const controller = new AbortController()
|
||||
const handle = bindManagedProcess({ ...spec(), signal: controller.signal }, result)
|
||||
if (action === 'abort') controller.abort(new Error('cancelled'))
|
||||
else handle.terminate()
|
||||
expect(child.kills).toEqual(['SIGTERM'])
|
||||
expect(processKill).toHaveBeenCalledWith(321, 'SIGTERM')
|
||||
child.exit(null, 'SIGTERM')
|
||||
child.stdout.end()
|
||||
child.stderr.end()
|
||||
@@ -266,9 +272,10 @@ describe('Linux scope establishment and quiescence', () => {
|
||||
|
||||
it('does not mistake pre-establishment unit absence for quiescence and settles an empty range after cancellation', async () => {
|
||||
const { child, result, requestPath, spawnSync } = launch(async () => missingUnit())
|
||||
const processKill = vi.spyOn(process, 'kill')
|
||||
const waiting = result.owner.waitForExit()
|
||||
result.owner.signal('SIGTERM')
|
||||
expect(child.kills).toEqual(['SIGTERM'])
|
||||
expect(processKill).toHaveBeenCalledWith(321, 'SIGTERM')
|
||||
expect(spawnSync).toHaveBeenCalledWith('/bin/systemctl', expect.arrayContaining([
|
||||
'kill', '--kill-whom=all', '--signal=SIGTERM',
|
||||
]), expect.anything())
|
||||
@@ -307,8 +314,10 @@ describe('Linux scope establishment and quiescence', () => {
|
||||
expect(processKill).not.toHaveBeenCalled()
|
||||
|
||||
result.owner.signal(signal)
|
||||
expect(processKill).toHaveBeenCalledExactlyOnceWith(-321, signal)
|
||||
expect(child.kills).toEqual(signal === 'SIGKILL' ? ['SIGKILL'] : [])
|
||||
expect(processKill.mock.calls).toEqual(signal === 'SIGKILL'
|
||||
? [[-321, signal], [321, signal]]
|
||||
: [[-321, signal]])
|
||||
expect(child.kills).toEqual([])
|
||||
expect(spawnSync).toHaveBeenCalledTimes(2)
|
||||
|
||||
child.exit(null, signal)
|
||||
@@ -451,8 +460,7 @@ describe('Linux scope establishment and quiescence', () => {
|
||||
const killFailed = launch(async () => activeUnit(), {
|
||||
spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'permission denied' })) as never,
|
||||
})
|
||||
denyProcessGroups()
|
||||
vi.spyOn(killFailed.child, 'kill').mockReturnValue(false)
|
||||
vi.spyOn(process, 'kill').mockImplementation(() => { throw Object.assign(new Error('denied'), { code: 'EPERM' }) })
|
||||
killFailed.result.owner.signal('SIGKILL')
|
||||
await expect(killFailed.result.owner.waitForExit()).rejects.toThrow('could not signal')
|
||||
killFailed.result.owner.cleanup?.()
|
||||
@@ -523,8 +531,8 @@ describe('Linux scope establishment and quiescence', () => {
|
||||
].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
|
||||
const processKill = vi.spyOn(process, 'kill').mockImplementation((pid) => {
|
||||
if ((pid < 0 && groupAccepted) || (pid > 0 && delivery === 'delivered')) return true
|
||||
throw Object.assign(new Error('absent'), { code: 'ESRCH' })
|
||||
})
|
||||
const firstRead = Promise.withResolvers<ReturnType<typeof activeUnit>>()
|
||||
@@ -538,8 +546,6 @@ describe('Linux scope establishment and quiescence', () => {
|
||||
const launched = launch(query, { sleep, spawnSync: spawnSync as never }, {
|
||||
...spec(), stdio: { ...spec().stdio, control: 'pipe' },
|
||||
})
|
||||
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
|
||||
@@ -550,7 +556,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(processKill).toHaveBeenCalledWith(321, 'SIGKILL')
|
||||
expect(query).toHaveBeenCalledOnce()
|
||||
expect(sleep).not.toHaveBeenCalled()
|
||||
launched.child.exit(null, 'SIGKILL')
|
||||
@@ -573,13 +579,12 @@ describe('Linux scope establishment and quiescence', () => {
|
||||
)
|
||||
|
||||
it('reports failed scope and direct kill submission without awaiting direct exit', async () => {
|
||||
denyProcessGroups()
|
||||
vi.spyOn(process, 'kill').mockImplementation(() => { throw Object.assign(new Error('denied'), { code: 'EPERM' }) })
|
||||
const query = vi.fn(async () => activeUnitWithTasks('1'))
|
||||
const launched = launch(query, {
|
||||
spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'permission denied' })) as never,
|
||||
})
|
||||
consumeLinuxLaunchRequest(launched.requestPath)
|
||||
vi.spyOn(launched.child, 'kill').mockReturnValue(false)
|
||||
launched.result.owner.signal('SIGKILL')
|
||||
try {
|
||||
await expect(launched.result.owner.waitForExit()).rejects.toThrow('permission denied')
|
||||
@@ -592,30 +597,47 @@ 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' })
|
||||
it.each(['live', 'EPERM', 'ESRCH'].flatMap(probe => [
|
||||
{ probe, exitCode: 23, signal: null },
|
||||
{ probe, exitCode: null, signal: 'SIGKILL' as const },
|
||||
]))('preserves the eventual direct exit $exitCode / $signal after a denied kill and a $probe PID probe', async ({ probe, exitCode, signal }) => {
|
||||
const signalFailure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' })
|
||||
const processKill = vi.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid < 0 || (signal === 0 && probe === 'live')) return true
|
||||
if (signal === 0) throw Object.assign(new Error(probe), { code: probe })
|
||||
throw signalFailure
|
||||
})
|
||||
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)
|
||||
vi.spyOn(launched.child, 'kill').mockImplementation(() => {
|
||||
launched.child.emit('error', signalFailure)
|
||||
return false
|
||||
})
|
||||
let directSettled = false
|
||||
const direct = launched.result.direct.finally(() => { directSettled = true })
|
||||
void direct.catch(() => {})
|
||||
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(directSettled).toBe(false)
|
||||
if (probe === 'ESRCH') expect(failure).toBeUndefined()
|
||||
else 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]])
|
||||
expect(processKill.mock.calls).toEqual([[-321, 'SIGKILL'], [321, 'SIGKILL'], [321, 0]])
|
||||
launched.child.exit(exitCode, signal)
|
||||
await expect(direct).resolves.toEqual({ exitCode, signal })
|
||||
await waiting
|
||||
expect(failure).toHaveProperty('message', expect.stringContaining('scope permission denied'))
|
||||
expect(query).toHaveBeenCalledTimes(probe === 'ESRCH' ? 2 : 1)
|
||||
} finally {
|
||||
launched.child.exit(null, 'SIGKILL')
|
||||
await launched.result.direct
|
||||
launched.child.exit(exitCode, signal)
|
||||
await direct.catch(() => {})
|
||||
await waiting
|
||||
launched.result.owner.cleanup?.()
|
||||
}
|
||||
@@ -702,7 +724,9 @@ describe('Linux scope establishment and quiescence', () => {
|
||||
const spawnSync = recordingSystemctl()
|
||||
.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'Invalid argument' })
|
||||
const launched = launch(async () => activeUnitWithTasks(tasks), { spawnSync: spawnSync as never })
|
||||
if (clientRunning) vi.spyOn(launched.child, 'kill').mockReturnValue(false)
|
||||
if (clientRunning) {
|
||||
vi.spyOn(process, 'kill').mockImplementation(() => { throw Object.assign(new Error('denied'), { code: 'EPERM' }) })
|
||||
}
|
||||
if (!clientRunning) consumeLinuxLaunchRequest(launched.requestPath)
|
||||
launched.result.owner.signal('SIGKILL')
|
||||
if (!clientRunning) launched.child.exit(null, 'SIGKILL')
|
||||
@@ -911,11 +935,10 @@ describe('Linux scope establishment and quiescence', () => {
|
||||
|
||||
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(), {
|
||||
const { 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('group'); return true })
|
||||
vi.spyOn(process, 'kill').mockImplementation((pid) => { events.push(pid < 0 ? 'group' : 'direct'); return true })
|
||||
result.owner.terminateForHostExit()
|
||||
expect(events).toEqual(['group', 'direct', 'scope'])
|
||||
result.owner.cleanup?.()
|
||||
|
||||
Reference in new issue
Block a user