* 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.
142 lines
6.2 KiB
TypeScript
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)
|
|
})
|
|
})
|