fix(cli): Auto restart CLI inner node process on trust change (#8378)

This commit is contained in:
shrutip90
2025-09-17 13:05:40 -07:00
committed by Shruti Padamata
parent 46afb7374a
commit 7dade1f0e2
7 changed files with 150 additions and 45 deletions

View File

@@ -163,11 +163,16 @@ describe('gemini.tsx main function', () => {
});
describe('gemini.tsx main function kitty protocol', () => {
let originalEnvNoRelaunch: string | undefined;
let setRawModeSpy: MockInstance<
(mode: boolean) => NodeJS.ReadStream & { fd: 0 }
>;
beforeEach(() => {
// Set no relaunch in tests since process spawning causing issues in tests
originalEnvNoRelaunch = process.env['GEMINI_CLI_NO_RELAUNCH'];
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(process.stdin as any).setRawMode) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -176,6 +181,15 @@ describe('gemini.tsx main function kitty protocol', () => {
setRawModeSpy = vi.spyOn(process.stdin, 'setRawMode');
});
afterEach(() => {
// Restore original env variables
if (originalEnvNoRelaunch !== undefined) {
process.env['GEMINI_CLI_NO_RELAUNCH'] = originalEnvNoRelaunch;
} else {
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
}
});
it('should call setRawMode and detectAndEnableKittyProtocol when isInteractive is true', async () => {
const { detectAndEnableKittyProtocol } = await import(
'./ui/utils/kittyProtocolDetector.js'

View File

@@ -16,6 +16,7 @@ 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, SettingScope } from './config/settings.js';
import { themeManager } from './ui/themes/theme-manager.js';
import { getStartupWarnings } from './utils/startupWarnings.js';
@@ -103,17 +104,50 @@ function getNodeMemoryArgs(config: Config): string[] {
return [];
}
async function relaunchWithAdditionalArgs(additionalArgs: string[]) {
const nodeArgs = [...additionalArgs, ...process.argv.slice(1)];
const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' };
async function relaunchOnExitCode(runner: () => Promise<number>) {
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,
});
await new Promise((resolve) => child.on('close', resolve));
process.exit(0);
return new Promise<number>((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';
@@ -350,15 +384,14 @@ export async function main() {
const sandboxArgs = injectStdinIntoArgs(process.argv, stdinData);
await start_sandbox(sandboxConfig, memoryArgs, config, sandboxArgs);
await relaunchOnExitCode(() =>
start_sandbox(sandboxConfig, memoryArgs, config, sandboxArgs),
);
process.exit(0);
} else {
// Not in a sandbox and not entering one, so relaunch with additional
// arguments to control memory usage if needed.
if (memoryArgs.length > 0) {
await relaunchWithAdditionalArgs(memoryArgs);
process.exit(0);
}
// Relaunch app so we always have a child process that can be internally
// restarted if needed.
await relaunchAppInChildProcess(memoryArgs);
}
}

View File

@@ -8,6 +8,11 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import { FolderTrustDialog, FolderTrustChoice } from './FolderTrustDialog.js';
import * as processUtils from '../../utils/processUtils.js';
vi.mock('../../utils/processUtils.js', () => ({
relaunchApp: vi.fn(),
}));
const mockedExit = vi.hoisted(() => vi.fn());
const mockedCwd = vi.hoisted(() => vi.fn());
@@ -69,21 +74,18 @@ describe('FolderTrustDialog', () => {
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
expect(lastFrame()).toContain(
'To see changes, Gemini CLI must be restarted',
);
expect(lastFrame()).toContain(' Gemini CLI is restarting');
});
it('should call process.exit when "r" is pressed and isRestarting is true', async () => {
const { stdin } = renderWithProviders(
it('should call relaunchApp when isRestarting is true', async () => {
vi.useFakeTimers();
const relaunchApp = vi.spyOn(processUtils, 'relaunchApp');
renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
stdin.write('r');
await waitFor(() => {
expect(mockedExit).toHaveBeenCalledWith(0);
});
await vi.advanceTimersByTimeAsync(1000);
expect(relaunchApp).toHaveBeenCalled();
vi.useRealTimers();
});
it('should not call process.exit when "r" is pressed and isRestarting is false', async () => {

View File

@@ -6,12 +6,15 @@
import { Box, Text } from 'ink';
import type React from 'react';
import { useEffect } from 'react';
import { theme } from '../semantic-colors.js';
import { Colors } from '../colors.js';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import * as process from 'node:process';
import * as path from 'node:path';
import { relaunchApp } from '../../utils/processUtils.js';
export enum FolderTrustChoice {
TRUST_FOLDER = 'trust_folder',
@@ -28,6 +31,17 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
onSelect,
isRestarting,
}) => {
useEffect(() => {
const doRelaunch = async () => {
if (isRestarting) {
setTimeout(async () => {
await relaunchApp();
}, 250);
}
};
doRelaunch();
}, [isRestarting]);
useKeypress(
(key) => {
if (key.name === 'escape') {
@@ -37,20 +51,12 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
{ isActive: !isRestarting },
);
useKeypress(
(key) => {
if (key.name === 'r') {
process.exit(0);
}
},
{ isActive: !!isRestarting },
);
const dirName = path.basename(process.cwd());
const parentFolder = path.basename(path.dirname(process.cwd()));
const options: Array<RadioSelectItem<FolderTrustChoice>> = [
{
label: 'Trust folder',
label: `Trust folder (${dirName})`,
value: FolderTrustChoice.TRUST_FOLDER,
},
{
@@ -90,9 +96,8 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
</Box>
{isRestarting && (
<Box marginLeft={1} marginTop={1}>
<Text color={Colors.AccentYellow}>
To see changes, Gemini CLI must be restarted. Press r to exit and
apply changes now.
<Text color={theme.status.warning}>
Gemini CLI is restarting to apply the trust changes...
</Text>
</Box>
)}

View File

@@ -0,0 +1,22 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
import { RELAUNCH_EXIT_CODE, relaunchApp } from './processUtils.js';
import * as cleanup from './cleanup.js';
describe('processUtils', () => {
const processExit = vi
.spyOn(process, 'exit')
.mockReturnValue(undefined as never);
const runExitCleanup = vi.spyOn(cleanup, 'runExitCleanup');
it('should run cleanup and exit with the relaunch code', async () => {
await relaunchApp();
expect(runExitCleanup).toHaveBeenCalledTimes(1);
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
});
});

View File

@@ -0,0 +1,20 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { runExitCleanup } from './cleanup.js';
/**
* Exit code used to signal that the CLI should be relaunched.
*/
export const RELAUNCH_EXIT_CODE = 42;
/**
* Exits the process with a special code to signal that the parent process should relaunch it.
*/
export async function relaunchApp(): Promise<void> {
await runExitCleanup();
process.exit(RELAUNCH_EXIT_CODE);
}

View File

@@ -188,7 +188,7 @@ export async function start_sandbox(
nodeArgs: string[] = [],
cliConfig?: Config,
cliArgs: string[] = [],
) {
): Promise<number> {
const patcher = new ConsolePatcher({
debugMode: cliConfig?.getDebugMode() || !!process.env['DEBUG'],
stderr: true,
@@ -339,11 +339,17 @@ export async function start_sandbox(
);
}
// spawn child and let it inherit stdio
process.stdin.pause();
sandboxProcess = spawn(config.command, args, {
stdio: 'inherit',
});
await new Promise((resolve) => sandboxProcess?.on('close', resolve));
return;
return new Promise((resolve, reject) => {
sandboxProcess?.on('error', reject);
sandboxProcess?.on('close', (code) => {
process.stdin.resume();
resolve(code ?? 1);
});
});
}
console.error(`hopping into sandbox (command: ${config.command}) ...`);
@@ -790,22 +796,25 @@ export async function start_sandbox(
}
// spawn child and let it inherit stdio
process.stdin.pause();
sandboxProcess = spawn(config.command, args, {
stdio: 'inherit',
});
sandboxProcess.on('error', (err) => {
console.error('Sandbox process error:', err);
});
return new Promise<number>((resolve, reject) => {
sandboxProcess.on('error', (err) => {
console.error('Sandbox process error:', err);
reject(err);
});
await new Promise<void>((resolve) => {
sandboxProcess?.on('close', (code, signal) => {
if (code !== 0) {
process.stdin.resume();
if (code !== 0 && code !== null) {
console.log(
`Sandbox process exited with code: ${code}, signal: ${signal}`,
);
}
resolve();
resolve(code ?? 1);
});
});
} finally {