Repository navigation
fix(cli): --background parent exits and detached server stays alive - #451
Conversation
yoshiko-pg
left a comment
There was a problem hiding this comment.
Thanks for the fix — replacing the stdout handshake with IPC is the right direction. Found one reproducible crash and two smaller issues, inline below.
| function releaseBackgroundChild(child: ChildProcess): void { | ||
| child.removeAllListeners(); | ||
| child.disconnect(); | ||
| child.unref(); | ||
| } |
There was a problem hiding this comment.
Bug: child.disconnect() is called without a guard and crashes the parent with an unhandled 'error' event when the child dies before the handshake.
If the child exits early (e.g. an invalid commit-ish was passed), the IPC channel is already closed by the time the 'exit' event fires, so child.connected is false. Calling disconnect() in that state makes Node emit an 'error' event (ERR_IPC_DISCONNECTED) on the ChildProcess — and since removeAllListeners() has just stripped every listener, it becomes an uncaught exception. The parent crashes with a stack trace instead of printing the intended Background difit server exited early (code 1) message.
Reproduced on Node v24:
Error [ERR_IPC_DISCONNECTED]: IPC channel is already disconnected
at target.disconnect (node:internal/child_process:926:26)
Suggested fix:
function releaseBackgroundChild(child: ChildProcess): void {
child.removeAllListeners('message');
child.removeAllListeners('error');
child.removeAllListeners('exit');
if (child.connected) {
child.disconnect();
}
child.unref();
}Note that a bare removeAllListeners() also strips Node's internal IPC listeners (internalMessage etc.), so removing only the events registered here is safer.
| const timeout = setTimeout(() => { | ||
| reject(new Error('Timed out while starting background difit server')); | ||
| finish(() => { | ||
| releaseBackgroundChild(child); | ||
| reject(new Error('Timed out while starting background difit server')); | ||
| }); | ||
| }, 10_000); |
There was a problem hiding this comment.
Orphaned server on timeout: the timeout path releases the child (disconnect + unref) instead of killing it. The parent exits with Timed out while starting background difit server, but a slow-starting child will finish booting afterwards and — since --keep-alive is forced — keep running forever. The user sees a failure while an orphaned server silently holds a port.
The timeout path should child.kill() rather than release it.
| const child = spawn(process.execPath, [scriptPath, ...childArgs], { | ||
| detached: true, | ||
| stdio: ['ignore', 'pipe', 'pipe'], | ||
| stdio: ['ignore', 'ignore', 'ignore', 'ipc'], |
There was a problem hiding this comment.
Lost startup diagnostics: with all stdio set to 'ignore', any startup error the child prints (invalid commit-ish, stdin read failure, etc.) goes to /dev/null. The previous implementation collected stderr and included it in the error message; now the parent can only report exited early (code 1) with no reason.
A way to keep both properties: leave stderr as 'pipe', collect it until the handshake, then child.stderr.destroy() on success — any later EPIPE after the parent exits is absorbed by the no-op 'error' handlers added in ignoreStdioErrorsForBackgroundDaemon(). Alternatively, send startup errors over the IPC channel.
Related: with the current all-'ignore' stdio config, the child's stdout/stderr point at /dev/null, so EPIPE can never happen and ignoreStdioErrorsForBackgroundDaemon() is effectively dead code (it becomes meaningful if stderr goes back to a pipe).
|
Thanks for tracking this down, the IPC handshake approach is the right fix for the SIGPIPE issue. Two problems in the error paths though:
Minor: with all stdio ignored, |
Use IPC for the parent/child handshake instead of stdout pipes so the parent can exit without SIGPIPE killing the detached child. Disconnect IPC after the handshake and ignore stdio errors on the background child.
fba1df9 to
0f212dc
Compare
|
@yoshiko-pg I pushed a new commit with some fixes, and added a manual validation section to the pr's description. Step 4 I found it flaky to validate. If you have other steps to reproduce the bugs, please let me know so I can try them locally as well. |
|
@rafaelsteil Thank you!! |
…oshiko-pg#451) * fix: --background parent actually exits and detached server stays alive Use IPC for the parent/child handshake instead of stdout pipes so the parent can exit without SIGPIPE killing the detached child. Disconnect IPC after the handshake and ignore stdio errors on the background child. * Address PR comments

Summary
--backgroundso the parent process exits after printing server info, while the detached child keeps runningprocess.send/child.on('message')) so the parent can exit without SIGPIPE killing the detached serverRoot cause
startBackgroundProcess()spawned the detached child with piped stdout/stderr and waited for the server-ready JSON onchild.stdout. The parent printed that line and calledchild.unref(), but the pipes stayed open until the parent exited. When the parent process ended, the broken stdout pipe could deliver SIGPIPE to the still-running child and kill the background server.The background child also continued to use stdout for the handshake, so it remained coupled to the parent's lifecycle even though the process was meant to be detached.
What changed
src/cli/index.tsstdio: ['ignore', 'ignore', 'ignore', 'ipc']instead of piped stdout/stderr{ port, url, pid }viaprocess.send()and disconnects IPC after the handshakechild.on('message'), printsJSON.stringify(handshake)to its own stdout, then disconnects and unrefs the childerrorevents after handshake so closed stdio does not crash the daemonReproduction (before this fix)
difit HEAD --background --no-open # Parent hung or background server died shortly after startup when the parent exitedTest plan
difit HEAD --background --no-openprints one JSON line (port,url,pid) and the parent exits immediatelycurl http://localhost:<port>/api/diff→ 200difit HEAD --background --no-opentwice on the same port behavior still works (or port retry still works when busy)difit HEADstill opens the browser and blocks as beforepnpm check/pnpm test/pnpm buildpassManual validation
These checks compare the reviewed PR head (
33a17c58c4a18ad725790f3774e46785e796d34b) with the current working tree. They use a detached Git worktree for the old code, so the current branch does not need to be changed.1. Prepare the reviewed version
Run from the difit repository:
git worktree add --detach /tmp/difit-pr451-before 33a17c58c4a18ad725790f3774e46785e796d34b cd /tmp/difit-pr451-before pnpm install --offline --frozen-lockfile --ignore-scripts pnpm build:cli2. Reproduce the disconnected-IPC crash
From
/tmp/difit-pr451-before:Expected before the fix:
ERR_IPC_DISCONNECTEDstack trace.releaseBackgroundChildandchild.disconnect().Validate the current fix:
cd /home/rafael/dev/difit pnpm build:cli node dist/cli/index.js definitely-not-a-valid-ref --background --no-openExpected after the fix:
Invalid or non-existent commit: definitely-not-a-valid-ref.ERR_IPC_DISCONNECTED,Unhandled 'error' event, or a Node stack trace.3. Verify that startup stderr is preserved
First show the diagnostic produced by the reviewed version's child:
cd /tmp/difit-pr451-before DIFIT_BACKGROUND_CHILD=1 node dist/cli/index.js definitely-not-a-valid-ref --keep-alive --no-openExpected:
Now run the reviewed version through its background parent:
Expected before the fix:
Run the equivalent command in the current working tree:
cd /home/rafael/dev/difit node dist/cli/index.js definitely-not-a-valid-ref --background --no-openExpected after the fix:
4. Reproduce and validate timeout cleanup
This is the hardest case to reproduce manually. The review concern is:
releaseBackgroundChild()(disconnect + unref) instead ofkill(), so a slow child can keep booting and hold the port.child.kill()so the detached server should not survive.The preload below delays only the detached child (
DIFIT_BACKGROUND_CHILD=1) for 12 seconds before difit starts. The parent times out at 10 seconds.Create the delay preload once
Helper: watch port 4977 for 20 seconds
Run this in a second terminal before starting the background command:
Before the fix
In the first terminal:
Interpret the watcher output:
Timed out while starting background difit serverafter ~10salive=yesfor several seconds aftert=10, thencurl=200aroundt=13–t=16alive=nosoon aftert=10andcurl=000throught=20If you saw the second row (like
kill: No such processandcurl 000immediately after timeout), that means:The pre-fix code still does not call
child.kill()on timeout; a slower or partially-started child can still orphan in other conditions.Cleanup if anything is still running:
After the fix
Repeat with the watcher running, then in the first terminal:
Expected after the fix:
t=11ort=12), watcher showsalive=no.curlstays000for the full 20s window.That confirms the timed-out child was killed instead of released to keep running.
5. Validate successful background startup
From the current working tree:
Expected after the fix:
port,url, andpid.200after the parent has exited.killstops the detached server.6. Cleanup
cd /home/rafael/dev/difit git worktree remove /tmp/difit-pr451-before rm -f /tmp/difit-pr451-delay-child.cjs /tmp/difit-pr451-child.pid