Merge remote-tracking branch 'origin/worktree-process-service-seam' into subprocess-simpl/d-one-env-channel

# Conflicts:
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/subprocess.i18n.yaml
#	packages/lsp/lsp-local/src/connection.ts
#	packages/subagent/subagent-acp/README.i18n.yaml
#	packages/subprocess/subprocess-local/README.i18n.yaml
#	packages/subprocess/subprocess-local/src/spawn.ts
#	packages/subprocess/subprocess/README.i18n.yaml
#	packages/subprocess/subprocess/README.md
#	packages/subprocess/subprocess/README.zh.md
#	packages/subprocess/subprocess/tests/service.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-27 11:31:38 +08:00
40 changed files with 288 additions and 428 deletions

View File

@@ -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
README.md: a62929e8ee1c1c852d99d9569f4118bc751e8150
README.zh.md: 99febdbe54988f54b74dbc2c80251ec1e9e671ef
README.md: a4e20b5c64afd14cb9dce9f8cc46fa96a1f5f19d
README.zh.md: 2bd7ebe7c3407a4ad6a86c520594465f7f1e710c

View File

@@ -6,7 +6,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README
## Behavior (and where it came from)
- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID <pid> /T /F` (injectable for tests). `terminate()` sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent); `kill(signal)` sends exactly one signal and is a no-op after settlement; `dispose(graces)` runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools.
- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID <pid> /T /F` (injectable for tests). `terminate()` — the handle's only termination verb — sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent) and is a no-op once the tree is gone; `waitForExit()` polls whole-tree liveness so consumer teardown confirms real quiescence. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools.
- **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*`/`*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-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement.

View File

@@ -6,7 +6,7 @@
## 行为(以及设计来源)
- **带平台正确信号发送的 detached 进程树**POSIX 子进程使用 `detached` spawn拥有独立进程组信号以负 pgid 发送并以直接子进程作为回退Windows 通过 `taskkill /PID <pid> /T /F` 终止进程树(可为测试注入)。`terminate()` 先发送 SIGTERM经过 spec 的宽限期后再发送 SIGKILL沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束)`kill(signal)` 恰好发送一个信号,结算后为空操作;`dispose(graces)` 以调用方提供的时间窗运行 stdin EOF→SIGTERM→SIGKILL 阶梯dispose资源释放按句柄 memoize 化、只执行一次。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。
- **带平台正确信号发送的 detached 进程树**POSIX 子进程使用 `detached` spawn拥有独立进程组信号以负 pgid 发送并以直接子进程作为回退Windows 通过 `taskkill /PID <pid> /T /F` 终止进程树(可为测试注入)。`terminate()`(句柄唯一的终止动词)先发送 SIGTERM经过 spec 的宽限期后再发送 SIGKILL沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束),进程树消亡后为空操作;`waitForExit()` 轮询整棵进程树的存活状态,使消费方的拆卸能确认真正的完全停稳。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。
- **按流划分的处置方式**`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符收集模式collect在输出超过上限后于内存中保留尾部错误与结果通常聚集在末尾沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留尾部,即诊断尾部的形状。某条流大于 spill 上限时,会丢弃已不完整的 spill仅返回带截断标记的尾部spill 文件描述符在结算时封存最终关闭失败时则不公布路径以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。
- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*``*SECRET*``*TOKEN*`)和所有环境中已有的 `DSH_*` 名称spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **基于偏移量的读取**收集模式的读取器以全流字节坐标返回增量服务自身从不持有游标因此消费方自有的游标bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。

View File

@@ -29,13 +29,11 @@
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -16,8 +16,8 @@ import type { SpawnInternals } from './spawn.ts'
/**
* Local subprocess service: detached process trees, Node-shaped stdio
* dispositions (raw pipes, inherit, bounded tail-keep collection with spill
* files), credential-scrubbed environment, tree-scoped signalling with
* SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder.
* files), credential-scrubbed environment, and tree-scoped signalling with
* SIGTERM→grace→SIGKILL escalation.
*/
export class LocalSubprocessService extends SubprocessService {
/** Live handles retained only so disposal can terminate and join them. */

View File

@@ -1,10 +1,9 @@
/**
* Process plumbing for the local subprocess service: detached process-tree
* spawn with per-stream stdio dispositions, tail-keep collection with spill
* files, tree-scoped signalling (POSIX groups; Windows taskkill), the
* SIGTERM→SIGKILL escalation, and the cooperative EOF-first dispose ladder.
* This layer reacts to an abort signal; callers own deadlines and classify
* causes.
* files, tree-scoped signalling (POSIX groups; Windows taskkill), and the
* SIGTERM→SIGKILL escalation. This layer reacts to an abort signal; callers
* own deadlines, teardown ladders, and cause classification.
* @module dsh-subprocess-local/spawn
*/
@@ -15,12 +14,10 @@ import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setTimeout as sleepMs } from 'node:timers/promises'
import { deadline } from '@deepseek-ai/dsh-timeout'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type {
CollectedOutput,
SubprocessCollect,
SubprocessDisposeGraces,
SubprocessHandle,
SubprocessOutcome,
SubprocessOutputMode,
@@ -48,9 +45,6 @@ export interface SpawnInternals {
platform?: NodeJS.Platform
}
/** Timeout code marking a dispose-ladder tier bound (vs an external abort). */
const DISPOSE_TIER_TIMEOUT = 'SUBPROCESS_DISPOSE_TIER'
/**
* Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an
* awaited teardown must keep the event loop alive until the tree really
@@ -377,11 +371,12 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
}
}
const kill = (sig: NodeJS.Signals = 'SIGTERM'): void => {
// Guard on TREE liveness, not outcome settlement: a TERM-trapping helper
// can outlive the settled direct child and must stay signalable, while a
// fully-dead tree (possible pid reuse) must not be re-signalled from a
// caller's finally block.
// The escalation's tier primitive (not on the handle — terminate() is the
// only consumer-facing termination verb). Guards on TREE liveness, not
// outcome settlement: a TERM-trapping helper can outlive the settled direct
// child and must stay signalable, while a fully-dead tree (possible pid
// reuse) must not be re-signalled by a later tier.
const kill = (sig: NodeJS.Signals): void => {
if (!treeAlive()) return
signalTree(platform, pid, sig, child, taskkill)
}
@@ -389,15 +384,13 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
const terminate = (): void => {
if (graceTimer !== undefined) return // escalation already in flight
if (!treeAlive()) return
signalTree(platform, pid, 'SIGTERM', child, taskkill)
kill('SIGTERM')
// The escalation must survive direct-child settlement — the leader dying
// does not mean the tree died — so settle does not clear this timer, and
// it re-probes tree liveness before force-killing. It stays ref'd: the
// pending SIGKILL is a commitment, and a parent exiting before it fires
// would orphan a trapped survivor. Self-bounds at graceMs.
graceTimer = setTimeout(() => {
if (treeAlive()) signalTree(platform, pid, 'SIGKILL', child, taskkill)
}, spec.graceMs)
// kill() re-probes tree liveness before force-killing. It stays ref'd:
// the pending SIGKILL is a commitment, and a parent exiting before it
// fires would orphan a trapped survivor. Self-bounds at graceMs.
graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs)
}
// The caller owns timeout classification; this layer only reacts to abort.
@@ -454,39 +447,6 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
return true
}
/**
* Wait, bounded, for whole-tree exit — the dispose ladder's quiescence test.
* Tree liveness, not direct-child settlement: a TERM-trapping helper that
* outlives the leader must hold the ladder on its tier until it exits.
*/
const treeExitsWithin = async (ms: number): Promise<boolean> => {
using bound = deadline(undefined, ms, DISPOSE_TIER_TIMEOUT)
return await waitForExit(bound.signal)
}
let disposal: Promise<void> | undefined
const dispose = (graces: SubprocessDisposeGraces): Promise<void> => (disposal ??= (async () => {
// A spawn failure has no process to tear down; observe the rejection so
// disposal in a finally block cannot surface it as unhandled.
if (pid <= 0) {
await done.catch(() => {})
return
}
// 1. Close a piped stdin and allow cooperative teardown and flush.
if (stdinMode === 'pipe') child.stdin?.end()
if (await treeExitsWithin(graces.eofGraceMs)) return
// 2. POSIX gets a catchable graceful signal; Windows taskkill force-terminates.
if (platform !== 'win32') {
kill('SIGTERM')
if (await treeExitsWithin(graces.graceMs)) return
}
// 3. Force-kill the tree and await a bounded exit edge.
kill('SIGKILL')
if (!(await treeExitsWithin(graces.graceMs))) {
throw new Error(`child process tree did not exit within ${graces.graceMs}ms after forced termination`)
}
})())
return {
pid,
/* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */
@@ -499,9 +459,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
...stderrCollector !== undefined ? { stderr: stderrCollector } : {},
},
done,
kill,
terminate,
waitForExit,
dispose,
}
}

View File

@@ -164,26 +164,15 @@ describe('spawnSubprocess', () => {
expect(result.signal).toBe('SIGKILL')
})
it('kill() sends one signal Node-style, without escalation', async () => {
const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60', { graceMs: 100 }))
await waitForStdout(running, 'armed\n')
running.kill() // trapped SIGTERM, no SIGKILL follow-up
await new Promise(resolve => setTimeout(resolve, 400))
expect(running.collected.stdout).toBeDefined()
running.kill('SIGKILL') // explicit signal choice, still no timers
const result = await running.done
expect(result.signal).toBe('SIGKILL')
})
it('kills the whole process group (grandchildren die too)', async () => {
// The subshell writes the sleep's pid then waits on it; killing the
it('terminates the whole process group (grandchildren die too)', async () => {
// The subshell writes the sleep's pid then waits on it; terminating the
// group must take the sleep down with bash.
const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`)
const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
const grandchild = await waitForPidFile(pidFile)
expect(grandchild).toBeGreaterThan(0)
running.kill()
running.terminate()
const result = await running.done
expect(result.signal).toBe('SIGTERM')
await waitGone(grandchild)
@@ -451,22 +440,6 @@ describe('killGroup', () => {
expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow()
})
it('handle.kill() after the tree died delivers no termination signal', async () => {
// Cleanup code commonly kills handles in a finally; once the tree is gone
// the pid may be reused, so a late kill must deliver nothing (the
// liveness PROBE — signal 0 — is the only process.kill allowed).
const running = spawnSubprocess(spec('true'))
await running.done
await running.waitForExit()
const spy = vi.spyOn(process, 'kill')
try {
running.kill()
const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0)
expect(delivered).toEqual([])
} finally {
spy.mockRestore()
}
})
})
describe('stdio dispositions', () => {
@@ -505,42 +478,8 @@ describe('stdio dispositions', () => {
})
})
describe('dispose ladder', () => {
it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => {
const running = spawnSubprocess({
...spec('read -r line; exit 0'),
stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
})
await running.dispose({ eofGraceMs: 5_000, graceMs: 200 })
const outcome = await running.done
expect(outcome.exitCode).toBe(0)
expect(outcome.signal).toBeNull()
})
it('tier 2: an EOF-deaf child dies by SIGTERM', async () => {
const running = spawnSubprocess({
...spec('sleep 60'),
stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
})
await running.dispose({ eofGraceMs: 100, graceMs: 5_000 })
const outcome = await running.done
expect(outcome.signal).toBe('SIGTERM')
})
it('tier 3: a TERM-trapping child dies by SIGKILL, and dispose() is idempotent', async () => {
const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60'))
await waitForStdout(running, 'armed\n')
const first = running.dispose({ eofGraceMs: 50, graceMs: 200 })
const second = running.dispose({ eofGraceMs: 50, graceMs: 200 })
expect(second).toBe(first)
await first
const outcome = await running.done
expect(outcome.signal).toBe('SIGKILL')
})
})
describe('windows tree semantics (injected platform)', () => {
it('kill and terminate route through taskkill by root pid', async () => {
it('terminate routes through taskkill by root pid', async () => {
const killed: number[] = []
const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), {
spillDir,
@@ -589,7 +528,7 @@ describe('waitForExit', () => {
})
})
describe('tree-survivor escalation (terminate/dispose reach helpers the leader left behind)', () => {
describe('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => {
it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => {
// The leader spawns a TERM-trapping helper with all stdio detached from
// the collected pipes, then exits: the helper holds the GROUP alive while
@@ -608,18 +547,21 @@ describe('tree-survivor escalation (terminate/dispose reach helpers the leader l
await waitGone(helper)
})
it('dispose() holds each tier on whole-tree exit, not direct-child settlement', async () => {
const pidFile = join(spillDir, `survivor-dispose-${Date.now()}.pid`)
it('a bounded waitForExit reports false while a survivor lives, true after escalation', async () => {
const pidFile = join(spillDir, `survivor-wait-${Date.now()}.pid`)
const running = spawnSubprocess(spec(
`bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; exit 0`,
{ graceMs: 200 },
))
const helper = await waitForPidFile(pidFile)
await running.done
expect(() => process.kill(helper, 0)).not.toThrow()
await running.dispose({ eofGraceMs: 100, graceMs: 300 })
// The ladder only returns once the WHOLE tree is gone.
// A consumer-owned teardown tier bounds its wait and reads the verdict.
const bound = new AbortController()
const timer = setTimeout(() => { bound.abort() }, 100)
await expect(running.waitForExit(bound.signal)).resolves.toBe(false)
clearTimeout(timer)
running.terminate()
await expect(running.waitForExit()).resolves.toBe(true)
expect(() => process.kill(helper, 0)).toThrow()
})
@@ -652,11 +594,10 @@ describe('coverage seams', () => {
expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow()
})
it('dispose on a spawn-failed handle observes the rejection and returns', async () => {
it('a spawn-failed handle rejects done while waitForExit reports gone', async () => {
const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' }))
const disposal = running.dispose({ eofGraceMs: 1_000, graceMs: 1_000 })
await expect(running.done).rejects.toThrow()
await expect(disposal).resolves.toBeUndefined()
await expect(running.waitForExit()).resolves.toBe(true)
})
it("an 'inherit' stdout with collected stderr wires only the requested collector", async () => {
@@ -671,6 +612,18 @@ describe('coverage seams', () => {
expect(running.collected.stderr!.readFrom(0).text).toBe('err\n')
})
it("an 'inherit' stderr with collected stdout wires only the requested collector", async () => {
const running = spawnSubprocess({
...spec('echo out; echo to-parent >&2'),
stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: 'inherit' },
})
const outcome = await running.done
expect(outcome.exitCode).toBe(0)
expect(running.stderr).toBeUndefined()
expect(running.collected.stderr).toBeUndefined()
expect(running.collected.stdout!.readFrom(0).text).toBe('out\n')
})
it('terminate() after the tree died delivers no termination signal', async () => {
const running = spawnSubprocess(spec('true'))
await running.done
@@ -691,25 +644,10 @@ describe('coverage seams', () => {
await expect(running.waitForExit()).resolves.toBe(true)
})
it('dispose() on an already-exited tree returns without delivering a signal', async () => {
const running = spawnSubprocess(spec('true'))
await running.done
await running.waitForExit()
const spy = vi.spyOn(process, 'kill')
try {
await running.dispose({ eofGraceMs: 50, graceMs: 50 })
const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0)
expect(delivered).toEqual([])
} finally {
spy.mockRestore()
}
})
it('a batch-stdin handle exposes no stdin and dispose skips the EOF tier', async () => {
it('a batch-stdin handle exposes no stdin surface', async () => {
const running = spawnSubprocess(spec('cat', { stdin: 'batch\n' }))
expect(running.stdin).toBeUndefined()
await running.done
await running.dispose({ eofGraceMs: 50, graceMs: 50 })
expect(running.collected.stdout!.readFrom(0).text).toBe('batch\n')
})
})
@@ -738,33 +676,15 @@ describe('coverage seams 2', () => {
await expect(running.waitForExit()).resolves.toBe(true)
})
it('the win32 dispose ladder skips the POSIX SIGTERM tier and force-terminates', async () => {
const kills: number[] = []
const running = spawnSubprocess({
...spec('sleep 60'),
stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
}, {
spillDir,
platform: 'win32',
taskkill: (pid) => {
kills.push(pid)
try {
process.kill(pid, 'SIGKILL')
} catch {
// Already gone.
}
},
})
await running.dispose({ eofGraceMs: 50, graceMs: 5_000 })
// Exactly one forced tree termination: no POSIX SIGTERM tier ran.
expect(kills).toEqual([running.pid])
})
it('dispose throws when even SIGKILL produces no exit within the grace', async () => {
// An inert taskkill simulates a tree that never reports exit.
it('an inert win32 taskkill leaves the tree alive for a bounded wait to report', async () => {
// An inert taskkill simulates a tree that never reports exit: terminate()
// delivers nothing, so a bounded consumer wait must come back false.
const running = spawnSubprocess(spec('sleep 60'), { spillDir, platform: 'win32', taskkill: () => {} })
await expect(running.dispose({ eofGraceMs: 20, graceMs: 40 }))
.rejects.toThrow(/did not exit within 40ms after forced termination/)
running.terminate()
const bound = new AbortController()
const timer = setTimeout(() => { bound.abort() }, 60)
await expect(running.waitForExit(bound.signal)).resolves.toBe(false)
clearTimeout(timer)
// Real cleanup: the injected platform spawned without detachment, so the
// child is a plain (group-less) POSIX process — kill it directly.
process.kill(running.pid, 'SIGKILL')

View File

@@ -17,9 +17,6 @@
{
"path": "../subprocess"
},
{
"path": "../../util/timeout"
},
{
"path": "../../support/invariants"
}