google-gemini / google-gemini/gemini-cli
Bug: relaunchAppInChildProcess does not forward signals to child, orphaning it when parent is killed
- Dominant language
- TypeScript
- Stars
- 107k
- Forks
- 14.6k
- Avg merge
- 2d 3h
- Merged PRs (30d)
- 45
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
```bash
# 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
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:
```ts
const FORWARDED_SIGNALS: readonly NodeJS.Signals[] = [
'SIGTERM', 'SIGHUP', 'SIGINT', 'SIGQUIT', 'SIGUSR1', 'SIGUSR2',
];
const forwarders = new Map 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((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
Assessment
This issue has not been assessed yet.