Files
deepseek-harness/packages/boot/plugin-manager/tests/operations-process.spec.ts
T
Turtle ccaa0dc11c fix(plugin-manager): bound pnpm runs so a silent child cannot hold the profile lock (#4982)
* fix(plugin-manager): bound pnpm runs so a silent child cannot hold the profile lock

A pnpm child that prints its completion line and then never exits holds the
profile write lock until the host is restarted, so every other session and host
on that profile fails after its own lock timeout (issue #4981). execa resolves
its promise only once the piped stdio has ended, so a run now completes on the
process's own exit and drains the pipes under a bound instead of awaiting their
end.

A service run whose captured output stays silent for idleTimeoutMs is
terminated, reports timedOut alongside its exit status, and is not retried on
the next registry; a run with inherited descriptors captures nothing and is
left unbounded for its operator.

* test(plugin-manager): type the mocked child and await the drain descendants

The inherited-descriptor fixture no longer needs its legacy `as unknown` cast,
because the mocked child is typed for the fields the operations read; the
retired baseline entry is pruned (verify-no-unknown-casts).

The drain specs wait for the killed descendant to exit before removing its
directory: Windows refuses to delete a directory a live process still holds.

* test(plugin-manager): stop the drain descendant deterministically

The descendant that holds the pipes now ends on a stop file the teardown
writes, and on its own after a minute, so the fixture leaves no process behind
when a kill cannot be delivered. Windows stops it through taskkill, and the
spec waits for the exit before removing the directory it ran in.

* test(plugin-manager): run the drain descendant from its own script file

Windows cannot carry a nested `-e` script through another spawn argument: the
descendant failed to start there, so the fixture asserted on a process that was
already gone. The descendant now runs from a script file the fixture writes.

* test(plugin-manager): cover the bounded drain everywhere and stage the real descendant on POSIX

Windows does not stage a descendant that keeps the inherited pipes open past
the direct child's exit, so the real-process cases run on POSIX only. The
mocked child now models execa's own completion rule — its promise settles only
once both pipes end — which covers the bounded drain on every platform and
still fails without the raw-exit race.

* test(plugin-manager): cover the drain failure path without a child process

An output consumer failure that the bounded drain cuts short is staged with the
mocked child as well, so the per-file coverage gate also holds where the
real-process cases are skipped.

* fix(plugin-manager): terminate the whole tree of a stalled run

A lifecycle script is a child of pnpm, so signalling pnpm alone left an
approved postinstall writing into the profile after the operation reported a
timeout and released the lock. A stalled run now stops its process group (or
Windows process tree) and waits for it to disappear before the caller restores
the files. The cut tail's close is told apart from a reading failure by where it
happened, so a failure from before the cut still surfaces.

* docs(plugin-manager): regenerate the config catalog after the rebase

The English catalog gains `idleTimeoutMs` from the regenerator, and the Chinese
counterpart carries the same interface line so the pair stays consistent.

* fix(plugin-manager): terminate a run's tree through execa, keeping exit cleanup

Spawning a service run with `detached` made execa skip the handler that kills
the child when the manager exits, so a crashed manager left the pnpm tree
running in the profile. `killDescendants` spawns the run as its own process
group while keeping that cleanup, and it addresses the tree on every kill path,
including cancellation. The module now only decides the probe target and waits
for quiescence.
2026-09-23 16:11:30 +08:00

142 lines
6.2 KiB
TypeScript

/** The bounded drain and the tree stop are measured against real child processes. */
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setTimeout as sleep } from 'node:timers/promises'
import { describe, expect, it, onTestFinished } from 'vitest'
import { initProfile } from '@deepseek-ai/dsh-app-boot'
import { runProfilePnpm, type PackageOperationOptions } from '../src/operations.ts'
/** Holds the inherited pipes until its stop file appears, and ends on its own after a minute. */
const HOLDER_SCRIPT = `
const { existsSync } = require('node:fs')
setInterval(() => { if (existsSync(process.env.DSH_DESCENDANT_STOP_FILE)) process.exit(0) }, 50)
setTimeout(() => process.exit(0), 60000)
`
/** Writes a marker after the run is over, which only a stopped tree can prevent. */
const LATE_SCRIPT = `
setTimeout(() => { require('node:fs').writeFileSync(process.env.DSH_LATE_FILE, 'late') }, 1500)
setTimeout(() => process.exit(0), 60000)
`
/**
* A child that exits immediately while its descendant inherits both pipes and
* outlives it, so execa's promise cannot settle before the bounded drain. It
* runs from a file because Windows cannot carry a nested `-e` argument.
*/
const DRAIN_CHILD = `
const { spawn } = require('node:child_process')
const { writeFileSync } = require('node:fs')
const descendant = spawn(process.execPath, [process.env.DSH_DESCENDANT_SCRIPT], {
stdio: ['ignore', 'inherit', 'inherit'],
})
writeFileSync(process.env.DSH_DESCENDANT_PID_FILE, String(descendant.pid))
console.log('installed')
setTimeout(() => process.exit(0), 50)
`
/** A child that starts a descendant and then never exits, so only the silence bound ends the run. */
const STALLED_CHILD = `
const { spawn } = require('node:child_process')
const { writeFileSync } = require('node:fs')
const descendant = spawn(process.execPath, [process.env.DSH_DESCENDANT_SCRIPT], {
stdio: ['ignore', 'inherit', 'inherit'],
})
writeFileSync(process.env.DSH_DESCENDANT_PID_FILE, String(descendant.pid))
console.log('installing')
setTimeout(() => process.exit(0), 60000)
`
/** Read the descendant's published pid, waiting for the write a loaded runner can delay. */
async function readPid(pidFile: string): Promise<number> {
for (let attempt = 0; attempt < 200; attempt += 1) {
if (existsSync(pidFile)) {
const pid = Number(readFileSync(pidFile, 'utf8'))
if (pid > 0) return pid
}
await sleep(25)
}
throw new Error('the descendant published no process id')
}
/** Wait until a descendant this spec killed is really gone, so its directory can be removed. */
async function awaitGone(pid: number): Promise<void> {
for (let attempt = 0; attempt < 200; attempt += 1) {
try { process.kill(pid, 0) } catch { return /* the descendant is gone */ }
await sleep(25)
}
}
/** A profile whose package run is `child`, with its descendant stopped during teardown. */
function fixture(child: string, descendant: string) {
const home = mkdtempSync(join(tmpdir(), 'manager-run-'))
const dir = join(home, 'profiles', 'test')
const installAnchor = join(home, 'package.json')
writeFileSync(installAnchor, '{}\n')
initProfile(dir, [])
const pidFile = join(home, 'descendant.pid')
const stopFile = join(home, 'descendant.stop')
const scriptFile = join(home, 'descendant.cjs')
const lateFile = join(home, 'late.marker')
writeFileSync(scriptFile, descendant)
onTestFinished(async () => {
// The stop file ends a surviving descendant on its own; the kill only shortens the wait.
writeFileSync(stopFile, '')
if (existsSync(pidFile)) {
const pid = Number(readFileSync(pidFile, 'utf8'))
try { process.kill(pid, 'SIGKILL') } catch { /* the descendant exited with its own timer */ }
await awaitGone(pid)
}
rmSync(home, { recursive: true, force: true })
})
const options: PackageOperationOptions = {
command: process.execPath, args: ['-e', child], execution: 'service', outputBytes: 1000,
activateNewBundles: false,
env: {
DSH_DESCENDANT_PID_FILE: pidFile, DSH_DESCENDANT_STOP_FILE: stopFile,
DSH_DESCENDANT_SCRIPT: scriptFile, DSH_LATE_FILE: lateFile,
},
}
return { context: { home, profile: 'test', installAnchor, cwd: home }, pidFile, lateFile, options }
}
/**
* Windows cannot stage these fixtures: there a descendant neither keeps the
* inherited pipes open past the direct child's exit nor leads a group the run
* can stop, so the drain's premise holds on POSIX alone. The drain itself is
* platform-independent, and the mocked-child cases in operations.spec.ts cover
* it everywhere.
*/
describe.skipIf(process.platform === 'win32')('a run with descendants', () => {
it('settles a run whose pipes a descendant keeps open', async () => {
const { context, pidFile, options } = fixture(DRAIN_CHILD, HOLDER_SCRIPT)
const outcome = await runProfilePnpm(context, ['add', 'held'], options)
const descendant = await readPid(pidFile)
expect(outcome).toMatchObject({ exitCode: 0 })
expect(outcome.output).toContain('dsh: pnpm output was cut short after its process exited')
// The descendant still holds the pipes, so the run settled under its drain bound instead of waiting for their end.
expect(() => process.kill(descendant, 0)).not.toThrow()
})
it('surfaces an output consumer failure that the bounded drain cuts short', async () => {
const { context, options } = fixture(DRAIN_CHILD, HOLDER_SCRIPT)
const run = runProfilePnpm(context, ['add', 'held'], {
...options,
onOutput() { throw new Error('output destination closed') },
})
await expect(run).rejects.toThrow('output destination closed')
})
it('stops the whole tree of a stalled run before returning', async () => {
const { context, pidFile, lateFile, options } = fixture(STALLED_CHILD, LATE_SCRIPT)
const outcome = await runProfilePnpm(context, ['add', 'stalled'], { ...options, idleTimeoutMs: 300 })
const descendant = await readPid(pidFile)
expect(outcome).toMatchObject({ timedOut: true })
// The descendant is gone, so the marker it would have written 1.5s in never appears.
expect(() => process.kill(descendant, 0)).toThrow()
await sleep(2_000)
expect(existsSync(lateFile)).toBe(false)
})
})