google-gemini/gemini-cli

Bug: relaunchAppInChildProcess does not forward signals to child, orphaning it when parent is killed

Open

#25590 opened on Apr 17, 2026

View on GitHub
 (6 comments) (0 reactions) (1 assignee)TypeScript (103,992 stars) (13,657 forks)batch import
area/coreeffort/largehelp wantedkind/bugpriority/p2status/bot-triagedtype/bug

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, avoiding removeAllListeners which would disturb other subscribers.
  • Remove forwarders on both close and error — otherwise the listener count grows each relaunch iteration and Node logs a MaxListenersExceeded warning after ~10 relaunches.
  • try/catch around child.kill guards 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: true is 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-cli v0.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 casual ps -ef inspection)

Happy to open a PR with the above diff + a test that asserts the child receives SIGTERM when the parent is signalled.

Contributor guide