From 7e1705274cb89400e2534b2f42e2c4ad1647db25 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Mon, 22 Sep 2025 19:48:25 -0700 Subject: [PATCH] Refactor to defer initialization. (#8925) --- packages/cli/src/config/config.ts | 17 +- packages/cli/src/gemini.test.tsx | 82 +++++- packages/cli/src/gemini.tsx | 166 +++++------- packages/cli/src/utils/relaunch.test.ts | 345 ++++++++++++++++++++++++ packages/cli/src/utils/relaunch.ts | 68 +++++ 5 files changed, 572 insertions(+), 106 deletions(-) create mode 100644 packages/cli/src/utils/relaunch.test.ts create mode 100644 packages/cli/src/utils/relaunch.ts diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index acf1b8ca93..6b11abd427 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -396,6 +396,15 @@ export async function loadHierarchicalGeminiMemory( ); } +export function isDebugMode(argv: CliArgs): boolean { + return ( + argv.debug || + [process.env['DEBUG'], process.env['DEBUG_MODE']].some( + (v) => v === 'true' || v === '1', + ) + ); +} + export async function loadCliConfig( settings: Settings, extensions: Extension[], @@ -403,12 +412,8 @@ export async function loadCliConfig( argv: CliArgs, cwd: string = process.cwd(), ): Promise { - const debugMode = - argv.debug || - [process.env['DEBUG'], process.env['DEBUG_MODE']].some( - (v) => v === 'true' || v === '1', - ) || - false; + const debugMode = isDebugMode(argv); + const memoryImportFormat = settings.context?.importFormat || 'tree'; const ideMode = settings.ide?.enabled ?? false; diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 7e6df99ca9..4bad3fd980 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -44,8 +44,10 @@ vi.mock('./config/config.js', () => ({ loadCliConfig: vi.fn().mockResolvedValue({ getSandbox: vi.fn(() => false), getQuestion: vi.fn(() => ''), + isInteractive: () => false, } as unknown as Config), parseArguments: vi.fn().mockResolvedValue({}), + isDebugMode: vi.fn(() => false), })); vi.mock('read-package-up', () => ({ @@ -76,18 +78,20 @@ vi.mock('./utils/sandbox.js', () => ({ start_sandbox: vi.fn(() => Promise.resolve()), // Mock as an async function that resolves })); +vi.mock('./utils/relaunch.js', () => ({ + relaunchAppInChildProcess: vi.fn(), +})); + +vi.mock('./config/sandboxConfig.js', () => ({ + loadSandboxConfig: vi.fn(), +})); + describe('gemini.tsx main function', () => { let originalEnvGeminiSandbox: string | undefined; let originalEnvSandbox: string | undefined; let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] = []; - const processExitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((code) => { - throw new MockProcessExitError(code); - }); - beforeEach(() => { // Store and clear sandbox-related env variables to ensure a consistent test environment originalEnvGeminiSandbox = process.env['GEMINI_SANDBOX']; @@ -123,7 +127,73 @@ describe('gemini.tsx main function', () => { vi.restoreAllMocks(); }); + it('verifies that we dont load the config before relaunchAppInChildProcess', async () => { + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + const { relaunchAppInChildProcess } = await import('./utils/relaunch.js'); + const { loadCliConfig } = await import('./config/config.js'); + const { loadSettings } = await import('./config/settings.js'); + const { loadSandboxConfig } = await import('./config/sandboxConfig.js'); + vi.mocked(loadSandboxConfig).mockResolvedValue(undefined); + + const callOrder: string[] = []; + vi.mocked(relaunchAppInChildProcess).mockImplementation(async () => { + callOrder.push('relaunch'); + }); + vi.mocked(loadCliConfig).mockImplementation(async () => { + callOrder.push('loadCliConfig'); + return { + isInteractive: () => false, + getQuestion: () => '', + getSandbox: () => false, + getDebugMode: () => false, + getListExtensions: () => false, + getMcpServers: () => ({}), + initialize: vi.fn(), + getIdeMode: () => false, + getExperimentalZedIntegration: () => false, + getScreenReader: () => false, + getGeminiMdFileCount: () => 0, + getProjectRoot: () => '/', + } as unknown as Config; + }); + vi.mocked(loadSettings).mockReturnValue({ + errors: [], + merged: { + advanced: { autoConfigureMemory: true }, + security: { auth: {} }, + ui: {}, + }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + } as never); + try { + await main(); + } catch (e) { + // Mocked process exit throws an error. + if (!(e instanceof MockProcessExitError)) throw e; + } + + // It is critical that we call relaunch before loadCliConfig to avoid + // loading config in the outer process when we are going to relaunch. + // By ensuring we don't load the config we also ensure we don't trigger any + // operations that might require loading the config such as such as + // initializing mcp servers. + // For the sandbox case we still have to load a partial cli config. + // we can authorize outside the sandbox. + expect(callOrder).toEqual(['relaunch', 'loadCliConfig']); + processExitSpy.mockRestore(); + }); + it('should log unhandled promise rejections and open debug console on first error', async () => { + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); const appEventsMock = vi.mocked(appEvents); const rejectionError = new Error('Test unhandled rejection'); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 03e98b4362..d31e29f898 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -8,15 +8,14 @@ import React from 'react'; import { render } from 'ink'; import { AppContainer } from './ui/AppContainer.js'; import { loadCliConfig, parseArguments } from './config/config.js'; +import * as cliConfig from './config/config.js'; import { readStdin } from './utils/readStdin.js'; import { basename } from 'node:path'; import v8 from 'node:v8'; import os from 'node:os'; import dns from 'node:dns'; -import { spawn } from 'node:child_process'; import { start_sandbox } from './utils/sandbox.js'; import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js'; -import { RELAUNCH_EXIT_CODE } from './utils/processUtils.js'; import { loadSettings, migrateDeprecatedSettings, @@ -58,6 +57,10 @@ import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; import { VimModeProvider } from './ui/contexts/VimModeContext.js'; import { KeypressProvider } from './ui/contexts/KeypressContext.js'; import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; +import { + relaunchAppInChildProcess, + relaunchOnExitCode, +} from './utils/relaunch.js'; export function validateDnsResolutionOrder( order: string | undefined, @@ -76,7 +79,7 @@ export function validateDnsResolutionOrder( return defaultValue; } -function getNodeMemoryArgs(config: Config): string[] { +function getNodeMemoryArgs(isDebugMode: boolean): string[] { const totalMemoryMB = os.totalmem() / (1024 * 1024); const heapStats = v8.getHeapStatistics(); const currentMaxOldSpaceSizeMb = Math.floor( @@ -85,7 +88,7 @@ function getNodeMemoryArgs(config: Config): string[] { // Set target to 50% of total memory const targetMaxOldSpaceSizeInMB = Math.floor(totalMemoryMB * 0.5); - if (config.getDebugMode()) { + if (isDebugMode) { console.debug( `Current heap size ${currentMaxOldSpaceSizeMb.toFixed(2)} MB`, ); @@ -96,7 +99,7 @@ function getNodeMemoryArgs(config: Config): string[] { } if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) { - if (config.getDebugMode()) { + if (isDebugMode) { console.debug( `Need to relaunch with more memory: ${targetMaxOldSpaceSizeInMB.toFixed(2)} MB`, ); @@ -107,53 +110,8 @@ function getNodeMemoryArgs(config: Config): string[] { return []; } -async function relaunchOnExitCode(runner: () => Promise) { - while (true) { - try { - const exitCode = await runner(); - - if (exitCode !== RELAUNCH_EXIT_CODE) { - process.exit(exitCode); - } - } catch (error) { - process.stdin.resume(); - console.error('Fatal error: Failed to relaunch the CLI process.', error); - process.exit(1); - } - } -} - -async function relaunchAppInChildProcess(additionalArgs: string[]) { - if (process.env['GEMINI_CLI_NO_RELAUNCH']) { - return; - } - - const runner = () => { - const nodeArgs = [...additionalArgs, ...process.argv.slice(1)]; - const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' }; - - // The parent process should not be reading from stdin while the child is running. - process.stdin.pause(); - - const child = spawn(process.execPath, nodeArgs, { - stdio: 'inherit', - env: newEnv, - }); - - return new Promise((resolve, reject) => { - child.on('error', reject); - child.on('close', (code) => { - // Resume stdin before the parent process exits. - process.stdin.resume(); - resolve(code ?? 1); - }); - }); - }; - - await relaunchOnExitCode(runner); -} - import { runZedIntegration } from './zed-integration/zedIntegration.js'; +import { loadSandboxConfig } from './config/sandboxConfig.js'; export function setupUnhandledRejectionHandler() { let unhandledRejectionOccurred = false; @@ -248,13 +206,6 @@ export async function main() { await cleanupCheckpoints(); const argv = await parseArguments(settings.merged); - const extensions = loadExtensions(); - const config = await loadCliConfig( - settings.merged, - extensions, - sessionId, - argv, - ); // Check for invalid input combinations early to prevent crashes if (argv.promptInteractive && !process.stdin.isTTY) { @@ -264,28 +215,10 @@ export async function main() { process.exit(1); } - const wasRaw = process.stdin.isRaw; - let kittyProtocolDetectionComplete: Promise | undefined; - if (config.isInteractive() && !wasRaw && process.stdin.isTTY) { - // Set this as early as possible to avoid spurious characters from - // input showing up in the output. - process.stdin.setRawMode(true); - - // This cleanup isn't strictly needed but may help in certain situations. - process.on('SIGTERM', () => { - process.stdin.setRawMode(wasRaw); - }); - process.on('SIGINT', () => { - process.stdin.setRawMode(wasRaw); - }); - - // Detect and enable Kitty keyboard protocol once at startup. - kittyProtocolDetectionComplete = detectAndEnableKittyProtocol(); - } - + const isDebugMode = cliConfig.isDebugMode(argv); const consolePatcher = new ConsolePatcher({ stderr: true, - debugMode: config.getDebugMode(), + debugMode: isDebugMode, }); consolePatcher.patch(); registerCleanup(consolePatcher.cleanup); @@ -294,14 +227,6 @@ export async function main() { validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder), ); - if (config.getListExtensions()) { - console.log('Installed extensions:'); - for (const extension of extensions) { - console.log(`- ${extension.config.name}`); - } - process.exit(0); - } - // Set a default auth type if one isn't set. if (!settings.merged.security?.auth?.selectedType) { if (process.env['CLOUD_SHELL'] === 'true') { @@ -313,8 +238,6 @@ export async function main() { } } - setMaxSizedBoxDebugging(config.getDebugMode()); - // Load custom themes from settings themeManager.loadCustomThemes(settings.merged.ui?.customThemes); @@ -326,14 +249,13 @@ export async function main() { } } - const initializationResult = await initializeApp(config, settings); - // hop into sandbox if we are outside and sandboxing is enabled if (!process.env['SANDBOX']) { const memoryArgs = settings.merged.advanced?.autoConfigureMemory - ? getNodeMemoryArgs(config) + ? getNodeMemoryArgs(isDebugMode) : []; - const sandboxConfig = config.getSandbox(); + const sandboxConfig = await loadSandboxConfig(settings.merged, argv); + if (sandboxConfig) { if ( settings.merged.security?.auth?.selectedType && @@ -347,7 +269,21 @@ export async function main() { if (err) { throw new Error(err); } - await config.refreshAuth(settings.merged.security.auth.selectedType); + // We intentially omit the list of extensions here because extensions + // should not impact auth. + // TODO(jacobr): refactor loadCliConfig so there is a minimal version + // that only initializes enough config to enable refreshAuth or find + // another way to decouple refreshAuth from requiring a config. + const partialConfig = await loadCliConfig( + settings.merged, + [], + sessionId, + argv, + ); + + await partialConfig.refreshAuth( + settings.merged.security.auth.selectedType, + ); } catch (err) { console.error('Error authenticating:', err); process.exit(1); @@ -390,10 +326,52 @@ export async function main() { } else { // Relaunch app so we always have a child process that can be internally // restarted if needed. - await relaunchAppInChildProcess(memoryArgs); + await relaunchAppInChildProcess(memoryArgs, []); } } + // We are now past the logic handling potentially launching a child process + // to run Gemini CLI. It is now safe to perform expensive initialization that + // may have side effects. + const extensions = loadExtensions(); + const config = await loadCliConfig( + settings.merged, + extensions, + sessionId, + argv, + ); + + if (config.getListExtensions()) { + console.log('Installed extensions:'); + for (const extension of extensions) { + console.log(`- ${extension.config.name}`); + } + process.exit(0); + } + + const wasRaw = process.stdin.isRaw; + let kittyProtocolDetectionComplete: Promise | undefined; + if (config.isInteractive() && !wasRaw && process.stdin.isTTY) { + // Set this as early as possible to avoid spurious characters from + // input showing up in the output. + process.stdin.setRawMode(true); + + // This cleanup isn't strictly needed but may help in certain situations. + process.on('SIGTERM', () => { + process.stdin.setRawMode(wasRaw); + }); + process.on('SIGINT', () => { + process.stdin.setRawMode(wasRaw); + }); + + // Detect and enable Kitty keyboard protocol once at startup. + kittyProtocolDetectionComplete = detectAndEnableKittyProtocol(); + } + + setMaxSizedBoxDebugging(isDebugMode); + + const initializationResult = await initializeApp(config, settings); + if ( settings.merged.security?.auth?.selectedType === AuthType.LOGIN_WITH_GOOGLE && diff --git a/packages/cli/src/utils/relaunch.test.ts b/packages/cli/src/utils/relaunch.test.ts new file mode 100644 index 0000000000..e627a07a43 --- /dev/null +++ b/packages/cli/src/utils/relaunch.test.ts @@ -0,0 +1,345 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + type MockInstance, +} from 'vitest'; +import { EventEmitter } from 'node:events'; +import { RELAUNCH_EXIT_CODE } from './processUtils.js'; +import type { ChildProcess } from 'node:child_process'; +import { spawn } from 'node:child_process'; + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: vi.fn(), + }; +}); + +const mockedSpawn = vi.mocked(spawn); + +// Import the functions initially +import { relaunchAppInChildProcess, relaunchOnExitCode } from './relaunch.js'; + +describe('relaunchOnExitCode', () => { + let processExitSpy: MockInstance; + let consoleErrorSpy: MockInstance; + let stdinResumeSpy: MockInstance; + + beforeEach(() => { + processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('PROCESS_EXIT_CALLED'); + }); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + stdinResumeSpy = vi + .spyOn(process.stdin, 'resume') + .mockImplementation(() => process.stdin); + vi.clearAllMocks(); + }); + + afterEach(() => { + processExitSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + stdinResumeSpy.mockRestore(); + }); + + it('should exit with non-RELAUNCH_EXIT_CODE', async () => { + const runner = vi.fn().mockResolvedValue(0); + + await expect(relaunchOnExitCode(runner)).rejects.toThrow( + 'PROCESS_EXIT_CALLED', + ); + + expect(runner).toHaveBeenCalledTimes(1); + expect(processExitSpy).toHaveBeenCalledWith(0); + }); + + it('should continue running when RELAUNCH_EXIT_CODE is returned', async () => { + let callCount = 0; + const runner = vi.fn().mockImplementation(async () => { + callCount++; + if (callCount === 1) return RELAUNCH_EXIT_CODE; + if (callCount === 2) return RELAUNCH_EXIT_CODE; + return 0; // Exit on third call + }); + + await expect(relaunchOnExitCode(runner)).rejects.toThrow( + 'PROCESS_EXIT_CALLED', + ); + + expect(runner).toHaveBeenCalledTimes(3); + expect(processExitSpy).toHaveBeenCalledWith(0); + }); + + it('should handle runner errors', async () => { + const error = new Error('Runner failed'); + const runner = vi.fn().mockRejectedValue(error); + + await expect(relaunchOnExitCode(runner)).rejects.toThrow( + 'PROCESS_EXIT_CALLED', + ); + + expect(runner).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Fatal error: Failed to relaunch the CLI process.', + error, + ); + expect(stdinResumeSpy).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); +}); + +describe('relaunchAppInChildProcess', () => { + let processExitSpy: MockInstance; + let consoleErrorSpy: MockInstance; + let stdinPauseSpy: MockInstance; + let stdinResumeSpy: MockInstance; + + // Store original values to restore later + const originalEnv = { ...process.env }; + const originalExecArgv = [...process.execArgv]; + const originalArgv = [...process.argv]; + const originalExecPath = process.execPath; + + beforeEach(() => { + vi.clearAllMocks(); + + process.env = { ...originalEnv }; + delete process.env['GEMINI_CLI_NO_RELAUNCH']; + + process.execArgv = [...originalExecArgv]; + process.argv = [...originalArgv]; + process.execPath = '/usr/bin/node'; + + processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('PROCESS_EXIT_CALLED'); + }); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + stdinPauseSpy = vi + .spyOn(process.stdin, 'pause') + .mockImplementation(() => process.stdin); + stdinResumeSpy = vi + .spyOn(process.stdin, 'resume') + .mockImplementation(() => process.stdin); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + process.execArgv = [...originalExecArgv]; + process.argv = [...originalArgv]; + process.execPath = originalExecPath; + + processExitSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + stdinPauseSpy.mockRestore(); + stdinResumeSpy.mockRestore(); + }); + + describe('when GEMINI_CLI_NO_RELAUNCH is set', () => { + it('should return early without spawning a child process', async () => { + process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true'; + + await relaunchAppInChildProcess(['--test'], ['--verbose']); + + expect(mockedSpawn).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + }); + + describe('when GEMINI_CLI_NO_RELAUNCH is not set', () => { + beforeEach(() => { + delete process.env['GEMINI_CLI_NO_RELAUNCH']; + }); + + it('should construct correct node arguments from execArgv, additionalNodeArgs, script, additionalScriptArgs, and argv', () => { + // Test the argument construction logic directly by extracting it into a testable function + // This tests the same logic that's used in relaunchAppInChildProcess + + // Setup test data to verify argument ordering + const mockExecArgv = ['--inspect=9229', '--trace-warnings']; + const mockArgv = [ + '/usr/bin/node', + '/path/to/cli.js', + 'command', + '--flag=value', + '--verbose', + ]; + const additionalNodeArgs = [ + '--max-old-space-size=4096', + '--experimental-modules', + ]; + const additionalScriptArgs = ['--model', 'gemini-1.5-pro', '--debug']; + + // Extract the argument construction logic from relaunchAppInChildProcess + const script = mockArgv[1]; + const scriptArgs = mockArgv.slice(2); + + const nodeArgs = [ + ...mockExecArgv, + ...additionalNodeArgs, + script, + ...additionalScriptArgs, + ...scriptArgs, + ]; + + // Verify the argument construction follows the expected pattern: + // [...process.execArgv, ...additionalNodeArgs, script, ...additionalScriptArgs, ...scriptArgs] + const expectedArgs = [ + // Original node execution arguments + '--inspect=9229', + '--trace-warnings', + // Additional node arguments passed to function + '--max-old-space-size=4096', + '--experimental-modules', + // The script path + '/path/to/cli.js', + // Additional script arguments passed to function + '--model', + 'gemini-1.5-pro', + '--debug', + // Original script arguments (everything after the script in process.argv) + 'command', + '--flag=value', + '--verbose', + ]; + + expect(nodeArgs).toEqual(expectedArgs); + }); + + it('should handle empty additional arguments correctly', () => { + // Test edge cases with empty arrays + const mockExecArgv = ['--trace-warnings']; + const mockArgv = ['/usr/bin/node', '/app/cli.js', 'start']; + const additionalNodeArgs: string[] = []; + const additionalScriptArgs: string[] = []; + + // Extract the argument construction logic + const script = mockArgv[1]; + const scriptArgs = mockArgv.slice(2); + + const nodeArgs = [ + ...mockExecArgv, + ...additionalNodeArgs, + script, + ...additionalScriptArgs, + ...scriptArgs, + ]; + + const expectedArgs = ['--trace-warnings', '/app/cli.js', 'start']; + + expect(nodeArgs).toEqual(expectedArgs); + }); + + it('should handle complex argument patterns', () => { + // Test with various argument types including flags with values, boolean flags, etc. + const mockExecArgv = ['--max-old-space-size=8192']; + const mockArgv = [ + '/usr/bin/node', + '/cli.js', + '--config=/path/to/config.json', + '--verbose', + 'subcommand', + '--output', + 'file.txt', + ]; + const additionalNodeArgs = ['--inspect-brk=9230']; + const additionalScriptArgs = ['--model=gpt-4', '--temperature=0.7']; + + const script = mockArgv[1]; + const scriptArgs = mockArgv.slice(2); + + const nodeArgs = [ + ...mockExecArgv, + ...additionalNodeArgs, + script, + ...additionalScriptArgs, + ...scriptArgs, + ]; + + const expectedArgs = [ + '--max-old-space-size=8192', + '--inspect-brk=9230', + '/cli.js', + '--model=gpt-4', + '--temperature=0.7', + '--config=/path/to/config.json', + '--verbose', + 'subcommand', + '--output', + 'file.txt', + ]; + + expect(nodeArgs).toEqual(expectedArgs); + }); + + // Note: Additional integration tests for spawn behavior are complex due to module mocking + // limitations with ES modules. The core logic is tested in relaunchOnExitCode tests. + + it('should handle null exit code from child process', async () => { + process.argv = ['/usr/bin/node', '/app/cli.js']; + + const mockChild = createMockChildProcess(0, false); // Don't auto-close + mockedSpawn.mockImplementation(() => { + // Emit close with null code immediately + setImmediate(() => { + mockChild.emit('close', null); + }); + return mockChild; + }); + + // Start the relaunch process + const promise = relaunchAppInChildProcess([], []); + + await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED'); + + // Should default to exit code 1 + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + }); +}); + +/** + * Creates a mock child process that emits events asynchronously + */ +function createMockChildProcess( + exitCode: number = 0, + autoClose: boolean = false, +): ChildProcess { + const mockChild = new EventEmitter() as ChildProcess; + + Object.assign(mockChild, { + stdin: null, + stdout: null, + stderr: null, + stdio: [null, null, null], + pid: 12345, + killed: false, + exitCode: null, + signalCode: null, + spawnargs: [], + spawnfile: '', + kill: vi.fn(), + send: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn(), + ref: vi.fn(), + }); + + if (autoClose) { + setImmediate(() => { + mockChild.emit('close', exitCode); + }); + } + + return mockChild; +} diff --git a/packages/cli/src/utils/relaunch.ts b/packages/cli/src/utils/relaunch.ts new file mode 100644 index 0000000000..1142efc717 --- /dev/null +++ b/packages/cli/src/utils/relaunch.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn } from 'node:child_process'; +import { RELAUNCH_EXIT_CODE } from './processUtils.js'; + +export async function relaunchOnExitCode(runner: () => Promise) { + while (true) { + try { + const exitCode = await runner(); + + if (exitCode !== RELAUNCH_EXIT_CODE) { + process.exit(exitCode); + } + } catch (error) { + process.stdin.resume(); + console.error('Fatal error: Failed to relaunch the CLI process.', error); + process.exit(1); + } + } +} + +export async function relaunchAppInChildProcess( + additionalNodeArgs: string[], + additionalScriptArgs: string[], +) { + if (process.env['GEMINI_CLI_NO_RELAUNCH']) { + return; + } + + const runner = () => { + // process.argv is [node, script, ...args] + // We want to construct [ ...nodeArgs, script, ...scriptArgs] + const script = process.argv[1]; + const scriptArgs = process.argv.slice(2); + + const nodeArgs = [ + ...process.execArgv, + ...additionalNodeArgs, + script, + ...additionalScriptArgs, + ...scriptArgs, + ]; + const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' }; + + // The parent process should not be reading from stdin while the child is running. + process.stdin.pause(); + + const child = spawn(process.execPath, nodeArgs, { + stdio: 'inherit', + env: newEnv, + }); + + return new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (code) => { + // Resume stdin before the parent process exits. + process.stdin.resume(); + resolve(code ?? 1); + }); + }); + }; + + await relaunchOnExitCode(runner); +}