fix(core): robust workspace-based IDE connection discovery (#18443)

This commit is contained in:
Emily Hedlund
2026-02-19 10:59:33 -05:00
committed by GitHub
parent 966eef14ee
commit 880af43b02
2 changed files with 235 additions and 7 deletions

View File

@@ -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' };

View File

@@ -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) {