Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
56 changes: 56 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,62 @@ 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('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
68 changes: 66 additions & 2 deletions packages/cli/src/utils/relaunch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,23 @@ 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.
// 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. Supervisors use
// SIGTERM for programmatic termination, which is forwarded.
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',
];


export async function relaunchOnExitCode(runner: () => Promise<number>) {
while (true) {
try {
Expand Down Expand Up @@ -71,11 +88,58 @@ 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.
}
};
forwarders.set(sig, handler);
// 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);
}
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);
}

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);
// Do NOT return here: a non-fatal signal (e.g. SIGUSR1) does not
// terminate this process, so we must still resolve the promise
// below. If the signal IS fatal, this process exits before
// resolve runs - which is the desired outcome.
} 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