Experiment with CLI startup fast paths

This commit is contained in:
Dmitry Lyalin
2026-03-12 08:21:43 -04:00
parent 020da58327
commit 115f8bce8b
18 changed files with 436 additions and 150 deletions
+37 -3
View File
@@ -26,6 +26,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';
@@ -809,7 +810,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([
{
@@ -859,7 +860,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: {
@@ -888,7 +889,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'],
@@ -914,6 +915,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', () => {
+45 -17
View File
@@ -40,6 +40,7 @@ import {
Config,
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';
@@ -436,6 +438,8 @@ export function isDebugMode(argv: CliArgs): boolean {
export interface LoadCliConfigOptions {
cwd?: string;
mode?: 'bootstrap' | 'full';
loadedSettings?: LoadedSettings;
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
disabled?: string[];
};
@@ -447,10 +451,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';
@@ -458,6 +467,17 @@ export async function loadCliConfig(
const memoryImportFormat = settings.context?.importFormat || 'tree';
const includeDirectoryTree = settings.context?.includeDirectoryTree ?? true;
const interactive =
!!argv.promptInteractive ||
!!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;
@@ -499,6 +519,7 @@ export async function loadCliConfig(
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));
const clientVersion = await getVersion();
const extensionManager = new ExtensionManager({
settings,
requestConsent: requestConsentNonInteractive,
@@ -507,9 +528,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 experimentalJitContext = settings.experimental?.jitContext ?? false;
@@ -517,7 +544,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,
@@ -535,6 +567,7 @@ export async function loadCliConfig(
memoryContent = result.memoryContent;
fileCount = result.fileCount;
filePaths = result.filePaths;
loadMemoryHandle?.end();
}
const question = argv.promptInteractive || argv.prompt || '';
@@ -624,14 +657,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.experimentalAcp ||
(!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) &&
!argv.isCommand);
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
const allowedToolsSet = new Set(allowedTools);
@@ -723,7 +748,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;
@@ -755,7 +780,7 @@ export async function loadCliConfig(
return new Config({
sessionId,
clientVersion: await getVersion(),
clientVersion,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: sandboxConfig,
targetDir: cwd,
@@ -832,6 +857,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'],
@@ -871,7 +898,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
@@ -90,7 +90,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;
@@ -138,12 +138,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
@@ -291,6 +291,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
@@ -255,6 +255,7 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({
describe('gemini.tsx main function', () => {
let originalIsTTY: boolean | undefined;
const originalArgv = [...process.argv];
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
[];
@@ -270,6 +271,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(() => {
@@ -282,11 +284,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')
@@ -641,6 +702,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
+69 -13
View File
@@ -21,7 +21,11 @@ import {
loadTrustedFolders,
type TrustedFoldersError,
} from './config/trustedFolders.js';
import { loadSettings, SettingScope } from './config/settings.js';
import {
createDefaultMergedSettings,
loadSettings,
SettingScope,
} from './config/settings.js';
import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
@@ -110,6 +114,24 @@ import { runDeferredCommand } from './deferred.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,
@@ -321,7 +343,36 @@ export async function startInteractiveUI(
registerCleanup(() => instance.unmount());
}
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,
),
]);
} 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
@@ -353,6 +404,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(
@@ -361,15 +416,6 @@ export async function main() {
);
});
await Promise.all([
cleanupCheckpoints(),
cleanupToolOutputFiles(settings.merged),
]);
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)
@@ -437,9 +483,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
@@ -549,9 +599,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);
}
}
@@ -562,6 +610,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();
@@ -639,6 +688,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
+3 -14
View File
@@ -126,7 +126,7 @@ import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js';
import { type UpdateObject } from './utils/updateCheck.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js';
import { relaunchApp } from '../utils/processUtils.js';
import type { SessionInfo } from '../utils/sessionUtils.js';
import { useMessageQueue } from './hooks/useMessageQueue.js';
import { useMcpStatus } from './hooks/useMcpStatus.js';
@@ -778,13 +778,12 @@ export const AppContainer = (props: AppContainerProps) => {
authType === AuthType.LOGIN_WITH_GOOGLE &&
config.isBrowserLaunchSuppressed()
) {
await runExitCleanup();
writeToStdout(`
----------------------------------------------------------------
Logging in with Google... Restarting Gemini CLI to continue.
----------------------------------------------------------------
`);
process.exit(RELAUNCH_EXIT_CODE);
await relaunchApp(config.getRemoteAdminSettings());
}
}
setAuthState(AuthState.Authenticated);
@@ -2522,17 +2521,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 runExitCleanup();
process.exit(RELAUNCH_EXIT_CODE);
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 Login with Google when browser is suppressed', async () => {
it('restarts for Login 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();
+2 -4
View File
@@ -21,9 +21,8 @@ import {
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { AuthState } from '../types.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { validateAuthMethodWithSettings } from './useAuth.js';
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
import { relaunchApp } from '../../utils/processUtils.js';
interface AuthDialogProps {
config: Config;
@@ -134,8 +133,7 @@ export function AuthDialog({
) {
setExiting(true);
setTimeout(async () => {
await runExitCleanup();
process.exit(RELAUNCH_EXIT_CODE);
await relaunchApp(config.getRemoteAdminSettings());
}, 100);
return;
}
@@ -8,27 +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 } 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(),
@@ -36,7 +32,6 @@ describe('LoginWithGoogleRestartDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
exitSpy.mockClear();
vi.useRealTimers();
});
@@ -74,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();
});
});
@@ -8,8 +8,7 @@ import { type Config } from '@google/gemini-cli-core';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
import { relaunchApp } from '../../utils/processUtils.js';
interface LoginWithGoogleRestartDialogProps {
onDismiss: () => void;
@@ -27,17 +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 runExitCleanup();
process.exit(RELAUNCH_EXIT_CODE);
await relaunchApp(config.getRemoteAdminSettings());
}, 100);
return true;
}
@@ -18,8 +18,7 @@ import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
import { ProQuotaDialog } from './ProQuotaDialog.js';
import { ValidationDialog } from './ValidationDialog.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
import { relaunchApp } from '../../utils/processUtils.js';
import { SessionBrowser } from './SessionBrowser.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { ModelDialog } from './ModelDialog.js';
@@ -28,7 +27,6 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import process from 'node:process';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
@@ -258,8 +256,7 @@ export const DialogManager = ({
settings={settings}
onSelect={() => uiActions.closeSettingsDialog()}
onRestartRequest={async () => {
await runExitCleanup();
process.exit(RELAUNCH_EXIT_CODE);
await relaunchApp(config.getRemoteAdminSettings());
}}
availableTerminalHeight={terminalHeight - staticExtraHeight}
config={config}
+7 -4
View File
@@ -100,10 +100,13 @@ async function drainStdin() {
await new Promise((resolve) => setTimeout(resolve, 50));
}
export async function cleanupCheckpoints() {
const storage = new Storage(process.cwd());
await storage.initialize();
const tempDir = storage.getProjectTempDir();
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 });
+31 -2
View File
@@ -7,16 +7,45 @@
import { vi } from 'vitest';
import { RELAUNCH_EXIT_CODE, relaunchApp } from './processUtils.js';
import * as cleanup from './cleanup.js';
import * as relaunch from './relaunch.js';
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',
);
it('should run cleanup and exit with the relaunch code', async () => {
await relaunchApp();
afterEach(() => {
delete process.env['SANDBOX'];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (process as any).send;
vi.clearAllMocks();
});
it('should 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(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(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';
/**
@@ -12,9 +13,24 @@ import { runExitCleanup } from './cleanup.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.
*/
export async function relaunchApp(): Promise<void> {
export async function relaunchApp(
remoteAdminSettings?: AdminControlsSettings,
): Promise<void> {
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
@@ -534,6 +534,7 @@ export interface ConfigParameters {
disabledSkills?: string[];
adminSkillsEnabled?: boolean;
experimentalJitContext?: boolean;
deferInitialMemoryLoad?: boolean;
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
disableLLMCorrection?: boolean;
plan?: boolean;
@@ -733,6 +734,7 @@ export class Config {
private readonly adminSkillsEnabled: boolean;
private readonly experimentalJitContext: boolean;
private readonly deferInitialMemoryLoad: boolean;
private readonly disableLLMCorrection: boolean;
private readonly planEnabled: boolean;
private readonly planModeRoutingEnabled: boolean;
@@ -832,6 +834,7 @@ export class Config {
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(),
@@ -1047,6 +1050,23 @@ export class Config {
this.workspaceContext.addDirectory(plansDir);
}
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;
}