You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit df532e5
Browse filesBrowse the repository at this point in the historyBrowse files
[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>
Copy file name to clipboardExpand all lines: TESTING.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -43,7 +43,7 @@ They are listed cheapest first, and that ordering is the rule: **write a test at
43
43
44
44
**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.
45
45
46
-
**2. Integration** — the files named `*.integration.test.cjs`. Runs the real modules against real Git repositoriesin 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.
47
47
48
48
**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.
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.
Copy file name to clipboardExpand all lines: tests/unit/kill-tree.test.cjs
+13Lines changed: 13 additions & 0 deletions
Original file line number
Diff line number
Diff line change
@@ -40,6 +40,19 @@ test('killTreeByPid forces the group even though the ChildProcess that led it ha
40
40
assert.equal(killTreeByPid(0,'SIGKILL',{platform: 'darwin',kill: ()=>{thrownewError('must not be called');}}),false,'a pid that names no live process is refused, forced or not');
41
41
});
42
42
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
0 commit comments