Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions packages/cli/src/utils/relaunch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,92 @@ describe('relaunchAppInChildProcess', () => {
// Should default to exit code 1
expect(processExitSpy).toHaveBeenCalledWith(1);
});

it('forwards termination signals to the child and removes them on close (#25590)', async () => {
process.argv = ['/usr/bin/node', '/app/cli.js'];

const mockChild = createMockChildProcess(0, false);
mockedSpawn.mockImplementation(() => mockChild);

const before = process.listenerCount('SIGTERM');
const killSpy = mockChild.kill as ReturnType<typeof vi.fn>;

// Start the relaunch process (does not auto-close, so it awaits).
const promise = relaunchAppInChildProcess([], []);

// Drain microtasks so the spawn + forwarder registration runs.
await new Promise((r) => setImmediate(r));

// A forwarder for SIGTERM was installed on the parent process.
expect(process.listenerCount('SIGTERM')).toBe(before + 1);

// Emulate the OS delivering SIGTERM: invoke the registered handler
// directly (process.emit would also fire unrelated listeners).
const handler = process.listeners('SIGTERM').at(-1) as
| (() => void)
| undefined;
expect(handler).toBeDefined();
handler!();
expect(killSpy).toHaveBeenCalledWith('SIGTERM');

// Closing the child must remove the forwarder (no listener leak).
mockChild.emit('close', 0);
await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED');
expect(process.listenerCount('SIGTERM')).toBe(before);
});
Comment on lines +349 to +380

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The signal propagation logic (process.kill) is currently untested because calling it directly would terminate the Vitest test runner. To ensure the robustness of the signal propagation and prevent accidental test runner termination in future tests, we should mock process.kill and add a dedicated test case to verify that the signal is correctly propagated to the parent process.

    it('forwards termination signals to the child and removes them on close (#25590)', async () => {
      process.argv = ['/usr/bin/node', '/app/cli.js'];

      const mockChild = createMockChildProcess(0, false);
      mockedSpawn.mockImplementation(() => mockChild);

      const before = process.listenerCount('SIGTERM');
      const killSpy = mockChild.kill as ReturnType<typeof vi.fn>;

      // Start the relaunch process (does not auto-close, so it awaits).
      const promise = relaunchAppInChildProcess([], []);

      // Drain microtasks so the spawn + forwarder registration runs.
      await new Promise((r) => setImmediate(r));

      // A forwarder for SIGTERM was installed on the parent process.
      expect(process.listenerCount('SIGTERM')).toBe(before + 1);

      // Emulate the OS delivering SIGTERM: invoke the registered handler
      // directly (process.emit would also fire unrelated listeners).
      const handler = process.listeners('SIGTERM').at(-1) as
        | (() => void)
        | undefined;
      expect(handler).toBeDefined();
      handler!();
      expect(killSpy).toHaveBeenCalledWith('SIGTERM');

      // Closing the child must remove the forwarder (no listener leak).
      mockChild.emit('close', 0);
      await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED');
      expect(process.listenerCount('SIGTERM')).toBe(before);
    });

    it('propagates the child signal termination to the parent process', async () => {
      process.argv = ['/usr/bin/node', '/app/cli.js'];

      const mockChild = createMockChildProcess(null, false);
      mockedSpawn.mockImplementation(() => mockChild);

      const processKillSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);

      const promise = relaunchAppInChildProcess([], []);
      await new Promise((r) => setImmediate(r));

      mockChild.emit('close', null, 'SIGTERM');
      await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED');
      expect(processKillSpy).toHaveBeenCalledWith(process.pid, 'SIGTERM');

      processKillSpy.mockRestore();
    });


it('keeps the parent alive on SIGINT (no-op) and removes it on close (#25590)', async () => {
process.argv = ['/usr/bin/node', '/app/cli.js'];

const mockChild = createMockChildProcess(0, false);
mockedSpawn.mockImplementation(() => mockChild);

const before = process.listenerCount('SIGINT');
const killSpy = mockChild.kill as ReturnType<typeof vi.fn>;

const promise = relaunchAppInChildProcess([], []);
await new Promise((r) => setImmediate(r));

// A no-op keepalive handler for SIGINT was installed on the parent.
expect(process.listenerCount('SIGINT')).toBe(before + 1);

// Emulate the TTY delivering SIGINT to the whole process group: the
// handler must NOT forward it to the child (it already received it).
const handler = process.listeners('SIGINT').at(-1) as
| (() => void)
| undefined;
expect(handler).toBeDefined();
handler!();
expect(killSpy).not.toHaveBeenCalledWith('SIGINT');

// Closing the child must remove the keepalive handler (no listener leak).
mockChild.emit('close', 0);
await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED');
expect(process.listenerCount('SIGINT')).toBe(before);
});

it('propagates the child signal termination to the parent process (#25590)', async () => {
process.argv = ['/usr/bin/node', '/app/cli.js'];

const mockChild = createMockChildProcess(0, false);
mockedSpawn.mockImplementation(() => mockChild);

// Mock process.kill so the re-raised signal does not terminate the
// Vitest runner; assert the parent re-raises the child's exit signal.
const processKillSpy = vi
.spyOn(process, 'kill')
.mockImplementation(() => true);

const promise = relaunchAppInChildProcess([], []);
await new Promise((r) => setImmediate(r));

// Child exits with a signal: the parent must re-raise it.
mockChild.emit('close', null, 'SIGTERM');
await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED');
expect(processKillSpy).toHaveBeenCalledWith(process.pid, 'SIGTERM');

processKillSpy.mockRestore();
});
});
});

Expand Down
105 changes: 103 additions & 2 deletions packages/cli/src/utils/relaunch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,30 @@ import {
type AdminControlsSettings,
} from '@google/gemini-cli-core';

// Signals a supervising process (ACP client, systemd, container runtime)
// may send to the bootstrap parent. Without forwarding, the parent dies on
// its default disposition while the spawned child is reparented to PID 1
// and keeps running - holding the OAuth session and allocated heap until
// killed manually. See #25590.
const FORWARDED_SIGNALS: readonly NodeJS.Signals[] = [
'SIGTERM',
'SIGHUP',
'SIGUSR1',
'SIGUSR2',
];
Comment on lines +23 to +28

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Forwarding SIGINT and SIGQUIT can break interactive graceful shutdown (e.g., via Ctrl+C or Ctrl+\).

When running interactively in a terminal, the TTY automatically delivers these signals to the entire foreground process group (both the parent and the child process). If the parent also forwards these signals, the child process will receive them twice.

If the child process registers a graceful shutdown handler using process.once('SIGINT'), the first signal (from the TTY) will trigger the handler and remove the listener. The second signal (forwarded from the parent) will then immediately trigger the default OS disposition (abrupt termination), preventing the child from completing its graceful shutdown.

Since supervisors primarily use SIGTERM for programmatic termination, we should exclude SIGINT and SIGQUIT from the forwarded signals list.

const FORWARDED_SIGNALS: readonly NodeJS.Signals[] = [
  'SIGTERM',
  'SIGHUP',
  'SIGUSR1',
  'SIGUSR2',
];


// SIGINT/SIGQUIT are intentionally NOT forwarded: when running
// interactively, the TTY delivers them to the whole foreground process
// group (parent and child), so forwarding would deliver them twice and
// could interrupt the child's graceful-shutdown handler. Instead the parent
// registers a no-op handler (KEEPALIVE_SIGNALS) so it does NOT die on its
// default disposition before the child finishes shutting down - which would
// orphan the child and return the shell prompt early with mixed output. The
// child receives the signal directly from the TTY; when it exits, the no-op
// handler is removed and the child's exit signal is re-raised on the parent.
// Supervisors use SIGTERM for programmatic termination, which is forwarded.
const KEEPALIVE_SIGNALS: readonly NodeJS.Signals[] = ['SIGINT', 'SIGQUIT'];

export async function relaunchOnExitCode(runner: () => Promise<number>) {
while (true) {
try {
Expand Down Expand Up @@ -71,11 +95,88 @@ export async function relaunchAppInChildProcess(
}
});

// Forward termination signals to the child so a supervised parent
// (kill -TERM <bootstrap-pid>) takes the child down with it instead of
// orphaning it. Use a Map of {signal -> handler} for precise cleanup on
// close/error; removeAllListeners would disturb unrelated subscribers,
// and leaking a handler per relaunch iteration trips
// MaxListenersExceededWarning after ~10 relaunches. #25590.
const forwarders = new Map<NodeJS.Signals, () => void>();
for (const sig of FORWARDED_SIGNALS) {
const handler = () => {
try {
child.kill(sig);
} catch {
// The child may have already exited; ignore the race.
}
};
// Register only signals the runtime supports - some POSIX-only signals
// (e.g. SIGUSR1/SIGUSR2 on Windows) throw in process.on(). Register
// first and only track successful listeners in the Map, since
// removeForwarders calls process.off on every entry. #25590.
try {
// Use on() so that if the child is slow to shut down and a second
// signal is received, it is still forwarded rather than killing the
// parent immediately and orphaning the child. The listeners are
// removed on child close/error anyway. #25590.
process.on(sig, handler);
forwarders.set(sig, handler);
} catch {
// Signal unsupported on this platform; skip forwarding for it.
}
}
// No-op handlers for the interactive interrupt signals the TTY delivers to
// the whole foreground process group. The parent must survive long enough
// for the child to finish its graceful shutdown; otherwise it dies on the
// default disposition and the child is orphaned. The no-op is removed in
// the close handler before the child's exit signal is re-raised, so the
// re-raise still terminates the parent. #25590.
for (const sig of KEEPALIVE_SIGNALS) {
// Reuse the same handler reference for both the Map and the listener so
// removeForwarders can process.off() it on close.
const handler = () => {};
try {
process.on(sig, handler);
forwarders.set(sig, handler);
} catch {
// Signal unsupported on this platform (e.g. SIGQUIT on Windows).
}
}
Comment on lines +104 to +127

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using process.on to register signal forwarders can lead to an unresponsive CLI if the child process hangs or takes too long to clean up. For example, if a user presses Ctrl+C (SIGINT) and the child process is deadlocked, pressing Ctrl+C again will continue to be intercepted by the parent and forwarded, preventing the user from force-terminating the CLI from the terminal.

By changing process.on to process.once, the first signal is forwarded to the child to allow graceful shutdown. If the child hangs and the user sends the signal a second time, the default signal disposition (immediate termination) will take over, allowing the user to force-quit the process.

Suggested change
const forwarders = new Map<NodeJS.Signals, () => void>();
for (const sig of FORWARDED_SIGNALS) {
const handler = () => {
try {
child.kill(sig);
} catch {
// The child may have already exited; ignore the race.
}
};
forwarders.set(sig, handler);
process.on(sig, handler);
}
const forwarders = new Map<NodeJS.Signals, () => void>();
for (const sig of FORWARDED_SIGNALS) {
const handler = () => {
try {
child.kill(sig);
} catch {
// The child may have already exited; ignore the race.
}
};
forwarders.set(sig, handler);
process.once(sig, handler);
}

Comment on lines +104 to +144

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

On Windows, Node.js does not support several POSIX signals such as SIGUSR1, SIGUSR2, and SIGQUIT. Attempting to register a listener for these unsupported signals using process.on() will throw a runtime error (e.g., Error: EINVAL: invalid argument, uv_signal_start or ERR_UNKNOWN_SIGNAL), which will crash the CLI during relaunch on Windows.

To ensure cross-platform compatibility and prevent crashes on Windows, wrap the process.on() registration in a try/catch block, and only add the signal to the forwarders map if it was successfully registered.

    const forwarders = new Map<NodeJS.Signals, () => void>();
    for (const sig of FORWARDED_SIGNALS) {
      const handler = () => {
        try {
          child.kill(sig);
        } catch {
          // The child may have already exited; ignore the race.
        }
      };
      try {
        process.on(sig, handler);
        forwarders.set(sig, handler);
      } catch {
        // Signal not supported on this platform (e.g., SIGUSR1/2 on Windows)
      }
    }
    // No-op handlers for the interactive interrupt signals the TTY delivers to
    // the whole foreground process group. The parent must survive long enough
    // for the child to finish its graceful shutdown; otherwise it dies on the
    // default disposition and the child is orphaned. The no-op is removed in
    // the close handler before the child's exit signal is re-raised, so the
    // re-raise still terminates the parent. #25590.
    for (const sig of KEEPALIVE_SIGNALS) {
      // Reuse the same handler reference for both the Map and the listener so
      // removeForwarders can process.off() it on close.
      const handler = () => {};
      try {
        process.on(sig, handler);
        forwarders.set(sig, handler);
      } catch {
        // Signal not supported on this platform (e.g., SIGQUIT on Windows)
      }
    }

const removeForwarders = () => {
for (const [sig, handler] of forwarders) {
process.off(sig, handler);
}
forwarders.clear();
};
Comment on lines +98 to +150

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

While SIGINT and SIGQUIT are intentionally not forwarded to prevent duplicate signal delivery in interactive TTY sessions, the parent process itself currently has no listeners for these signals. As a result, when a user presses Ctrl+C (sending SIGINT to the entire process group), the parent process will terminate immediately on its default disposition. This causes the shell prompt to return prematurely while the child process is still running its graceful shutdown handlers in the background, leading to mixed terminal output and orphaned child processes during shutdown.

To prevent this, we should register no-op handlers for SIGINT and SIGQUIT on the parent process during the child's lifetime. This keeps the parent process alive, allowing it to wait for the child process to exit cleanly before exiting itself.


return new Promise<number>((resolve, reject) => {
child.on('error', reject);
child.on('close', (code) => {
child.on('error', (err) => {
removeForwarders();
reject(err);
});
child.on('close', (code, signal) => {
removeForwarders();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When the child process is terminated by a signal, the parent process currently exits with code 1 (via resolve(code ?? 1) because code is null when killed by a signal).

This misrepresents the termination status to supervisors (like systemd or Kubernetes), which may interpret exit code 1 as an unexpected application crash rather than a clean signal termination.

To propagate the signal termination status correctly, the parent process should kill itself with the same signal after removing the forwarders.

Since we cannot directly modify the outer child.on('close', ...) signature via a targeted suggestion here without touching context lines, please consider refactoring the handler to capture the signal parameter and propagate it:

      child.on('close', (code, signal) => {
        removeForwarders();
        // Resume stdin before the parent process exits.
        process.stdin.resume();
        if (signal) {
          try {
            process.kill(process.pid, signal);
            return;
          } catch {
            // Fallback if process.kill is not supported
          }
        }
        resolve(code ?? 1);
      });

// Resume stdin before the parent process exits.
process.stdin.resume();
// Propagate the child's signal termination so supervisors (systemd,
// Kubernetes) see a clean signal exit rather than an unexpected code
// 1 crash. #25590.
if (signal) {
try {
process.kill(process.pid, signal);
// process.kill schedules the signal on the event loop; it does NOT
// terminate synchronously when a JS handler is registered (e.g. by
// the app or a supervisor integration), so deferring resolve gives
// the loop a tick to deliver the re-raised signal before
// relaunchOnExitCode calls process.exit(). A fatal signal
// terminates the process here; a non-fatal one (e.g. SIGUSR1) falls
// through to resolve below. #25590.
setTimeout(() => resolve(code ?? 1), 0);
return;
} catch {
// Fall back to exit code 1 if the signal cannot be re-raised.
}
}
Comment on lines +164 to +179

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If process.kill(process.pid, signal) is successful but does not terminate the parent process (for example, with non-fatal signals like SIGUSR1 which Node.js uses to start the debugger, or if the signal is ignored/handled), the return; statement will prevent the promise from resolving, causing the bootstrap parent process to hang indefinitely.

Since process.kill is synchronous, if it successfully terminates the process, the execution stops immediately. If it does not terminate the process, we should allow the execution to continue and resolve the promise normally. Removing the return; statement resolves this issue safely.

        if (signal) {
          try {
            process.kill(process.pid, signal);
          } catch {
            // Fall back to exit code 1 if the signal cannot be re-raised.
          }
        }

resolve(code ?? 1);
Comment on lines +164 to 180

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Because JavaScript is single-threaded, calling process.kill(process.pid, signal) schedules the signal delivery asynchronously on the event loop. However, the current synchronous execution context (resolving the promise, returning to relaunchOnExitCode, and immediately calling process.exit(exitCode)) runs to completion first. Since process.exit() terminates the process synchronously, the event loop never gets a chance to process the pending signal, and the parent process exits with the exit code instead of being terminated by the signal.

To fix this, we should defer the promise resolution using setTimeout when a signal is propagated. This allows the event loop to run and process the fatal signal (terminating the process). If the signal is non-fatal, the timeout will fire and resolve the promise normally.

        if (signal) {
          try {
            process.kill(process.pid, signal);
            // Defer resolution to allow the event loop to process the signal.
            // If the signal is fatal, the process will terminate before the timeout fires.
            // If it is non-fatal, we resolve with code ?? 1 after the timeout.
            setTimeout(() => {
              resolve(code ?? 1);
            }, 20);
            return;
          } catch {
            // Fall back to exit code 1 if the signal cannot be re-raised.
          }
        }
        resolve(code ?? 1);

});
});
Expand Down