Files
gemini-cli/packages/cli/src/utils/devtoolsService.ts

219 lines
6.3 KiB
TypeScript
Raw Permalink Normal View History

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { debugLogger } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import WebSocket from 'ws';
import {
initActivityLogger,
addNetworkTransport,
ActivityLogger,
} from './activityLogger.js';
interface IDevTools {
start(): Promise<string>;
stop(): Promise<void>;
getPort(): number;
}
const DEVTOOLS_PKG = 'gemini-cli-devtools';
const DEFAULT_DEVTOOLS_PORT = 25417;
const DEFAULT_DEVTOOLS_HOST = '127.0.0.1';
const MAX_PROMOTION_ATTEMPTS = 3;
let promotionAttempts = 0;
let serverStartPromise: Promise<string> | null = null;
let connectedUrl: string | null = null;
/**
* Probe whether a DevTools server is already listening on the given host:port.
* Returns true if a WebSocket handshake succeeds within a short timeout.
*/
function probeDevTools(host: string, port: number): Promise<boolean> {
return new Promise((resolve) => {
const ws = new WebSocket(`ws://${host}:${port}/ws`);
const timer = setTimeout(() => {
ws.close();
resolve(false);
}, 500);
ws.on('open', () => {
clearTimeout(timer);
ws.close();
resolve(true);
});
ws.on('error', () => {
clearTimeout(timer);
ws.close();
resolve(false);
});
});
}
/**
* Start a DevTools server, then check if we won the default port.
* If another instance grabbed it first (race), stop ours and connect as client.
* Returns { host, port } of the DevTools to connect to.
*/
async function startOrJoinDevTools(
defaultHost: string,
defaultPort: number,
): Promise<{ host: string; port: number }> {
const mod = await import(DEVTOOLS_PKG);
const devtools: IDevTools = mod.DevTools.getInstance();
const url = await devtools.start();
const actualPort = devtools.getPort();
if (actualPort === defaultPort) {
// We won the port — we are the server
debugLogger.log(`DevTools available at: ${url}`);
return { host: defaultHost, port: actualPort };
}
// Lost the race — someone else has the default port.
// Verify the winner is actually alive, then stop ours and connect to theirs.
const winnerAlive = await probeDevTools(defaultHost, defaultPort);
if (winnerAlive) {
await devtools.stop();
debugLogger.log(
`DevTools (existing) at: http://${defaultHost}:${defaultPort}`,
);
return { host: defaultHost, port: defaultPort };
}
// Winner isn't responding (maybe also racing and failed) — keep ours
debugLogger.log(`DevTools available at: ${url}`);
return { host: defaultHost, port: actualPort };
}
/**
* Handle promotion: when reconnect fails, start or join a DevTools server
* and add a new network transport for the logger.
*/
async function handlePromotion(config: Config) {
promotionAttempts++;
if (promotionAttempts > MAX_PROMOTION_ATTEMPTS) {
debugLogger.debug(
`Giving up on DevTools promotion after ${MAX_PROMOTION_ATTEMPTS} attempts`,
);
return;
}
try {
const result = await startOrJoinDevTools(
DEFAULT_DEVTOOLS_HOST,
DEFAULT_DEVTOOLS_PORT,
);
addNetworkTransport(config, result.host, result.port, () =>
handlePromotion(config),
);
} catch (err) {
debugLogger.debug('Failed to promote to DevTools server:', err);
}
}
/**
* Initializes the activity logger.
* Interception starts immediately in buffering mode.
* If an existing DevTools server is found, attaches transport eagerly.
*/
export async function setupInitialActivityLogger(config: Config) {
const target = process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'];
if (target) {
if (!config.storage) return;
initActivityLogger(config, { mode: 'file', filePath: target });
} else {
// Start in buffering mode (no transport attached yet)
initActivityLogger(config, { mode: 'buffer' });
// Eagerly probe for an existing DevTools server
try {
const existing = await probeDevTools(
DEFAULT_DEVTOOLS_HOST,
DEFAULT_DEVTOOLS_PORT,
);
if (existing) {
const onReconnectFailed = () => handlePromotion(config);
addNetworkTransport(
config,
DEFAULT_DEVTOOLS_HOST,
DEFAULT_DEVTOOLS_PORT,
onReconnectFailed,
);
ActivityLogger.getInstance().enableNetworkLogging();
connectedUrl = `http://localhost:${DEFAULT_DEVTOOLS_PORT}`;
debugLogger.log(`DevTools (existing) at startup: ${connectedUrl}`);
}
} catch {
// Probe failed silently — stay in buffer mode
}
}
}
/**
* Starts the DevTools server and opens the UI in the browser.
* Returns the URL to the DevTools UI.
* Deduplicates concurrent calls returns the same promise if already in flight.
*/
export function startDevToolsServer(config: Config): Promise<string> {
if (connectedUrl) return Promise.resolve(connectedUrl);
if (serverStartPromise) return serverStartPromise;
serverStartPromise = startDevToolsServerImpl(config).catch((err) => {
serverStartPromise = null;
throw err;
});
return serverStartPromise;
}
async function startDevToolsServerImpl(config: Config): Promise<string> {
const onReconnectFailed = () => handlePromotion(config);
// Probe for an existing DevTools server
const existing = await probeDevTools(
DEFAULT_DEVTOOLS_HOST,
DEFAULT_DEVTOOLS_PORT,
);
let host = DEFAULT_DEVTOOLS_HOST;
let port = DEFAULT_DEVTOOLS_PORT;
if (existing) {
debugLogger.log(
`DevTools (existing) at: http://${DEFAULT_DEVTOOLS_HOST}:${DEFAULT_DEVTOOLS_PORT}`,
);
} else {
// No existing server — start (or join if we lose the race)
try {
const result = await startOrJoinDevTools(
DEFAULT_DEVTOOLS_HOST,
DEFAULT_DEVTOOLS_PORT,
);
host = result.host;
port = result.port;
} catch (err) {
debugLogger.debug('Failed to start DevTools:', err);
throw err;
}
}
// Promote the activity logger to use the network transport
addNetworkTransport(config, host, port, onReconnectFailed);
const capture = ActivityLogger.getInstance();
capture.enableNetworkLogging();
const url = `http://localhost:${port}`;
connectedUrl = url;
return url;
}
/** Reset module-level state — test only. */
export function resetForTesting() {
promotionAttempts = 0;
serverStartPromise = null;
connectedUrl = null;
}