Skip to content

Commit f49c77f

Browse files
juanmaguitarclaude
andcommitted
Force the group on POSIX only, say the let-go in the terminal, clean the test up by group
Self-review findings on c6f152c: killTreeByPid ran on Windows against a pid that is certainly dead, which npm:kill's own rule forbids (a taskkill there can land on a reissued pid, and it is a synchronous spawn on the main process); the let-go reached only the log file, so the contributor saw a step end and the output stop with no word, on Windows with wp-build still running; the integration test's cleanup went through the pid fallback. TESTING.md's layer 2 now says real processes belong there too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent e5f02d1 commit f49c77f

4 files changed

Lines changed: 35 additions & 14 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/main.js‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3433,19 +3433,24 @@ function runNpmWithEngineRetry({ runnerPath, args, cwd, onLog, onDone, register,
34333433
// its cmd.exe was closed by hand (#497) — so npm has exited, the step is
34343434
// still IN PROGRESS, and the terminal keeps receiving the orphan's output
34353435
// (#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
3437-
// (on POSIX the detached group outlives its leader, so the orphan goes;
3438-
// on Windows `taskkill /T` from a pid that is gone reaches nothing, and
3439-
// the orphan runs on until it finishes) and destroy the pipes, which is
3440-
// what makes Node emit `close` with the exit code npm gave. The close
3441-
// handler then settles as usual, so the engines-retry decision stays in
3442-
// one place; whatever the orphan writes afterwards goes with the stream.
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.
34433446
let letGo = null;
34443447
child.once('exit', (code, signal) => {
34453448
letGo = setTimeout(() => {
34463449
if (settled) return;
3447-
logEvent(logScope, `exited with code ${code}${signal ? ` (signal ${signal})` : ''} but a descendant still holds its output; forcing the tree and letting go`);
3448-
killTreeByPid(child.pid, 'SIGKILL');
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+
if (process.platform !== 'win32') killTreeByPid(child.pid, 'SIGKILL');
34493454
for (const stream of [child.stdout, child.stderr, child.stdin]) {
34503455
if (stream && typeof stream.destroy === 'function') stream.destroy();
34513456
}

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2157,10 +2157,21 @@ test('a runner that exited but never closed is let go after a grace, with npm\'s
21572157
assert.deepEqual(child.stdout.destroy.calls, []);
21582158
t.mock.timers.tick(1);
21592159

2160-
assert.deepEqual(killTreeByPid.calls, [[child.pid, 'SIGKILL']], 'the group the runner led is forced by pid');
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']],
2165+
'POSIX forces the group the runner led by pid; Windows must not'
2166+
);
21612167
for (const stream of [child.stdout, child.stderr, child.stdin]) {
21622168
assert.equal(stream.destroy.calls.length, 1, 'the pipe the orphan holds has to be destroyed, or close never comes');
21632169
}
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+
);
21642175
assert.ok(!event.sent.some((m) => m.channel === 'npm:run-script:done'), 'done is the close handler\'s to send');
21652176
// Node emits close with the stored exit code once the destroyed pipes shut.
21662177
child.emit('close', 1, null);
@@ -2210,7 +2221,7 @@ test('an install that exited but never closed is let go the same way', async (t)
22102221

22112222
child.emit('exit', 1, null);
22122223
t.mock.timers.tick(3000);
2213-
assert.deepEqual(killTreeByPid.calls, [[child.pid, 'SIGKILL']]);
2224+
assert.deepEqual(killTreeByPid.calls, process.platform === 'win32' ? [] : [[child.pid, 'SIGKILL']]);
22142225
assert.equal(child.stderr.destroy.calls.length, 1);
22152226
child.emit('close', 1, null);
22162227
// The install's done follows a store write.

‎tests/unit/orphan-pipe.integration.test.cjs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,14 @@ test('a runner whose orphan holds the pipes exits without closing, and closes on
4141
detached: true,
4242
stdio: ['pipe', 'pipe', 'pipe']
4343
});
44-
t.after(() => { try { killTreeByPid(runner.pid, 'SIGKILL'); } catch {} });
45-
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.
4646
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+
4752
runner.stdout.on('data', (data) => { if (orphanPid === null) orphanPid = Number(String(data).trim()); });
4853
const exited = new Promise((resolve) => runner.once('exit', resolve));
4954
let closed = null;

0 commit comments

Comments
 (0)