Skip to content

Commit df532e5

Browse files
juanmaguitarclaude
andauthored
[Fix] Let a script go three seconds after npm exits when a descendant still holds its output (#513)
## Why A script's run settles on the runner's `close` event, which waits for the stdio pipes. A descendant that outlives npm keeps them open: on Windows, `wp-build` after the console window it ran in was closed (#497). npm exits, the checklist stays **IN PROGRESS**, the button stays busy and the terminal keeps receiving the orphan's lines for as long as it runs (#498). Ctrl+C was the only way out. The `close`-vs-`exit` logic is shared, so a POSIX orphan holding the pipe hangs the same way. ## What changes `runNpmWithEngineRetry` in `src/main.js`, which installs and scripts share, mirrors the Stop escalation of #479 on the natural-exit path. On the runner's `exit` it arms a three-second grace; `close` stands it down. If the grace runs out it logs why, forces the group by pid (`killTreeByPid`, the same call Stop escalates with) and destroys the runner's stdio pipes. Destroying the pipes is what makes Node emit `close` with the exit code npm gave, so the existing `close` handler settles the run and the engines-retry decision stays in one place. Whatever the orphan writes afterwards goes with the destroyed stream. On POSIX the detached process group outlives its leader, so the forced signal ends the orphan. On Windows nothing is forced, for the reason `npm:kill` already gives: the pid is certainly dead, a `taskkill /T` on it reaches nothing of the orphan and could land on a reissued pid. There the step ends and the orphan runs on until it finishes on its own. A line in the terminal says which of the two happened. The Playground and web servers keep their `close`-only lifecycle: their runners load the CLI in-process and have no descendant to orphan. ## How to test this Platform: either; the mechanism is covered on macOS by an integration test and the Windows case is the one the issue reports. Build: the Buildkite artifact for the current head, or `npm start` from the repository root. **Windows, Gutenberg site past install:** click **Run full build** and, while it runs, end the `cmd.exe` that runs `wp-build` from Task Manager or Process Explorer (with #497 in, there is no window to close, so break the tree by hand). Expected within about three seconds of npm's `exit 1` in the terminal: the build step turns **FAILED**, the button frees, the terminal stops receiving `wp-build`'s lines. `wp-build` itself may run on in Task Manager until it finishes; that is the stated limitation. Done on Windows 11 (UTM VM, arm64 host), by hand, on the Buildkite artifact for `f49c77f`, with the build watch instead of the build (same helper): **Start build watch**, then from Task Manager's Details tab ended the `cmd.exe /d /s /c node ./dev.mjs` under npm. npm printed its `command failed` stack and `exit 1`, `dev.mjs` kept transpiling packages into the tab for a moment, then the let-go line ("npm exited with code 1, but something it started is still running and holding its output. Letting go; that process runs on until it finishes on its own.") and `npm run dev exited with code 1` closed the run with the tab at *(exited 1)*; `dev.mjs` stayed alive in Task Manager, as stated. **macOS, by script, on the current head:** a scratch Gutenberg copy with its `build` script replaced by `sh -c 'sleep 90 & echo npm-side-done; exit 1'`; **Run full build**. Expected: the terminal shows `npm-side-done` and npm's exit, the button frees about three seconds later, the terminal says npm exited but something it started was still holding its output, and `pgrep -f 'sleep 90'` finds nothing. Done on macOS, current head, by a Playwright script driving the app against a scratch copy of a built Gutenberg trunk, the command sent through the app's terminal: npm's side done at 0.5 s with the orphan alive, the terminal freed 2.8 s after npm's exit, the orphan gone 1.5 s later. **What must not have happened:** a run that closed on its own being forced (the grace is cleared by `close`); a done event sent twice; an install being retried by the let-go (the retry decision is untouched and reads npm's real code). Tests: `tests/unit/ipc-wiring.test.cjs`, three `#498` cases (script let go after the grace with npm's code; a close inside the grace is left alone; an install the same way), fail on trunk and pass here. `tests/unit/orphan-pipe.integration.test.cjs` (POSIX) pins the Node and POSIX facts against a real orphan: exit without close while it holds the pipes, close with the runner's code once they are destroyed, the orphan ending on the group signal by pid. Lint clean, 1619 unit tests pass. ## Risks and limitations - Three seconds is the same grace Stop gives; a runner whose pipes take longer than that to drain after exit would be cut short. Nothing prints for three seconds after its own exit. - On Windows the orphan survives (above). Ending it would need a job object, which Node does not expose. - The quit sweep and the pre-delete stop are unchanged (#479's own follow-ups). ## Related Fixes #498. Stacked on #497's PR. Builds on #479. Part of #251, v1.2.0-beta.2. --- <details> <summary>Review outcome (required — see AGENTS.md)</summary> 3 [fix here] · 2 [follow-up] on `c6f152c`, the three fixed and one follow-up taken in `f49c77f`. - Fixed: `killTreeByPid` ran on Windows against a pid that is certainly dead, which `npm:kill`'s own rule forbids; POSIX only now, and the tests assert both branches the way the #479 test does. - Fixed: the let-go reached only the log file; the terminal now says npm exited with something still holding its output, and whether it was ended or runs on. - Fixed: the integration test's cleanup went through the pid fallback; it signals the group and the orphan directly, like its sibling. - Taken: `TESTING.md` layer 2 now names real processes beside real Git repositories. - CodeRabbit on `55f41a4`: 2 actionable, both fixed in `b81a422`: the let-go now signals the group only (`killTreeByPid` gained `groupOnly`), never the bare pid of a leader that is certainly dead; the integration test reads the orphan's pid by line. - Follow-up, not taken: `fakeChild` does not set `exitCode` when a test emits `exit`; harmless while the let-go kills by pid, worth doing if it ever goes through `killChildTree`. - The review also raised whether a test that spends a second proving a negative belongs in the default suite; one second here, left as is. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 6b0a8a2 commit df532e5

6 files changed

Lines changed: 236 additions & 4 deletions

File tree

‎TESTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ They are listed cheapest first, and that ordering is the rule: **write a test at
4343

4444
**1. Unit** — `tests/unit/`. Starts nothing; calls plain functions. Pure logic: parsing a ticket reference, deriving a status, building a command line. Blind to anything touching disk, a process or a window.
4545

46-
**2. Integration** — the files named `*.integration.test.cjs`. Runs the real modules against real Git repositories in a temporary directory. Proves Git does what the code assumes when it switches a branch, applies a patch or updates trunk. Blind to everything above the module boundary.
46+
**2. Integration** — the files named `*.integration.test.cjs`. Runs the real modules against real Git repositories, or real processes, in a temporary directory. Proves Git does what the code assumes when it switches a branch, applies a patch or updates trunk, and that a process tree behaves the way the kill and let-go paths assume (`kill-tree`, `orphan-pipe`). Blind to everything above the module boundary.
4747

4848
**3. IPC wiring** — one file, `tests/unit/ipc-wiring.test.cjs`. Loads the real `src/main.js` with `electron` replaced by a double, and exercises every handler: what each returns, what it rejects, what error it gives. Blind to the window, and its store is a stand-in rather than the real one.
4949

‎src/kill-tree.js‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,18 +75,25 @@ function killChildTree(child, deps = {}) {
7575
* there, which `close` not having fired tells it. Never throws; returns true
7676
* when a kill was attempted.
7777
*
78+
* `groupOnly` is for a caller that knows the leader is dead because it saw
79+
* its `exit` (#498): if the group is gone too there is nothing left to kill,
80+
* and the bare pid may already be someone else's, so the fallback must not
81+
* run. Without it the fallback stays, for a child that never led a group.
82+
*
7883
* @param {number} pid
7984
* @param {'SIGTERM'|'SIGKILL'} [signal] Windows forces regardless.
8085
* @param {Object} [deps]
8186
* @param {string} [deps.platform]
8287
* @param {Function} [deps.spawnSync]
8388
* @param {Function} [deps.kill]
89+
* @param {boolean} [deps.groupOnly] POSIX: never fall back to the bare pid (see below)
8490
* @return {boolean}
8591
*/
8692
function killTreeByPid(pid, signal = 'SIGTERM', {
8793
platform = process.platform,
8894
spawnSync = require('child_process').spawnSync,
89-
kill = process.kill
95+
kill = process.kill,
96+
groupOnly = false
9097
} = {}) {
9198
const plan = killTreePlan(platform, pid, signal);
9299
if (!plan) return false;
@@ -97,6 +104,7 @@ function killTreeByPid(pid, signal = 'SIGTERM', {
97104
try {
98105
kill(plan.target, plan.signal);
99106
} catch {
107+
if (groupOnly) return true;
100108
try { kill(plan.fallback, plan.signal); } catch {}
101109
}
102110
return true;

‎src/main.js‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3428,7 +3428,39 @@ function runNpmWithEngineRetry({ runnerPath, args, cwd, onLog, onDone, register,
34283428
}, 0);
34293429
});
34303430

3431+
// `close` is the event the run settles on, and it waits for the stdio
3432+
// pipes. A descendant that outlives npm keeps them open — wp-build after
3433+
// its cmd.exe was closed by hand (#497) — so npm has exited, the step is
3434+
// still IN PROGRESS, and the terminal keeps receiving the orphan's output
3435+
// (#498). Mirror of the Stop escalation (#479) on the natural-exit path:
3436+
// three seconds after `exit` with no `close`, force the group by pid on
3437+
// POSIX, where the detached group outlives its leader and the orphan
3438+
// goes with it, and destroy the pipes, which is what makes Node emit
3439+
// `close` with the exit code npm gave. The close handler then settles as
3440+
// usual, so the engines-retry decision stays in one place; whatever the
3441+
// orphan writes afterwards goes with the stream. Not forced on Windows,
3442+
// for the reason npm:kill gives: the pid is certainly dead by now, a
3443+
// `taskkill /T` on it reaches nothing of the orphan (its parent chain
3444+
// is gone) and could land on a reissued pid; there the orphan runs on
3445+
// until it finishes, and the terminal line says so.
3446+
let letGo = null;
3447+
child.once('exit', (code, signal) => {
3448+
letGo = setTimeout(() => {
3449+
if (settled) return;
3450+
const exit = `code ${code}${signal ? ` (signal ${signal})` : ''}`;
3451+
logEvent(logScope, `exited with ${exit} but a descendant still holds its output; letting go`);
3452+
onLog('stderr', `\nnpm exited with ${exit}, but something it started is still running and holding its output. Letting go${process.platform === 'win32' ? '; that process runs on until it finishes on its own' : ' and ending it'}.\n`);
3453+
// Group only: the leader is dead (this is its exit), so a group that
3454+
// is gone too leaves nothing to kill and a pid that may be reissued.
3455+
if (process.platform !== 'win32') killTreeByPid(child.pid, 'SIGKILL', { groupOnly: true });
3456+
for (const stream of [child.stdout, child.stderr, child.stdin]) {
3457+
if (stream && typeof stream.destroy === 'function') stream.destroy();
3458+
}
3459+
}, 3000);
3460+
});
3461+
34313462
child.on('close', (code, signal) => {
3463+
if (letGo) clearTimeout(letGo);
34323464
flushChildOutput(logScope);
34333465
logEvent(logScope, `exited with code ${code}${signal ? ` (signal ${signal})` : ''}`);
34343466
// `!settled` guards the case where the error path got there first: the

‎tests/unit/ipc-wiring.test.cjs‎

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1860,8 +1860,9 @@ function fakeChild() {
18601860
const child = new EventEmitter();
18611861
// setEncoding is a no-op here but has to exist: the Playground handlers call
18621862
// it on both streams before they attach a listener.
1863-
child.stdout = Object.assign(new EventEmitter(), { setEncoding() {} });
1864-
child.stderr = Object.assign(new EventEmitter(), { setEncoding() {} });
1863+
child.stdout = Object.assign(new EventEmitter(), { setEncoding() {}, destroy: spy() });
1864+
child.stderr = Object.assign(new EventEmitter(), { setEncoding() {}, destroy: spy() });
1865+
child.stdin = { destroy: spy() };
18651866
child.pid = 4242;
18661867
child.exitCode = null;
18671868
child.kill = spy();
@@ -2129,6 +2130,105 @@ test('npm:kill ends the script tree rather than signalling the runner alone', as
21292130
assert.deepEqual(cp.children[0].kill.calls, [], 'the escalation must not stop at the runner');
21302131
});
21312132

2133+
// #498: `close` waits for the stdio pipes, and a descendant that outlived npm
2134+
// (wp-build after its cmd.exe was closed by hand, #497) keeps them open. npm
2135+
// has exited, the step stays IN PROGRESS. Mirror of the Stop escalation on the
2136+
// natural-exit path: three seconds after `exit` with no `close`, force the
2137+
// group by pid (POSIX; the detached group outlives its leader) and destroy the
2138+
// pipes, which is what makes Node emit the `close` the run settles on.
2139+
test('a runner that exited but never closed is let go after a grace, with npm\'s code', async (t) => {
2140+
const cp = stubbedSpawn();
2141+
const killTreeByPid = spy();
2142+
const main = loadMain({
2143+
stubs: {
2144+
...silentLogging(),
2145+
'child_process': { spawn: cp.spawn },
2146+
'./kill-tree': { killTreeByPid }
2147+
}
2148+
});
2149+
const event = createIpcEvent();
2150+
const { runId } = await main.invokeWith('npm:run-script', event, '/sites/wp', 'build');
2151+
const child = cp.children[0];
2152+
t.mock.timers.enable({ apis: ['setTimeout'] });
2153+
2154+
child.emit('exit', 1, null);
2155+
t.mock.timers.tick(2999);
2156+
assert.deepEqual(killTreeByPid.calls, [], 'the grace is what tells an orphan from a pipe still draining');
2157+
assert.deepEqual(child.stdout.destroy.calls, []);
2158+
t.mock.timers.tick(1);
2159+
2160+
// The forced group signal is POSIX; on Windows the pid is dead and a
2161+
// taskkill on it could land on a reissued one (the npm:kill rule).
2162+
assert.deepEqual(
2163+
killTreeByPid.calls,
2164+
process.platform === 'win32' ? [] : [[child.pid, 'SIGKILL', { groupOnly: true }]],
2165+
'POSIX forces the group the runner led by pid, and only the group; Windows must not'
2166+
);
2167+
for (const stream of [child.stdout, child.stderr, child.stdin]) {
2168+
assert.equal(stream.destroy.calls.length, 1, 'the pipe the orphan holds has to be destroyed, or close never comes');
2169+
}
2170+
// The contributor is told in the terminal, not only in the log file.
2171+
assert.ok(
2172+
event.sent.some((m) => m.channel === 'npm:run-script:log' && m.payload.type === 'stderr' && /still running and holding its output/.test(m.payload.data)),
2173+
'the let-go said nothing in the terminal'
2174+
);
2175+
assert.ok(!event.sent.some((m) => m.channel === 'npm:run-script:done'), 'done is the close handler\'s to send');
2176+
// Node emits close with the stored exit code once the destroyed pipes shut.
2177+
child.emit('close', 1, null);
2178+
assert.deepEqual(event.sent.filter((m) => m.channel === 'npm:run-script:done').map((m) => m.payload), [{ runId, code: 1 }]);
2179+
});
2180+
2181+
test('a runner whose close follows its exit inside the grace is left alone', async (t) => {
2182+
const cp = stubbedSpawn();
2183+
const killTreeByPid = spy();
2184+
const main = loadMain({
2185+
stubs: {
2186+
...silentLogging(),
2187+
'child_process': { spawn: cp.spawn },
2188+
'./kill-tree': { killTreeByPid }
2189+
}
2190+
});
2191+
const event = createIpcEvent();
2192+
const { runId } = await main.invokeWith('npm:run-script', event, '/sites/wp', 'build');
2193+
const child = cp.children[0];
2194+
t.mock.timers.enable({ apis: ['setTimeout'] });
2195+
2196+
child.emit('exit', 0, null);
2197+
child.emit('close', 0, null);
2198+
t.mock.timers.tick(3000);
2199+
2200+
assert.deepEqual(killTreeByPid.calls, [], 'a tree that closed has nothing left to force, and its pid may be reissued');
2201+
assert.deepEqual(child.stdout.destroy.calls, []);
2202+
assert.deepEqual(event.sent.filter((m) => m.channel === 'npm:run-script:done').map((m) => m.payload), [{ runId, code: 0 }]);
2203+
});
2204+
2205+
test('an install that exited but never closed is let go the same way', async (t) => {
2206+
const cp = stubbedSpawn();
2207+
const killTreeByPid = spy();
2208+
const main = loadMain({
2209+
stubs: {
2210+
...silentLogging(),
2211+
// The install's done writes installFailed to the store first.
2212+
...fakeSettingsStore().stubs,
2213+
'child_process': { spawn: cp.spawn },
2214+
'./kill-tree': { killTreeByPid }
2215+
}
2216+
});
2217+
const event = createIpcEvent();
2218+
const { installId } = await main.invokeWith('npm:install', event, '/sites/wp');
2219+
const child = cp.children[0];
2220+
t.mock.timers.enable({ apis: ['setTimeout'] });
2221+
2222+
child.emit('exit', 1, null);
2223+
t.mock.timers.tick(3000);
2224+
assert.deepEqual(killTreeByPid.calls, process.platform === 'win32' ? [] : [[child.pid, 'SIGKILL', { groupOnly: true }]]);
2225+
assert.equal(child.stderr.destroy.calls.length, 1);
2226+
child.emit('close', 1, null);
2227+
// The install's done follows a store write.
2228+
await new Promise((resolve) => setImmediate(resolve));
2229+
assert.deepEqual(event.sent.filter((m) => m.channel === 'npm:install:done').map((m) => m.payload), [{ installId, code: 1 }]);
2230+
});
2231+
21322232
test('npm:kill arms no escalation for a child that had already closed', async (t) => {
21332233
const cp = stubbedSpawn();
21342234
const killTreeByPid = spy();

‎tests/unit/kill-tree.test.cjs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,19 @@ test('killTreeByPid forces the group even though the ChildProcess that led it ha
4040
assert.equal(killTreeByPid(0, 'SIGKILL', { platform: 'darwin', kill: () => { throw new Error('must not be called'); } }), false, 'a pid that names no live process is refused, forced or not');
4141
});
4242

43+
test('killTreeByPid with groupOnly never signals the bare pid when the group is gone', () => {
44+
// The let-go after exit (#498): the leader is certainly dead, so a group
45+
// that no longer exists means nothing is left, and the bare pid may have
46+
// been handed to someone else since.
47+
const calls = [];
48+
const kill = (target, signal) => { calls.push([target, signal]); if (target < 0) throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' }); };
49+
assert.equal(killTreeByPid(42, 'SIGKILL', { platform: 'darwin', kill, groupOnly: true }), true);
50+
assert.deepEqual(calls, [[-42, 'SIGKILL']], 'the fallback to the bare pid must not run');
51+
calls.length = 0;
52+
killTreeByPid(42, 'SIGKILL', { platform: 'darwin', kill });
53+
assert.deepEqual(calls, [[-42, 'SIGKILL'], [42, 'SIGKILL']], 'without groupOnly the fallback stays, for a child that never led a group');
54+
});
55+
4356
test('killTreePlan refuses pids that cannot name a live process', () => {
4457
// pid 0 would signal the caller's own group and -1 every process the user
4558
// owns — a bug here is catastrophic, so these must return null, not a plan.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
'use strict';
2+
3+
// The mechanism #498 rests on, against a real process: a runner whose
4+
// descendant outlives it keeps the stdio pipes open, so `exit` fires and
5+
// `close` does not; destroying the pipes on our side is what makes Node emit
6+
// `close`, with the exit code the runner gave, and a forced group signal by
7+
// pid ends the orphan although the leader is gone. The unit suite pins the
8+
// wiring in main.js; this pins the Node and POSIX facts it relies on. POSIX
9+
// only by nature (process groups and `sh`); on Windows the orphan runs on and
10+
// the destroyed pipes still deliver `close`, which the wiring test covers.
11+
12+
const test = require('node:test');
13+
const assert = require('node:assert/strict');
14+
const { spawn } = require('node:child_process');
15+
16+
const { killTreeByPid } = require('../../src/kill-tree.js');
17+
18+
const posixOnly = process.platform === 'win32' ? { skip: 'process groups are POSIX' } : {};
19+
20+
function alive(pid) {
21+
try { process.kill(pid, 0); return true; } catch { return false; }
22+
}
23+
24+
function until(check, ms) {
25+
return new Promise((resolve) => {
26+
const started = Date.now();
27+
const tick = () => {
28+
if (check()) return resolve(true);
29+
if (Date.now() - started > ms) return resolve(false);
30+
setTimeout(tick, 50);
31+
};
32+
tick();
33+
});
34+
}
35+
36+
test('a runner whose orphan holds the pipes exits without closing, and closes once the pipes are destroyed', { ...posixOnly, timeout: 30000 }, async (t) => {
37+
// runner (sh) -> orphan (node) that inherits stdout and keeps it open past
38+
// the runner's own exit 3, the way wp-build outlives npm (#498). `& exit`
39+
// backgrounds it so the shell itself ends at once.
40+
const runner = spawn('sh', ['-c', `"$0" -e "$1" & exit 3`, process.execPath, 'console.log(process.pid); setInterval(() => {}, 1000);'], {
41+
detached: true,
42+
stdio: ['pipe', 'pipe', 'pipe']
43+
});
44+
// Straight to the group, never the bare pid: once the body has reaped the
45+
// group, the runner's pid may be someone else's.
46+
let orphanPid = null;
47+
t.after(() => {
48+
try { process.kill(-runner.pid, 'SIGKILL'); } catch {}
49+
if (orphanPid !== null) { try { process.kill(orphanPid, 'SIGKILL'); } catch {} }
50+
});
51+
52+
// Data events are not line-delimited: wait for the newline before reading
53+
// the pid, or a partial chunk names some other process.
54+
let stdout = '';
55+
runner.stdout.on('data', (data) => {
56+
stdout += String(data);
57+
const newline = stdout.indexOf('\n');
58+
if (orphanPid === null && newline !== -1) {
59+
const pid = Number(stdout.slice(0, newline).trim());
60+
if (Number.isInteger(pid) && pid > 0) orphanPid = pid;
61+
}
62+
});
63+
const exited = new Promise((resolve) => runner.once('exit', resolve));
64+
let closed = null;
65+
runner.once('close', (code, signal) => { closed = { code, signal }; });
66+
67+
assert.equal(await exited, 3, 'the runner exits with its own code');
68+
assert.ok(await until(() => orphanPid !== null, 5000), 'the orphan never reported its pid');
69+
assert.ok(alive(orphanPid), 'the orphan should outlive the runner');
70+
assert.equal(await until(() => closed !== null, 1000), false, 'close must not come while the orphan holds the pipes');
71+
72+
// What main.js does when the grace runs out.
73+
killTreeByPid(runner.pid, 'SIGKILL', { groupOnly: true });
74+
for (const stream of [runner.stdout, runner.stderr, runner.stdin]) stream.destroy();
75+
76+
assert.ok(await until(() => closed !== null, 5000), 'destroying the pipes did not deliver close');
77+
assert.deepEqual(closed, { code: 3, signal: null }, 'close carries the code the runner exited with');
78+
assert.ok(await until(() => !alive(orphanPid), 5000), 'the group signal by pid did not end the orphan');
79+
});

0 commit comments

Comments
 (0)