Bug: relaunchAppInChildProcess does not forward signals to child, orphaning it when parent is killed
#25590 opened on Apr 17, 2026
Description
Summary
relaunchAppInChildProcess spawns a full-memory child via node:child_process.spawn
with stdio: ['inherit','inherit','inherit','ipc'], but the bootstrap parent does
not install signal handlers to forward termination signals to the child.
When the parent receives SIGTERM/SIGHUP from a supervising process (e.g. an
ACP client, systemd, a container runtime, or any process manager), the bootstrap
exits but the child is reparented to PID 1 / the user's systemd --user manager
and keeps running. The orphan continues to hold the OAuth session and allocated
heap until killed manually.
Reproduction
# terminal 1
gemini -m gemini-3.1-pro-preview -y --acp
# terminal 2
ps -eo pid,ppid,pgid,cmd | grep gemini
# observe bootstrap PID and child PID (child PPID == bootstrap PID)
kill -TERM <bootstrap-pid>
ps -eo pid,ppid,pgid,cmd | grep gemini
# child is still alive, PPID now 1 (or systemd user manager PID)
Interactive Ctrl+C does not surface the bug because SIGINT is delivered to the
whole foreground process group through the controlling terminal. The bug only
manifests with programmatic kill(pid, signal), which is the normal path for
any process manager that supervises gemini CLI as a child process.
Root cause
File: packages/cli/src/utils/relaunch.ts, function relaunchAppInChildProcess.
The runner closure spawns the child, wires an IPC message listener, and
resolves on close. There is no process.on('SIGTERM', ...) (or HUP/INT/QUIT/
USR1/USR2) that proxies the signal to child.kill(signal), so the parent
simply dies on its default signal disposition while the child keeps going.
Proposed fix
Install forwarders before awaiting the child, remove them on close/error to avoid listener leaks across relaunch iterations:
const FORWARDED_SIGNALS: readonly NodeJS.Signals[] = [
'SIGTERM', 'SIGHUP', 'SIGINT', 'SIGQUIT', 'SIGUSR1', 'SIGUSR2',
];
const forwarders = new Map<NodeJS.Signals, () => void>();
for (const sig of FORWARDED_SIGNALS) {
const handler = () => {
try { child.kill(sig); } catch { /* child may already be gone */ }
};
forwarders.set(sig, handler);
process.on(sig, handler);
}
const removeForwarders = () => {
for (const [sig, handler] of forwarders) process.off(sig, handler);
forwarders.clear();
};
return new Promise<number>((resolve, reject) => {
child.on('error', (err) => { removeForwarders(); reject(err); });
child.on('close', (code) => {
removeForwarders();
process.stdin.resume();
resolve(code ?? 1);
});
});
Design notes:
- Use a Map of
{signal → handler}so cleanup is precise, avoidingremoveAllListenerswhich would disturb other subscribers. - Remove forwarders on both
closeanderror— otherwise the listener count grows each relaunch iteration and Node logs a MaxListenersExceeded warning after ~10 relaunches. try/catcharoundchild.killguards the race where the signal arrives just after the child exits.stdio: 'inherit'is left unchanged; signal forwarding is orthogonal to the stdio wiring.detached: trueis intentionally not introduced — it would change PGID/foreground-tty semantics and break interactive Ctrl+C.
Downstream workaround
Setting GEMINI_CLI_NO_RELAUNCH=true skips the relaunch, which also
avoids the bug — but it disables the --max-old-space-size tuning that
relaunch was designed to apply. Fixing signal forwarding keeps both.
Environment
@google/gemini-cliv0.38.1- Node.js 20+
- Linux (observed under
systemd --user; reparenting target is the user manager rather than PID 1, which can mask the orphan in casualps -efinspection)
Happy to open a PR with the above diff + a test that asserts the child
receives SIGTERM when the parent is signalled.