Compare commits

...

3 Commits

Author SHA1 Message Date
Dmitry Lyalin ce18470921 Merge branch 'main' into gemini-cli-startup 2026-03-12 11:45:03 -04:00
Dmitry Lyalin f8dbc8084a Merge remote-tracking branch 'origin/main' into gemini-cli-startup
# Conflicts:
#	packages/cli/src/config/config.ts
#	packages/cli/src/gemini.tsx
#	packages/cli/src/ui/AppContainer.tsx
#	packages/cli/src/ui/auth/AuthDialog.test.tsx
#	packages/cli/src/ui/auth/AuthDialog.tsx
#	packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx
#	packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx
#	packages/cli/src/ui/components/DialogManager.tsx
#	packages/cli/src/utils/cleanup.ts
#	packages/cli/src/utils/processUtils.test.ts
#	packages/cli/src/utils/processUtils.ts
2026-03-12 08:43:41 -04:00
Dmitry Lyalin 115f8bce8b Experiment with CLI startup fast paths 2026-03-12 08:21:43 -04:00
18 changed files with 442 additions and 152 deletions
+37 -3
View File
@@ -28,6 +28,7 @@ import {
type MergedSettings,
createTestMergedSettings,
} from './settings.js';
import * as SettingsModule from './settings.js';
import * as ServerConfig from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
@@ -813,7 +814,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
});
it('should pass extension context file paths to loadServerHierarchicalMemory', async () => {
process.argv = ['node', 'script.js'];
process.argv = ['node', 'script.js', '--prompt', 'test'];
const settings = createTestMergedSettings();
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
@@ -862,7 +863,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
});
it('should pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is true', async () => {
process.argv = ['node', 'script.js'];
process.argv = ['node', 'script.js', '--prompt', 'test'];
const includeDir = path.resolve(path.sep, 'path', 'to', 'include');
const settings = createTestMergedSettings({
context: {
@@ -890,7 +891,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
});
it('should NOT pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is false', async () => {
process.argv = ['node', 'script.js'];
process.argv = ['node', 'script.js', '--prompt', 'test'];
const settings = createTestMergedSettings({
context: {
includeDirectories: ['/path/to/include'],
@@ -915,6 +916,39 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
200,
);
});
it('should skip extension, memory, and PTY discovery in bootstrap mode', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings();
const argv = await parseArguments(settings);
const loadSettingsSpy = vi.spyOn(SettingsModule, 'loadSettings');
const getPtySpy = vi.spyOn(ServerConfig, 'getPty');
await loadCliConfig(settings, 'session-id', argv, {
mode: 'bootstrap',
loadedSettings: { merged: settings } as SettingsModule.LoadedSettings,
});
expect(loadSettingsSpy).not.toHaveBeenCalled();
expect(ExtensionManager.prototype.loadExtensions).not.toHaveBeenCalled();
expect(ServerConfig.loadServerHierarchicalMemory).not.toHaveBeenCalled();
expect(getPtySpy).not.toHaveBeenCalled();
});
it('should defer extension and memory discovery during interactive startup', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings();
const argv = await parseArguments(settings);
const getPtySpy = vi.spyOn(ServerConfig, 'getPty');
await loadCliConfig(settings, 'session-id', argv, {
loadedSettings: { merged: settings } as SettingsModule.LoadedSettings,
});
expect(ExtensionManager.prototype.loadExtensions).not.toHaveBeenCalled();
expect(ServerConfig.loadServerHierarchicalMemory).not.toHaveBeenCalled();
expect(getPtySpy).toHaveBeenCalled();
});
});
describe('mergeMcpServers', () => {
+46 -18
View File
@@ -39,6 +39,7 @@ import {
resolveToRealPath,
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
startupProfiler,
type HookDefinition,
type HookEventName,
type OutputFormat,
@@ -47,6 +48,7 @@ import {
import {
type Settings,
type MergedSettings,
type LoadedSettings,
saveModelChange,
loadSettings,
} from './settings.js';
@@ -417,6 +419,8 @@ export function isDebugMode(argv: CliArgs): boolean {
export interface LoadCliConfigOptions {
cwd?: string;
mode?: 'bootstrap' | 'full';
loadedSettings?: LoadedSettings;
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
disabled?: string[];
};
@@ -428,10 +432,15 @@ export async function loadCliConfig(
argv: CliArgs,
options: LoadCliConfigOptions = {},
): Promise<Config> {
const { cwd = process.cwd(), projectHooks } = options;
const {
cwd = process.cwd(),
mode = 'full',
loadedSettings,
projectHooks,
} = options;
const debugMode = isDebugMode(argv);
const loadedSettings = loadSettings(cwd);
const resolvedLoadedSettings = loadedSettings ?? loadSettings(cwd);
const isBootstrap = mode === 'bootstrap';
if (argv.sandbox) {
process.env['GEMINI_SANDBOX'] = 'true';
@@ -439,6 +448,18 @@ export async function loadCliConfig(
const memoryImportFormat = settings.context?.importFormat || 'tree';
const includeDirectoryTree = settings.context?.includeDirectoryTree ?? true;
const interactive =
!!argv.promptInteractive ||
!!argv.acp ||
!!argv.experimentalAcp ||
(!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) &&
!argv.isCommand);
const shouldDeferInteractiveStartupWork =
!isBootstrap &&
interactive &&
!argv.listExtensions &&
!argv.listSessions &&
!argv.deleteSession;
const ideMode = settings.ide?.enabled ?? false;
@@ -480,6 +501,7 @@ export async function loadCliConfig(
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));
const clientVersion = await getVersion();
const extensionManager = new ExtensionManager({
settings,
requestConsent: requestConsentNonInteractive,
@@ -488,9 +510,15 @@ export async function loadCliConfig(
enabledExtensionOverrides: argv.extensions,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
clientVersion: await getVersion(),
clientVersion,
});
await extensionManager.loadExtensions();
if (!isBootstrap && !shouldDeferInteractiveStartupWork) {
const loadExtensionsHandle = startupProfiler.start(
'load_extensions_during_config',
);
await extensionManager.loadExtensions();
loadExtensionsHandle?.end();
}
const extensionPlanSettings = extensionManager
.getExtensions()
@@ -511,7 +539,12 @@ export async function loadCliConfig(
let fileCount = 0;
let filePaths: string[] = [];
if (!experimentalJitContext) {
if (
!experimentalJitContext &&
!isBootstrap &&
!shouldDeferInteractiveStartupWork
) {
const loadMemoryHandle = startupProfiler.start('load_memory');
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const result = await loadServerHierarchicalMemory(
cwd,
@@ -528,6 +561,7 @@ export async function loadCliConfig(
memoryContent = result.memoryContent;
fileCount = result.fileCount;
filePaths = result.filePaths;
loadMemoryHandle?.end();
}
const question = argv.promptInteractive || argv.prompt || '';
@@ -617,15 +651,6 @@ export async function loadCliConfig(
throw err;
}
// -p/--prompt forces non-interactive (headless) mode
// -i/--prompt-interactive forces interactive mode with an initial prompt
const interactive =
!!argv.promptInteractive ||
!!argv.acp ||
!!argv.experimentalAcp ||
(!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) &&
!argv.isCommand);
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
// In non-interactive mode, exclude tools that require a prompt.
@@ -695,7 +720,7 @@ export async function loadCliConfig(
? argv.screenReader
: (settings.ui?.accessibility?.screenReader ?? false);
const ptyInfo = await getPty();
const ptyInfo = isBootstrap ? null : await getPty();
const mcpEnabled = settings.admin?.mcp?.enabled ?? true;
const extensionsEnabled = settings.admin?.extensions?.enabled ?? true;
@@ -741,7 +766,7 @@ export async function loadCliConfig(
acpMode: isAcpMode,
clientName,
sessionId,
clientVersion: await getVersion(),
clientVersion,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: sandboxConfig,
targetDir: cwd,
@@ -822,6 +847,8 @@ export async function loadCliConfig(
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
experimentalJitContext: settings.experimental?.jitContext,
deferInitialMemoryLoad:
shouldDeferInteractiveStartupWork && !experimentalJitContext,
modelSteering: settings.experimental?.modelSteering,
toolOutputMasking: settings.experimental?.toolOutputMasking,
noBrowser: !!process.env['NO_BROWSER'],
@@ -864,7 +891,8 @@ export async function loadCliConfig(
hooks: settings.hooks || {},
disabledHooks: settings.hooksConfig?.disabled || [],
projectHooks: projectHooks || {},
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
onModelChange: (model: string) =>
saveModelChange(resolvedLoadedSettings, model),
onReload: async () => {
const refreshedSettings = loadSettings(cwd);
return {
+18 -6
View File
@@ -94,7 +94,7 @@ interface ExtensionManagerParams {
/**
* Actual implementation of an ExtensionLoader.
*
* You must call `loadExtensions` prior to calling other methods on this class.
* Extension metadata is loaded lazily via `loadExtensions`/`ensureLoaded`.
*/
export class ExtensionManager extends ExtensionLoader {
private extensionEnablementManager: ExtensionEnablementManager;
@@ -146,12 +146,24 @@ export class ExtensionManager extends ExtensionLoader {
}
getExtensions(): GeminiCLIExtension[] {
if (!this.loadedExtensions) {
throw new Error(
'Extensions not yet loaded, must call `loadExtensions` first',
);
return this.loadedExtensions ?? [];
}
override isLoaded(): boolean {
return this.loadedExtensions !== undefined;
}
override async ensureLoaded(): Promise<void> {
if (this.loadedExtensions) {
return;
}
return this.loadedExtensions;
if (this.loadingPromise) {
await this.loadingPromise;
return;
}
await this.loadExtensions();
}
async installOrUpdateExtension(
+5
View File
@@ -289,6 +289,11 @@ export function createTestMergedSettings(
) as MergedSettings;
}
export function createDefaultMergedSettings(): MergedSettings {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return getDefaultsFromSchema() as MergedSettings;
}
/**
* An immutable snapshot of settings state.
* Used with useSyncExternalStore for reactive updates.
+97
View File
@@ -269,6 +269,7 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({
describe('gemini.tsx main function', () => {
let originalIsTTY: boolean | undefined;
const originalArgv = [...process.argv];
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
[];
@@ -284,6 +285,7 @@ describe('gemini.tsx main function', () => {
originalIsTTY = process.stdin.isTTY;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(process.stdin as any).isTTY = true;
process.argv = ['node', 'script.js'];
});
afterEach(() => {
@@ -296,11 +298,70 @@ describe('gemini.tsx main function', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(process.stdin as any).isTTY = originalIsTTY;
process.argv = [...originalArgv];
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('should fast-path --version without loading settings', async () => {
process.argv = ['node', 'script.js', '--version'];
await main();
expect(loadSettings).not.toHaveBeenCalled();
expect(parseArguments).not.toHaveBeenCalled();
});
it('should fast-path --help without loading settings', async () => {
process.argv = ['node', 'script.js', '--help'];
await main();
expect(loadSettings).not.toHaveBeenCalled();
expect(parseArguments).toHaveBeenCalledTimes(1);
});
it('should not relaunch a child process when sandboxing and memory relaunch are unnecessary', async () => {
vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: { advanced: {}, security: { auth: {} }, ui: {} },
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
}),
);
vi.mocked(parseArguments).mockResolvedValue({
prompt: 'test',
} as unknown as CliArgs);
vi.mocked(loadCliConfig).mockResolvedValue(
createMockConfig({
isInteractive: () => false,
getQuestion: () => 'test',
getSandbox: () => undefined,
}),
);
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
try {
await main();
expect.fail('Should have thrown MockProcessExitError');
} catch (e) {
expect(e).toBeInstanceOf(MockProcessExitError);
expect((e as MockProcessExitError).code).toBe(0);
} finally {
processExitSpy.mockRestore();
}
const { relaunchAppInChildProcess } = await import('./utils/relaunch.js');
expect(relaunchAppInChildProcess).not.toHaveBeenCalled();
});
it('should log unhandled promise rejections and open debug console on first error', async () => {
const processExitSpy = vi
.spyOn(process, 'exit')
@@ -665,6 +726,42 @@ describe('gemini.tsx main function kitty protocol', () => {
processExitSpy.mockRestore();
});
it('should use bootstrap config for the pre-relaunch startup pass', async () => {
const mockSettings = createMockSettings({
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
workspace: { settings: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
});
vi.mocked(loadSettings).mockReturnValue(mockSettings);
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
} as unknown as CliArgs);
vi.mocked(loadCliConfig).mockResolvedValue(
createMockConfig({
isInteractive: () => true,
getQuestion: () => '',
getSandbox: () => undefined,
}),
);
await main();
expect(loadCliConfig).toHaveBeenCalledTimes(2);
expect(vi.mocked(loadCliConfig).mock.calls[0][3]).toMatchObject({
mode: 'bootstrap',
loadedSettings: mockSettings,
});
expect(vi.mocked(loadCliConfig).mock.calls[1][3]).toMatchObject({
loadedSettings: mockSettings,
});
});
it('should log warning when theme is not found', async () => {
const { themeManager } = await import('./ui/themes/theme-manager.js');
const debugLoggerWarnSpy = vi
+66 -14
View File
@@ -17,6 +17,7 @@ import os from 'node:os';
import dns from 'node:dns';
import { start_sandbox } from './utils/sandbox.js';
import {
createDefaultMergedSettings,
loadSettings,
SettingScope,
type DnsResolutionOrder,
@@ -119,6 +120,24 @@ import { cleanupBackgroundLogs } from './utils/logCleanup.js';
import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js';
const SLOW_RENDER_MS = 200;
const HELP_FLAGS = new Set(['--help', '-h']);
const VERSION_FLAGS = new Set(['--version', '-v']);
function getStartupFastPath(args: string[]): 'help' | 'version' | null {
if (args.length === 0) {
return null;
}
if (args.every((arg) => HELP_FLAGS.has(arg))) {
return 'help';
}
if (args.every((arg) => VERSION_FLAGS.has(arg))) {
return 'version';
}
return null;
}
export function validateDnsResolutionOrder(
order: string | undefined,
@@ -341,7 +360,37 @@ export async function startInteractiveUI(
registerCleanup(setupTtyCheck());
}
async function runStartupCleanup(
config: Config,
settings: LoadedSettings,
): Promise<void> {
const projectTempDir = config.storage.getProjectTempDir();
try {
await Promise.all([
cleanupCheckpoints(projectTempDir),
cleanupToolOutputFiles(
settings.merged,
config.getDebugMode(),
projectTempDir,
),
cleanupBackgroundLogs(),
]);
} catch (error) {
debugLogger.warn('Deferred startup cleanup failed:', error);
}
}
export async function main() {
const fastPath = getStartupFastPath(process.argv.slice(2));
if (fastPath === 'version') {
writeToStdout(`${await getVersion()}\n`);
return;
}
if (fastPath === 'help') {
await parseArguments(createDefaultMergedSettings());
return;
}
const cliStartupHandle = startupProfiler.start('cli_startup');
// Listen for admin controls from parent process (IPC) in non-sandbox mode. In
@@ -375,6 +424,10 @@ export async function main() {
coreEvents.emitFeedback('warning', error.message);
});
const parseArgsHandle = startupProfiler.start('parse_arguments');
const argv = await parseArguments(settings.merged);
parseArgsHandle?.end();
const trustedFolders = loadTrustedFolders();
trustedFolders.errors.forEach((error: TrustedFoldersError) => {
coreEvents.emitFeedback(
@@ -382,17 +435,6 @@ export async function main() {
`Error in ${error.path}: ${error.message}`,
);
});
await Promise.all([
cleanupCheckpoints(),
cleanupToolOutputFiles(settings.merged),
cleanupBackgroundLogs(),
]);
const parseArgsHandle = startupProfiler.start('parse_arguments');
const argv = await parseArguments(settings.merged);
parseArgsHandle?.end();
if (
(argv.allowedTools && argv.allowedTools.length > 0) ||
(settings.merged.tools?.allowed && settings.merged.tools.allowed.length > 0)
@@ -460,9 +502,13 @@ export async function main() {
}
}
const bootstrapConfigHandle = startupProfiler.start('load_bootstrap_config');
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
mode: 'bootstrap',
loadedSettings: settings,
projectHooks: settings.workspace.settings.hooks,
});
bootstrapConfigHandle?.end();
adminControlsListner.setConfig(partialConfig);
// Refresh auth to fetch remote admin settings from CCPA and before entering
@@ -572,9 +618,7 @@ export async function main() {
);
await runExitCleanup();
process.exit(ExitCodes.SUCCESS);
} else {
// Relaunch app so we always have a child process that can be internally
// restarted if needed.
} else if (memoryArgs.length > 0) {
await relaunchAppInChildProcess(memoryArgs, [], remoteAdminSettings);
}
}
@@ -585,6 +629,7 @@ export async function main() {
{
const loadConfigHandle = startupProfiler.start('load_cli_config');
const config = await loadCliConfig(settings.merged, sessionId, argv, {
loadedSettings: settings,
projectHooks: settings.workspace.settings.hooks,
});
loadConfigHandle?.end();
@@ -662,6 +707,13 @@ export async function main() {
process.exit(ExitCodes.SUCCESS);
}
const startupCleanup = runStartupCleanup(config, settings);
if (config.isInteractive()) {
void startupCleanup;
} else {
await startupCleanup;
}
const wasRaw = process.stdin.isRaw;
if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
// Set this as early as possible to avoid spurious characters from
+2 -11
View File
@@ -796,7 +796,7 @@ export const AppContainer = (props: AppContainerProps) => {
Logging in with Google... Restarting Gemini CLI to continue.
----------------------------------------------------------------
`);
await relaunchApp();
await relaunchApp(config.getRemoteAdminSettings());
}
}
setAuthState(AuthState.Authenticated);
@@ -2478,16 +2478,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
onHintClear: () => {},
onHintSubmit: () => {},
handleRestart: async () => {
if (process.send) {
const remoteSettings = config.getRemoteAdminSettings();
if (remoteSettings) {
process.send({
type: 'admin-settings-update',
settings: remoteSettings,
});
}
}
await relaunchApp();
await relaunchApp(config.getRemoteAdminSettings());
},
handleNewAgentsSelect: async (choice: NewAgentsChoice) => {
if (newAgents && choice === NewAgentsChoice.ACKNOWLEDGE) {
+9 -14
View File
@@ -22,9 +22,8 @@ import { AuthState } from '../types.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { validateAuthMethodWithSettings } from './useAuth.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { Text } from 'ink';
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
import { relaunchApp } from '../../utils/processUtils.js';
// Mocks
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -36,14 +35,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
vi.mock('../../utils/cleanup.js', () => ({
runExitCleanup: vi.fn(),
}));
vi.mock('./useAuth.js', () => ({
validateAuthMethodWithSettings: vi.fn(),
}));
vi.mock('../../utils/processUtils.js', () => ({
relaunchApp: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
@@ -64,7 +63,7 @@ vi.mock('../components/shared/RadioButtonSelect.js', () => ({
const mockedUseKeypress = useKeypress as Mock;
const mockedRadioButtonSelect = RadioButtonSelect as Mock;
const mockedValidateAuthMethod = validateAuthMethodWithSettings as Mock;
const mockedRunExitCleanup = runExitCleanup as Mock;
const mockedRelaunchApp = relaunchApp as Mock;
describe('AuthDialog', () => {
let props: {
@@ -85,6 +84,7 @@ describe('AuthDialog', () => {
props = {
config: {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
} as unknown as Config,
settings: {
merged: {
@@ -351,11 +351,8 @@ describe('AuthDialog', () => {
unmount();
});
it('exits process for Sign in with Google when browser is suppressed', async () => {
it('restarts for Sign in with Google when browser is suppressed', async () => {
vi.useFakeTimers();
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
const logSpy = vi.spyOn(debugLogger, 'log').mockImplementation(() => {});
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
mockedValidateAuthMethod.mockReturnValue(null);
@@ -371,10 +368,8 @@ describe('AuthDialog', () => {
await vi.runAllTimersAsync();
});
expect(mockedRunExitCleanup).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
expect(mockedRelaunchApp).toHaveBeenCalledWith(undefined);
exitSpy.mockRestore();
logSpy.mockRestore();
vi.useRealTimers();
unmount();
+3 -1
View File
@@ -132,7 +132,9 @@ export function AuthDialog({
config.isBrowserLaunchSuppressed()
) {
setExiting(true);
setTimeout(relaunchApp, 100);
setTimeout(async () => {
await relaunchApp(config.getRemoteAdminSettings());
}, 100);
return;
}
@@ -8,30 +8,23 @@ import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { LoginWithGoogleRestartDialog } from './LoginWithGoogleRestartDialog.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import {
RELAUNCH_EXIT_CODE,
_resetRelaunchStateForTesting,
} from '../../utils/processUtils.js';
import { type Config } from '@google/gemini-cli-core';
import { relaunchApp } from '../../utils/processUtils.js';
// Mocks
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('../../utils/cleanup.js', () => ({
runExitCleanup: vi.fn(),
vi.mock('../../utils/processUtils.js', () => ({
relaunchApp: vi.fn().mockResolvedValue(undefined),
}));
const mockedUseKeypress = useKeypress as Mock;
const mockedRunExitCleanup = runExitCleanup as Mock;
const mockedRelaunchApp = relaunchApp as Mock;
describe('LoginWithGoogleRestartDialog', () => {
const onDismiss = vi.fn();
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
const mockConfig = {
getRemoteAdminSettings: vi.fn(),
@@ -39,9 +32,7 @@ describe('LoginWithGoogleRestartDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
exitSpy.mockClear();
vi.useRealTimers();
_resetRelaunchStateForTesting();
});
it('renders correctly', async () => {
@@ -78,36 +69,33 @@ describe('LoginWithGoogleRestartDialog', () => {
unmount();
});
it.each(['r', 'R'])(
'calls runExitCleanup and process.exit when %s is pressed',
async (keyName) => {
vi.useFakeTimers();
it.each(['r', 'R'])('restarts when %s is pressed', async (keyName) => {
vi.useFakeTimers();
const { waitUntilReady, unmount } = render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
const { waitUntilReady, unmount } = render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
name: keyName,
shift: false,
ctrl: false,
cmd: false,
sequence: keyName,
});
keypressHandler({
name: keyName,
shift: false,
ctrl: false,
cmd: false,
sequence: keyName,
});
// Advance timers to trigger the setTimeout callback
await vi.runAllTimersAsync();
// Advance timers to trigger the setTimeout callback
await vi.runAllTimersAsync();
expect(mockedRunExitCleanup).toHaveBeenCalledTimes(1);
expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
expect(mockedRelaunchApp).toHaveBeenCalledTimes(1);
expect(mockedRelaunchApp).toHaveBeenCalledWith(undefined);
vi.useRealTimers();
unmount();
},
);
vi.useRealTimers();
unmount();
});
});
@@ -26,16 +26,7 @@ export const LoginWithGoogleRestartDialog = ({
return true;
} else if (key.name === 'r' || key.name === 'R') {
setTimeout(async () => {
if (process.send) {
const remoteSettings = config.getRemoteAdminSettings();
if (remoteSettings) {
process.send({
type: 'admin-settings-update',
settings: remoteSettings,
});
}
}
await relaunchApp();
await relaunchApp(config.getRemoteAdminSettings());
}, 100);
return true;
}
@@ -231,7 +231,9 @@ export const DialogManager = ({
<Box flexDirection="column">
<SettingsDialog
onSelect={() => uiActions.closeSettingsDialog()}
onRestartRequest={relaunchApp}
onRestartRequest={async () => {
await relaunchApp(config.getRemoteAdminSettings());
}}
availableTerminalHeight={terminalHeight - staticExtraHeight}
/>
</Box>
+15 -12
View File
@@ -103,6 +103,21 @@ async function drainStdin() {
await new Promise((resolve) => setTimeout(resolve, 50));
}
export async function cleanupCheckpoints(projectTempDir?: string) {
let tempDir = projectTempDir;
if (!tempDir) {
const storage = new Storage(process.cwd());
await storage.initialize();
tempDir = storage.getProjectTempDir();
}
const checkpointsDir = join(tempDir, 'checkpoints');
try {
await fs.rm(checkpointsDir, { recursive: true, force: true });
} catch {
// Ignore errors if the directory doesn't exist or fails to delete.
}
}
/**
* Gracefully shuts down the process, ensuring cleanup runs exactly once.
* Guards against concurrent shutdown from signals (SIGHUP, SIGTERM, SIGINT)
@@ -161,15 +176,3 @@ export function setupTtyCheck(): () => void {
}
};
}
export async function cleanupCheckpoints() {
const storage = new Storage(process.cwd());
await storage.initialize();
const tempDir = storage.getProjectTempDir();
const checkpointsDir = join(tempDir, 'checkpoints');
try {
await fs.rm(checkpointsDir, { recursive: true, force: true });
} catch {
// Ignore errors if the directory doesn't exist or fails to delete.
}
}
+31 -3
View File
@@ -11,28 +11,56 @@ import {
_resetRelaunchStateForTesting,
} from './processUtils.js';
import * as cleanup from './cleanup.js';
import * as relaunch from './relaunch.js';
import * as handleAutoUpdate from './handleAutoUpdate.js';
vi.mock('./handleAutoUpdate.js', () => ({
waitForUpdateCompletion: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./relaunch.js', () => ({
relaunchAppInChildProcess: vi.fn().mockResolvedValue(undefined),
}));
describe('processUtils', () => {
const processExit = vi
.spyOn(process, 'exit')
.mockReturnValue(undefined as never);
const runExitCleanup = vi.spyOn(cleanup, 'runExitCleanup');
const relaunchAppInChildProcess = vi.spyOn(
relaunch,
'relaunchAppInChildProcess',
);
beforeEach(() => {
_resetRelaunchStateForTesting();
});
afterEach(() => vi.clearAllMocks());
afterEach(() => {
delete process.env['SANDBOX'];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (process as any).send;
vi.clearAllMocks();
});
it('should wait for updates, run cleanup, and exit with the relaunch code', async () => {
await relaunchApp();
it('should wait for updates, run cleanup, and exit with the relaunch code when a parent wrapper exists', async () => {
process.env['SANDBOX'] = 'sandbox-exec';
processExit.mockImplementationOnce(() => {
throw new Error('PROCESS_EXIT_CALLED');
});
await expect(relaunchApp()).rejects.toThrow('PROCESS_EXIT_CALLED');
expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1);
expect(runExitCleanup).toHaveBeenCalledTimes(1);
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
expect(relaunchAppInChildProcess).not.toHaveBeenCalled();
});
it('should spawn a child wrapper on demand when no parent wrapper exists', async () => {
await relaunchApp();
expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1);
expect(runExitCleanup).toHaveBeenCalledTimes(1);
expect(relaunchAppInChildProcess).toHaveBeenCalledWith([], [], undefined);
expect(processExit).not.toHaveBeenCalled();
});
});
+19 -3
View File
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { AdminControlsSettings } from '@google/gemini-cli-core';
import { runExitCleanup } from './cleanup.js';
import { waitForUpdateCompletion } from './handleAutoUpdate.js';
@@ -13,7 +14,8 @@ import { waitForUpdateCompletion } from './handleAutoUpdate.js';
export const RELAUNCH_EXIT_CODE = 199;
/**
* Exits the process with a special code to signal that the parent process should relaunch it.
* Restarts the CLI, either by signaling an existing parent wrapper or by
* spawning one on demand.
*/
let isRelaunching = false;
@@ -22,10 +24,24 @@ export function _resetRelaunchStateForTesting(): void {
isRelaunching = false;
}
export async function relaunchApp(): Promise<void> {
export async function relaunchApp(
remoteAdminSettings?: AdminControlsSettings,
): Promise<void> {
if (isRelaunching) return;
isRelaunching = true;
await waitForUpdateCompletion();
await runExitCleanup();
process.exit(RELAUNCH_EXIT_CODE);
if (process.send || process.env['SANDBOX']) {
if (process.send && remoteAdminSettings) {
process.send({
type: 'admin-settings-update',
settings: remoteAdminSettings,
});
}
process.exit(RELAUNCH_EXIT_CODE);
}
const { relaunchAppInChildProcess } = await import('./relaunch.js');
await relaunchAppInChildProcess([], [], remoteAdminSettings);
}
+20
View File
@@ -614,6 +614,7 @@ export interface ConfigParameters {
disabledSkills?: string[];
adminSkillsEnabled?: boolean;
experimentalJitContext?: boolean;
deferInitialMemoryLoad?: boolean;
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
disableLLMCorrection?: boolean;
plan?: boolean;
@@ -832,6 +833,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly adminSkillsEnabled: boolean;
private readonly experimentalJitContext: boolean;
private readonly deferInitialMemoryLoad: boolean;
private readonly disableLLMCorrection: boolean;
private readonly planEnabled: boolean;
private readonly trackerEnabled: boolean;
@@ -934,6 +936,7 @@ export class Config implements McpContext, AgentLoopContext {
this.adminSkillsEnabled = params.adminSkillsEnabled ?? true;
this.modelAvailabilityService = new ModelAvailabilityService();
this.experimentalJitContext = params.experimentalJitContext ?? false;
this.deferInitialMemoryLoad = params.deferInitialMemoryLoad ?? false;
this.modelSteering = params.modelSteering ?? false;
this.userHintService = new UserHintService(() =>
this.isModelSteeringEnabled(),
@@ -1174,6 +1177,23 @@ export class Config implements McpContext, AgentLoopContext {
}
}
if (!this.getExtensionLoader().isLoaded()) {
const extensionLoadHandle = startupProfiler.start('load_extensions');
await this.getExtensionLoader().ensureLoaded();
extensionLoadHandle?.end();
} else {
await this.getExtensionLoader().ensureLoaded();
}
if (this.deferInitialMemoryLoad) {
const memoryLoadHandle = startupProfiler.start('load_memory');
const { refreshServerHierarchicalMemory } = await import(
'../utils/memoryDiscovery.js'
);
await refreshServerHierarchicalMemory(this);
memoryLoadHandle?.end();
}
// Initialize centralized FileDiscoveryService
const discoverToolsHandle = startupProfiler.start('discover_tools');
this.getFileService();
@@ -24,6 +24,19 @@ export abstract class ExtensionLoader {
constructor(private readonly eventEmitter?: EventEmitter<ExtensionEvents>) {}
isLoaded(): boolean {
return true;
}
/**
* Ensures extension metadata is available before initialization continues.
*
* Most implementations have nothing to do here, but loaders backed by disk
* discovery can override this to defer filesystem work until after first
* render.
*/
async ensureLoaded(): Promise<void> {}
/**
* All currently known extensions, both active and inactive.
*/
+29 -16
View File
@@ -17,25 +17,38 @@ export interface PtyProcess {
kill(signal?: string): void;
}
let ptyImplementationPromise: Promise<PtyImplementation> | undefined;
export const getPty = async (): Promise<PtyImplementation> => {
if (process.env['GEMINI_PTY_INFO'] === 'child_process') {
return null;
}
try {
const lydell = '@lydell/node-pty';
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const module = await import(lydell);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
return { module, name: 'lydell-node-pty' };
} catch (_e) {
try {
const nodePty = 'node-pty';
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const module = await import(nodePty);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
return { module, name: 'node-pty' };
} catch (_e2) {
return null;
}
if (!ptyImplementationPromise) {
ptyImplementationPromise = (async () => {
try {
const lydell = '@lydell/node-pty';
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const module = await import(lydell);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
return { module, name: 'lydell-node-pty' };
} catch (_e) {
try {
const nodePty = 'node-pty';
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const module = await import(nodePty);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
return { module, name: 'node-pty' };
} catch (_e2) {
return null;
}
}
})();
}
return ptyImplementationPromise;
};
/** For testing purposes only. */
export function resetPtyCache(): void {
ptyImplementationPromise = undefined;
}