* 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.
48 lines
2.2 KiB
TypeScript
48 lines
2.2 KiB
TypeScript
/** Waiting for one run's tree is decided by platform and target, without starting a real process. */
|
|
import { expect, it } from 'vitest'
|
|
import { awaitTreeGone, leadsOwnGroup, type RunTree } from '../src/run-tree.ts'
|
|
|
|
/** One run's process or process group. */
|
|
function tree(pid: number | undefined, grouped = true): RunTree {
|
|
return { pid, grouped }
|
|
}
|
|
|
|
it.each([
|
|
['service', 'linux', true],
|
|
['service', 'darwin', true],
|
|
['service', 'win32', false],
|
|
['cli', 'linux', false],
|
|
['cli', 'win32', false],
|
|
] as const)('decides group leadership for %s on %s', (execution, platform, expected) => {
|
|
expect(leadsOwnGroup(execution, platform)).toBe(expected)
|
|
})
|
|
|
|
it('waits for a tree that disappears on its own', async () => {
|
|
let alive = true
|
|
await expect(awaitTreeGone(tree(42), {
|
|
platform: 'linux',
|
|
alive: () => { const first = alive; alive = false; return first },
|
|
})).resolves.toBeUndefined()
|
|
})
|
|
|
|
it('probes the group a run leads and the process it does not', async () => {
|
|
const probes: number[] = []
|
|
const gone = { alive: (target: number) => { probes.push(target); return false } }
|
|
await expect(awaitTreeGone(tree(42), { ...gone, platform: 'linux' })).resolves.toBeUndefined()
|
|
await expect(awaitTreeGone(tree(42, false), { ...gone, platform: 'linux' })).resolves.toBeUndefined()
|
|
await expect(awaitTreeGone(tree(42), { ...gone, platform: 'win32' })).resolves.toBeUndefined()
|
|
expect(probes).toEqual([-42, 42, 42])
|
|
})
|
|
|
|
it('returns at once for a tree with no process id or one already gone', async () => {
|
|
await expect(awaitTreeGone(tree(undefined), { alive: () => { throw new Error('never probed') } })).resolves.toBeUndefined()
|
|
// The host platform answers for a run that names none, and the default probe reports a vanished process.
|
|
await expect(awaitTreeGone(tree(99_999_999, false))).resolves.toBeUndefined()
|
|
await expect(awaitTreeGone(tree(99_999_999, false), { platform: 'linux' })).resolves.toBeUndefined()
|
|
})
|
|
|
|
it('gives up on a tree that never disappears', async () => {
|
|
// The wait bound is an input, so this needs no global clock: fake timers leak into the suites sharing the worker.
|
|
await expect(awaitTreeGone(tree(42), { platform: 'linux', alive: () => true, waitMs: 0 })).resolves.toBeUndefined()
|
|
})
|