gemini-cli - ✅(Solved) Fix Bug: relaunchAppInChildProcess does not forward signals to child, orphaning it when parent is killed [1 pull requests, 2 comments, 3 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
google-gemini/gemini-cli#25590Fetched 2026-04-18 05:57:38
View on GitHub
Comments
2
Participants
3
Timeline
9
Reactions
0
Author
Timeline (top)
labeled ×3commented ×2referenced ×2cross-referenced ×1

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.

Error Message

Install forwarders before awaiting the child, remove them on close/error to child.on('error', (err) => { removeForwarders(); reject(err); });

  • Remove forwarders on both close and error — otherwise the listener

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.

Fix Action

Fix / Workaround

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.

PR fix notes

PR #25605: fix(cli): forward termination signals to relaunched child process

Description (problem / solution / changelog)

Summary

relaunchAppInChildProcess spawns a full-memory child via node:child_process.spawn, but the bootstrap parent did 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), the bootstrap exits but the child is reparented to PID 1 / the user's systemd --user manager and keeps running, holding the OAuth session and allocated heap indefinitely.

This PR installs forwarders for the standard termination signals before awaiting the child, and removes them on close/error to avoid listener leaks across relaunch iterations.

Closes #25590

Reproduction (pre-patch)

gemini -m gemini-3.1-pro-preview -y --acp
# Separate terminal:
ps -eo pid,ppid,pgid,cmd | grep gemini   # observe bootstrap + child
kill -TERM <bootstrap-pid>
ps -eo pid,ppid,pgid,cmd | grep gemini   # child survives, PPID=1

Interactive Ctrl+C does not surface the bug because SIGINT is delivered to the foreground process group by the controlling tty. Only programmatic kill(pid, signal) against the parent exposes the leak — which is the normal path for any supervisor.

Root cause

packages/cli/src/utils/relaunch.ts, function relaunchAppInChildProcess:

  • The runner closure spawns the child and awaits close, but never calls process.on('SIGTERM'/'SIGHUP'/...) to proxy signals to child.kill(sig).
  • The parent therefore dies on its default signal disposition while the child keeps running.

Fix

Install a Map<NodeJS.Signals, handler> of forwarders for SIGTERM, SIGHUP, SIGINT, SIGQUIT, SIGUSR1, SIGUSR2 immediately after spawn. Each handler calls child.kill(sig) inside try/catch to tolerate the race where the child has already exited. The forwarders are removed on both child.on('close') and child.on('error') so that the per-iteration listener count stays bounded (otherwise Node logs a MaxListenersExceeded warning after ~10 relaunches).

Design notes:

  • Using a Map<signal, handler> rather than removeAllListeners keeps cleanup precise and avoids disturbing any other listeners the process may have installed.
  • detached: true is intentionally not introduced — it would change PGID/foreground-tty semantics and could break interactive Ctrl+C.
  • stdio: 'inherit' is unchanged; signal forwarding is orthogonal to stdio wiring.

Tests

Added two tests in packages/cli/src/utils/relaunch.test.ts:

  1. should forward termination signals to the child and clean up listeners on close — verifies:
    • After spawn, each of the six forwarded signals has exactly one additional listener on process.
    • Emitting SIGTERM on the parent triggers child.kill('SIGTERM').
    • After the child closes, all signal listener counts return to their baselines.
  2. should clean up signal listeners on child process error — verifies that if the child emits error, listeners are still removed (no leak on failure path).

All tests pass locally (vitest run packages/cli/src/utils/relaunch.test.ts: 10/10).

Downstream workaround (for users on older versions)

Setting GEMINI_CLI_NO_RELAUNCH=true skips the relaunch, which also avoids the bug — but it disables the --max-old-space-size tuning that the relaunch is designed to apply. This PR fixes the underlying issue so both features work together.

Compatibility

  • No public API changes.
  • No change to interactive behavior (Ctrl+C still routed via tty).
  • No change to stdio wiring or IPC messaging.

Changed files

  • packages/cli/src/utils/relaunch.test.ts (modified, +114/-0)
  • packages/cli/src/utils/relaunch.ts (modified, +46/-1)

Code Example

# 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)

---

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);
  });
});
RAW_BUFFERClick to expand / collapse

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.

extent analysis

TL;DR

Install signal forwarders in the relaunchAppInChildProcess function to ensure the child process receives termination signals when the parent process is signalled.

Guidance

  • The proposed fix involves installing forwarders for signals like SIGTERM, SIGHUP, SIGINT, SIGQUIT, SIGUSR1, and SIGUSR2 before awaiting the child process, and removing them on close or error to avoid listener leaks.
  • Verify the fix by running the reproduction steps and checking that the child process is terminated when the parent process receives a signal.
  • To mitigate the issue temporarily, set GEMINI_CLI_NO_RELAUNCH=true to skip the relaunch, but note that this disables the --max-old-space-size tuning.
  • Review the relaunch.ts file and ensure that the signal forwarders are properly installed and removed to prevent listener leaks.

Example

The proposed fix provides a code snippet that demonstrates how to install and remove signal forwarders:

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);
}

Notes

The fix assumes that the relaunchAppInChildProcess function is the root cause of the issue, and that installing signal forwarders will resolve the problem. However, additional testing and verification may be necessary to ensure that the fix works as expected in all scenarios.

Recommendation

Apply the proposed fix by installing signal forwarders in the relaunchAppInChildProcess function, as this will ensure that the child process receives termination signals when the parent process is signalled, and will also allow the --max-old-space-size tuning to be applied.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

gemini-cli - ✅(Solved) Fix Bug: relaunchAppInChildProcess does not forward signals to child, orphaning it when parent is killed [1 pull requests, 2 comments, 3 participants]