Summary
Follow-up to #19433 — close not emitted after parent .disconnect(), which was automatically closed for inactivity on 2026-07-28, not resolved by a fix. I cannot reopen that issue with my repository permissions, so I am reporting the current-version evidence here. Reopening/consolidating into the original would also be welcome.
The original downstream report:
Confirmed on Node v24.20.0 and v26.7.0: fork an inert child with detached:true and stdio ['ignore','ignore','pipe','ipc']; child sends 'ready' then blocks in Atomics.wait. On ready, parent child.disconnect() followed by SIGKILL to owned process group yields exit=true, stderrClose=true, close=false although PID/group are absent. Kill-only emits close=true.
AI assistance: This investigation and write-up were AI-assisted. An AI coding agent searched the prior reports, ran the isolated reproductions, and inspected the Node source. The results below are from those actual runs; untested platforms and source-only checks are identified explicitly.
Version
Fresh runs on 2026-08-29:
| Node version |
Distribution |
Parent disconnect → SIGKILL |
SIGKILL only |
| v22.23.2 |
Official nodejs.org Darwin arm64 binary |
Missing close: 10/10 |
Emitted close: 10/10 |
| v24.20.0 |
Homebrew Node 24 |
Missing close: 10/10 |
Emitted close: 10/10 |
| v26.7.0 |
Homebrew Node 26 |
Missing close: 10/10 |
Emitted close: 10/10 |
| v26.8.1 |
Official nodejs.org Darwin arm64 binary |
Missing close: 10/10 |
Emitted close: 10/10 |
The official archives matched their published SHA-256 checksums. The Node distribution index listed v22.23.2, v24.20.0, and v26.8.1 as the latest releases in those major lines when tested.
Platform
All fresh runs: macOS 27.0, build 26A5416b (prerelease), Apple Silicon. uname -srm: Darwin 27.0.0 arm64.
The original issue reported Node v9.8.0/Linux x64; that is historical corroboration, not fresh Linux proof. Current Linux and Windows behavior was not tested. The process-group reproduction below is POSIX-only.
Subsystem
child_process: parent-side IPC shutdown / ChildProcess close accounting.
What steps will reproduce the bug?
Save these two files in the same directory. No packages are needed. The child sends one message and blocks without consuming CPU; it launches no descendants. Only the new child's owned process group is signaled.
ipc-child.mjs:
process.send('ready', (error) => {
if (error) throw error;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0);
});
ipc-parent.mjs:
import assert from 'node:assert/strict';
import { fork } from 'node:child_process';
assert.notEqual(process.platform, 'win32', 'This repro uses POSIX process groups');
const mode = process.argv[2];
assert(['disconnect', 'kill-only'].includes(mode));
const child = fork(new URL('./ipc-child.mjs', import.meta.url), {
execArgv: [],
detached: true,
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
// Safety only: kill this inert child if its ready message never arrives.
timeout: 5_000,
killSignal: 'SIGKILL',
});
const observed = { ready: false, disconnect: false, exit: false, stderrClose: false, close: false };
let failed = false;
child.on('error', (error) => {
failed = true;
console.error(error);
child.kill('SIGKILL');
});
child.stderr.resume();
child.stderr.on('close', () => { observed.stderrClose = true; });
child.on('disconnect', () => { observed.disconnect = true; });
child.on('exit', () => { observed.exit = true; });
child.on('close', () => { observed.close = true; });
child.once('message', (message) => {
try {
assert.equal(message, 'ready');
observed.ready = true;
if (mode === 'disconnect') child.disconnect();
} finally {
// Only the new process group created by this fork is signaled.
process.kill(-child.pid, 'SIGKILL');
}
});
process.once('exit', () => {
if (!observed.exit) child.kill('SIGKILL');
});
process.once('beforeExit', () => {
assert.equal(failed, false);
assert(observed.ready && observed.exit && observed.stderrClose);
assert.equal(child.signalCode, 'SIGKILL');
assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' });
assert.throws(() => process.kill(-child.pid, 0), { code: 'ESRCH' });
console.log(JSON.stringify({
node: process.version, platform: process.platform, arch: process.arch,
mode, ...observed, pidAndGroupAbsent: true,
}));
});
Failing path:
node ipc-parent.mjs disconnect
Control:
node ipc-parent.mjs kill-only
The five-second timeout is an emergency child-termination guard, not the observation window. Normal runs kill immediately on ready. Results are recorded at beforeExit, after the parent's event loop drains naturally. The program does not call process.exit() or fabricate a close event. Both commands exit successfully when cleanup assertions pass; the observed bug is the printed close: false.
How often does it reproduce? Is there a required condition?
40/40 disconnect-before-kill runs missed close; all 40 kill-only controls emitted it. Every run observed exit and stderr close, and asserted ESRCH for the child PID and its owned group.
Additional one-run-per-mode checks on v24.20.0 and v26.7.0 reproduced the same distinction using child.kill('SIGKILL'), both with and without detached. Non-detached checks asserted child PID absence only. Thus neither detached mode nor negative-PID signaling is necessary. The original upstream report also demonstrated ordinary graceful child exit rather than SIGKILL.
What is the expected behavior? Why is that the expected behavior?
Both modes should emit ChildProcess close exactly once after child exit and stdio closure, with (null, 'SIGKILL') here, consistent with the documented close contract.
What do you see instead?
Actual output from official v26.8.1:
{"node":"v26.8.1","platform":"darwin","arch":"arm64","mode":"disconnect","ready":true,"disconnect":true,"exit":true,"stderrClose":true,"close":false,"pidAndGroupAbsent":true}
Control:
{"node":"v26.8.1","platform":"darwin","arch":"arm64","mode":"kill-only","ready":true,"disconnect":true,"exit":true,"stderrClose":true,"close":true,"pidAndGroupAbsent":true}
The control's disconnect: true is the automatic IPC disconnect following child termination, not an explicit parent call.
Code waiting for close remains unsettled despite successful process/pipe cleanup. With a referenced deadline timer this can appear as a cleanup timeout; otherwise the parent can exit without calling its close listener. This reproduction does not show a surviving child or a leaked live pipe.
Additional information
Source-level cause
Node main was inspected at d86ded95f15f844fad5df54b94b25bbbab9d1a4a. Its complete lib/internal/child_process.js matches the builtin source extracted from v26.7.0 and official v26.8.1, ignoring the extraction's extra final newline. A build of that main commit was not run.
A separate read-only diagnostic on v24.20.0 and v26.7.0 recorded _closesGot = 2, _closesNeeded = 3 at beforeExit after explicit disconnect, versus 3/3 in the control. No private field or event was mutated; the public-API repro above does not rely on those fields.
Earlier attempted repair and coverage
PR 19566 — fix channel disconnect logic attempted a repair and close-after-disconnect coverage, but was closed for inactivity in 2019. It is useful history, not a recommendation to transplant that old patch.
Current parent-disconnect coverage requires disconnect and exit, but not ChildProcess close. Fork-close coverage requires close after ordinary child exit without explicit parent disconnect. A regression test requiring exactly one close after parent disconnect would cover the missing combination; a repair also needs to preserve EOF, buffered-message/handle, and exit-before-close ordering.
Downstream impact
Discovered during OpenClaw PR 132251 — isolate native test process resources. A Node-only fixture could time out joining close although its owned supervisor and process group were gone. The merged avoidance keeps IPC attached through termination and joins the real close event; it neither synthesizes close nor patches Node. No OpenClaw installation or native app is required for the reproduction.
Summary
Follow-up to #19433 —
closenot emitted after parent.disconnect(), which was automatically closed for inactivity on 2026-07-28, not resolved by a fix. I cannot reopen that issue with my repository permissions, so I am reporting the current-version evidence here. Reopening/consolidating into the original would also be welcome.The original downstream report:
AI assistance: This investigation and write-up were AI-assisted. An AI coding agent searched the prior reports, ran the isolated reproductions, and inspected the Node source. The results below are from those actual runs; untested platforms and source-only checks are identified explicitly.
Version
Fresh runs on 2026-08-29:
close: 10/10close: 10/10close: 10/10close: 10/10close: 10/10close: 10/10close: 10/10close: 10/10The official archives matched their published SHA-256 checksums. The Node distribution index listed v22.23.2, v24.20.0, and v26.8.1 as the latest releases in those major lines when tested.
Platform
All fresh runs: macOS 27.0, build 26A5416b (prerelease), Apple Silicon.
uname -srm:Darwin 27.0.0 arm64.The original issue reported Node v9.8.0/Linux x64; that is historical corroboration, not fresh Linux proof. Current Linux and Windows behavior was not tested. The process-group reproduction below is POSIX-only.
Subsystem
child_process: parent-side IPC shutdown / ChildProcess close accounting.What steps will reproduce the bug?
Save these two files in the same directory. No packages are needed. The child sends one message and blocks without consuming CPU; it launches no descendants. Only the new child's owned process group is signaled.
ipc-child.mjs:ipc-parent.mjs:Failing path:
Control:
The five-second timeout is an emergency child-termination guard, not the observation window. Normal runs kill immediately on
ready. Results are recorded atbeforeExit, after the parent's event loop drains naturally. The program does not callprocess.exit()or fabricate acloseevent. Both commands exit successfully when cleanup assertions pass; the observed bug is the printedclose: false.How often does it reproduce? Is there a required condition?
40/40 disconnect-before-kill runs missed
close; all 40 kill-only controls emitted it. Every run observedexitand stderrclose, and assertedESRCHfor the child PID and its owned group.Additional one-run-per-mode checks on v24.20.0 and v26.7.0 reproduced the same distinction using
child.kill('SIGKILL'), both with and withoutdetached. Non-detached checks asserted child PID absence only. Thus neither detached mode nor negative-PID signaling is necessary. The original upstream report also demonstrated ordinary graceful child exit rather than SIGKILL.What is the expected behavior? Why is that the expected behavior?
Both modes should emit ChildProcess
closeexactly once after child exit and stdio closure, with(null, 'SIGKILL')here, consistent with the documented close contract.What do you see instead?
Actual output from official v26.8.1:
{"node":"v26.8.1","platform":"darwin","arch":"arm64","mode":"disconnect","ready":true,"disconnect":true,"exit":true,"stderrClose":true,"close":false,"pidAndGroupAbsent":true}Control:
{"node":"v26.8.1","platform":"darwin","arch":"arm64","mode":"kill-only","ready":true,"disconnect":true,"exit":true,"stderrClose":true,"close":true,"pidAndGroupAbsent":true}The control's
disconnect: trueis the automatic IPC disconnect following child termination, not an explicit parent call.Code waiting for
closeremains unsettled despite successful process/pipe cleanup. With a referenced deadline timer this can appear as a cleanup timeout; otherwise the parent can exit without calling its close listener. This reproduction does not show a surviving child or a leaked live pipe.Additional information
Source-level cause
Node
mainwas inspected at d86ded95f15f844fad5df54b94b25bbbab9d1a4a. Its completelib/internal/child_process.jsmatches the builtin source extracted from v26.7.0 and official v26.8.1, ignoring the extraction's extra final newline. A build of that main commit was not run.maybeClose(target)._disconnect()closes the channel and emitsdisconnectwithout that accounting.maybeClose()emits only when the received and required counts are equal.A separate read-only diagnostic on v24.20.0 and v26.7.0 recorded
_closesGot = 2,_closesNeeded = 3atbeforeExitafter explicit disconnect, versus3/3in the control. No private field or event was mutated; the public-API repro above does not rely on those fields.Earlier attempted repair and coverage
PR 19566 — fix channel disconnect logic attempted a repair and close-after-disconnect coverage, but was closed for inactivity in 2019. It is useful history, not a recommendation to transplant that old patch.
Current parent-disconnect coverage requires
disconnectandexit, but not ChildProcessclose. Fork-close coverage requirescloseafter ordinary child exit without explicit parent disconnect. A regression test requiring exactly onecloseafter parent disconnect would cover the missing combination; a repair also needs to preserve EOF, buffered-message/handle, and exit-before-close ordering.Downstream impact
Discovered during OpenClaw PR 132251 — isolate native test process resources. A Node-only fixture could time out joining
closealthough its owned supervisor and process group were gone. The merged avoidance keeps IPC attached through termination and joins the real close event; it neither synthesizesclosenor patches Node. No OpenClaw installation or native app is required for the reproduction.