From 880af43b023b78d9a3e45e8ed5df6aed7eca6b70 Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Thu, 19 Feb 2026 10:59:33 -0500 Subject: [PATCH] fix(core): robust workspace-based IDE connection discovery (#18443) --- .../core/src/ide/ide-connection-utils.test.ts | 155 ++++++++++++++++++ packages/core/src/ide/ide-connection-utils.ts | 87 +++++++++- 2 files changed, 235 insertions(+), 7 deletions(-) diff --git a/packages/core/src/ide/ide-connection-utils.test.ts b/packages/core/src/ide/ide-connection-utils.test.ts index 19d955ca36..99e62951be 100644 --- a/packages/core/src/ide/ide-connection-utils.test.ts +++ b/packages/core/src/ide/ide-connection-utils.test.ts @@ -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 + > + ).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 + > + ).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 + > + ).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 + > + ).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' }; diff --git a/packages/core/src/ide/ide-connection-utils.ts b/packages/core/src/ide/ide-connection-utils.ts index 2414064f5d..ce01b5997c 100644 --- a/packages/core/src/ide/ide-connection-utils.ts +++ b/packages/core/src/ide/ide-connection-utils.ts @@ -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) {