mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-13 07:30:52 -07:00
fix(core): robust workspace-based IDE connection discovery (#18443)
This commit is contained in:
@@ -49,6 +49,8 @@ describe('ide-connection-utils', () => {
|
||||
|
||||
vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir');
|
||||
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -133,6 +135,159 @@ describe('ide-connection-utils', () => {
|
||||
expect(result).toEqual(validConfig);
|
||||
});
|
||||
|
||||
it('should fall back to a different PID if it matches the current workspace', async () => {
|
||||
const targetPid = 12345;
|
||||
const otherPid = 67890;
|
||||
const validConfig = {
|
||||
port: '5678',
|
||||
workspacePath: '/test/workspace',
|
||||
};
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([`gemini-ide-server-${otherPid}-111.json`]);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
|
||||
JSON.stringify(validConfig),
|
||||
);
|
||||
|
||||
const result = await getConnectionConfigFromFile(targetPid);
|
||||
|
||||
expect(result).toEqual(validConfig);
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join(
|
||||
'/tmp',
|
||||
'gemini',
|
||||
'ide',
|
||||
`gemini-ide-server-${otherPid}-111.json`,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize the target PID over other PIDs', async () => {
|
||||
const targetPid = 12345;
|
||||
const otherPid = 67890;
|
||||
const targetConfig = { port: '1111', workspacePath: '/test/workspace' };
|
||||
const otherConfig = { port: '2222', workspacePath: '/test/workspace' };
|
||||
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
`gemini-ide-server-${otherPid}-1.json`,
|
||||
`gemini-ide-server-${targetPid}-1.json`,
|
||||
]);
|
||||
|
||||
// readFile will be called for both files in the sorted order.
|
||||
// We expect targetPid file to be first.
|
||||
vi.mocked(fs.promises.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(targetConfig))
|
||||
.mockResolvedValueOnce(JSON.stringify(otherConfig));
|
||||
|
||||
const result = await getConnectionConfigFromFile(targetPid);
|
||||
|
||||
expect(result).toEqual(targetConfig);
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join(
|
||||
'/tmp',
|
||||
'gemini',
|
||||
'ide',
|
||||
`gemini-ide-server-${targetPid}-1.json`,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize an alive process over a dead one', async () => {
|
||||
const targetPid = 12345; // target not present
|
||||
const alivePid = 22222;
|
||||
const deadPid = 11111;
|
||||
const aliveConfig = { port: '2222', workspacePath: '/test/workspace' };
|
||||
const deadConfig = { port: '1111', workspacePath: '/test/workspace' };
|
||||
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
`gemini-ide-server-${deadPid}-1.json`,
|
||||
`gemini-ide-server-${alivePid}-1.json`,
|
||||
]);
|
||||
|
||||
vi.spyOn(process, 'kill').mockImplementation((pid) => {
|
||||
if (pid === alivePid) return true;
|
||||
throw new Error('dead');
|
||||
});
|
||||
|
||||
vi.mocked(fs.promises.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(aliveConfig))
|
||||
.mockResolvedValueOnce(JSON.stringify(deadConfig));
|
||||
|
||||
const result = await getConnectionConfigFromFile(targetPid);
|
||||
|
||||
expect(result).toEqual(aliveConfig);
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join(
|
||||
'/tmp',
|
||||
'gemini',
|
||||
'ide',
|
||||
`gemini-ide-server-${alivePid}-1.json`,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize the largest PID (newest) among alive processes', async () => {
|
||||
const targetPid = 12345; // target not present
|
||||
const oldPid = 20000;
|
||||
const newPid = 30000;
|
||||
const oldConfig = { port: '2000', workspacePath: '/test/workspace' };
|
||||
const newConfig = { port: '3000', workspacePath: '/test/workspace' };
|
||||
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
new Error('not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([
|
||||
`gemini-ide-server-${oldPid}-1.json`,
|
||||
`gemini-ide-server-${newPid}-1.json`,
|
||||
]);
|
||||
|
||||
// Both are alive
|
||||
vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
|
||||
vi.mocked(fs.promises.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(newConfig))
|
||||
.mockResolvedValueOnce(JSON.stringify(oldConfig));
|
||||
|
||||
const result = await getConnectionConfigFromFile(targetPid);
|
||||
|
||||
expect(result).toEqual(newConfig);
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||
path.join(
|
||||
'/tmp',
|
||||
'gemini',
|
||||
'ide',
|
||||
`gemini-ide-server-${newPid}-1.json`,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the first valid config when multiple workspaces are valid', async () => {
|
||||
const config1 = { port: '1111', workspacePath: '/test/workspace' };
|
||||
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as os from 'node:os';
|
||||
import { EnvHttpProxyAgent } from 'undici';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
|
||||
import { isNodeError } from '../utils/errors.js';
|
||||
import { type IdeInfo } from './detect-ide.js';
|
||||
|
||||
const logger = {
|
||||
@@ -104,6 +105,8 @@ export function getStdioConfigFromEnv(): StdioConfig | undefined {
|
||||
return { command, args };
|
||||
}
|
||||
|
||||
const IDE_SERVER_FILE_REGEX = /^gemini-ide-server-(\d+)-\d+\.json$/;
|
||||
|
||||
export async function getConnectionConfigFromFile(
|
||||
pid: number,
|
||||
): Promise<
|
||||
@@ -139,12 +142,16 @@ export async function getConnectionConfigFromFile(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fileRegex = new RegExp(`^gemini-ide-server-${pid}-\\d+\\.json$`);
|
||||
const matchingFiles = portFiles.filter((file) => fileRegex.test(file)).sort();
|
||||
const matchingFiles = portFiles.filter((file) =>
|
||||
IDE_SERVER_FILE_REGEX.test(file),
|
||||
);
|
||||
|
||||
if (matchingFiles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
sortConnectionFiles(matchingFiles, pid);
|
||||
|
||||
let fileContents: string[];
|
||||
try {
|
||||
fileContents = await Promise.all(
|
||||
@@ -181,20 +188,86 @@ export async function getConnectionConfigFromFile(
|
||||
}
|
||||
|
||||
if (validWorkspaces.length === 1) {
|
||||
return validWorkspaces[0];
|
||||
const selected = validWorkspaces[0];
|
||||
const fileIndex = parsedContents.indexOf(selected);
|
||||
if (fileIndex !== -1) {
|
||||
logger.debug(`Selected IDE connection file: ${matchingFiles[fileIndex]}`);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
const portFromEnv = getPortFromEnv();
|
||||
if (portFromEnv) {
|
||||
const matchingPort = validWorkspaces.find(
|
||||
const matchingPortIndex = validWorkspaces.findIndex(
|
||||
(content) => String(content.port) === portFromEnv,
|
||||
);
|
||||
if (matchingPort) {
|
||||
return matchingPort;
|
||||
if (matchingPortIndex !== -1) {
|
||||
const selected = validWorkspaces[matchingPortIndex];
|
||||
const fileIndex = parsedContents.indexOf(selected);
|
||||
if (fileIndex !== -1) {
|
||||
logger.debug(
|
||||
`Selected IDE connection file (matched port from env): ${matchingFiles[fileIndex]}`,
|
||||
);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
|
||||
return validWorkspaces[0];
|
||||
const selected = validWorkspaces[0];
|
||||
const fileIndex = parsedContents.indexOf(selected);
|
||||
if (fileIndex !== -1) {
|
||||
logger.debug(
|
||||
`Selected first valid IDE connection file: ${matchingFiles[fileIndex]}`,
|
||||
);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
// Sort files to prioritize the one matching the target pid,
|
||||
// then by whether the process is still alive, then by newest (largest PID).
|
||||
function sortConnectionFiles(files: string[], targetPid: number) {
|
||||
files.sort((a, b) => {
|
||||
const aMatch = a.match(IDE_SERVER_FILE_REGEX);
|
||||
const bMatch = b.match(IDE_SERVER_FILE_REGEX);
|
||||
const aPid = aMatch ? parseInt(aMatch[1], 10) : 0;
|
||||
const bPid = bMatch ? parseInt(bMatch[1], 10) : 0;
|
||||
|
||||
if (aPid === targetPid && bPid !== targetPid) {
|
||||
return -1;
|
||||
}
|
||||
if (bPid === targetPid && aPid !== targetPid) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const aIsAlive = isPidAlive(aPid);
|
||||
const bIsAlive = isPidAlive(bPid);
|
||||
|
||||
if (aIsAlive && !bIsAlive) {
|
||||
return -1;
|
||||
}
|
||||
if (bIsAlive && !aIsAlive) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Newest PIDs first as a heuristic
|
||||
return bPid - aPid;
|
||||
});
|
||||
}
|
||||
|
||||
function isPidAlive(pid: number): boolean {
|
||||
if (pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
// Assume the process is alive since checking would introduce significant overhead.
|
||||
if (os.platform() === 'win32') {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return isNodeError(e) && e.code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProxyAwareFetch(ideServerHost: string) {
|
||||
|
||||
Reference in New Issue
Block a user