remove pid from lockfile name of ide connection file

This commit is contained in:
tanzhenxin
2025-12-19 18:12:04 +08:00
parent a8a863581b
commit c5c556a326
5 changed files with 142 additions and 360 deletions

View File

@@ -102,17 +102,12 @@ describe('IdeClient', () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/home/test', '.qwen', 'ide', '12345-8080.lock'),
path.join('/home/test', '.qwen', 'ide', '8080.lock'),
'utf8',
);
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
@@ -127,13 +122,9 @@ describe('IdeClient', () => {
});
it('should connect using stdio when stdio config is provided in file', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
@@ -146,19 +137,16 @@ describe('IdeClient', () => {
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
it('should prioritize port over stdio when both are in config file', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '8080';
const config = {
port: '8080',
stdio: { command: 'test-cmd', args: ['--foo'] },
};
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
@@ -168,6 +156,7 @@ describe('IdeClient', () => {
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
it('should connect using HTTP when port is provided in environment variables', async () => {
@@ -282,7 +271,7 @@ describe('IdeClient', () => {
expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/home/test', '.qwen', 'ide', '12345-1234.lock'),
path.join('/home/test', '.qwen', 'ide', '1234.lock'),
'utf8',
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
@@ -290,11 +279,6 @@ describe('IdeClient', () => {
it('should return undefined if no config files are found', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('not found'));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -306,26 +290,15 @@ describe('IdeClient', () => {
expect(result).toBeUndefined();
});
it('should find and parse a single lock file matching the IDE pid', async () => {
it('should read legacy pid config when available', async () => {
const config = {
port: '5678',
workspacePath: '/test/workspace',
ppid: 12345,
};
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValueOnce(['12345-5678.lock']);
vi.mocked(fs.promises.stat).mockResolvedValueOnce({
mtimeMs: 123,
} as unknown as fs.Stats);
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
JSON.stringify(config),
);
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -336,106 +309,18 @@ describe('IdeClient', () => {
expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/home/test', '.qwen', 'ide', '12345-5678.lock'),
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
'utf8',
);
});
it('should filter out configs with invalid workspace paths', async () => {
const validConfig = {
port: '5678',
workspacePath: '/test/workspace',
};
const invalidConfig = {
port: '1111',
workspacePath: '/invalid/workspace',
};
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValueOnce(['12345-1111.lock', '12345-5678.lock']); // ~/.qwen/ide scan
vi.mocked(fs.promises.stat)
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats)
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(invalidConfig))
.mockResolvedValueOnce(JSON.stringify(validConfig));
const validateSpy = vi
.spyOn(IdeClient, 'validateWorkspacePath')
.mockImplementation((ideWorkspacePath) => ({
isValid: ideWorkspacePath === '/test/workspace',
}));
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(validConfig);
expect(validateSpy).toHaveBeenCalledWith(
'/invalid/workspace',
'/test/workspace/sub-dir',
);
expect(validateSpy).toHaveBeenCalledWith(
'/test/workspace',
'/test/workspace/sub-dir',
);
});
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' };
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan
// Make config1 "newer" so it wins when both are valid.
vi.mocked(fs.promises.stat)
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats)
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(config1);
});
it('should prioritize the config matching the port from the environment variable', async () => {
it('should fall back to legacy port file when pid file is missing', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '2222';
const config1 = { port: '1111', workspacePath: '/test/workspace' };
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
); // For ~/.qwen/ide/<pid>-<port>.lock
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan
vi.mocked(fs.promises.stat)
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats)
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockRejectedValueOnce(new Error('not found')) // ~/.qwen/ide/<port>.lock
.mockRejectedValueOnce(new Error('not found')) // legacy pid file
.mockResolvedValueOnce(JSON.stringify(config2));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -445,25 +330,23 @@ describe('IdeClient', () => {
).getConnectionConfigFromFile();
expect(result).toEqual(config2);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
'utf8',
);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-2222.json'),
'utf8',
);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
it('should handle invalid JSON in one of the config files', async () => {
const validConfig = { port: '2222', workspacePath: '/test/workspace' };
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValueOnce(['12345-1111.lock', '12345-2222.lock']); // ~/.qwen/ide scan
vi.mocked(fs.promises.stat)
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats)
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats);
it('should fall back to legacy config when env lock file has invalid JSON', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333';
const config = { port: '1111', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce('invalid json')
.mockResolvedValueOnce(JSON.stringify(validConfig));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
.mockResolvedValueOnce(JSON.stringify(config));
const ideClient = await IdeClient.getInstance();
const result = await (
@@ -472,99 +355,7 @@ describe('IdeClient', () => {
}
).getConnectionConfigFromFile();
expect(result).toEqual(validConfig);
});
it('should return undefined if readdir throws an error', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockRejectedValueOnce(new Error('readdir failed')); // ~/.qwen/ide scan
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toBeUndefined();
});
it('should ignore files with invalid names', async () => {
const validConfig = { port: '3333', workspacePath: '/test/workspace' };
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValueOnce([
'12345-3333.lock', // valid
'not-a-config-file.txt', // invalid
'asdf.lock', // invalid
'12345-asdf.lock', // invalid
]); // ~/.qwen/ide scan
vi.mocked(fs.promises.stat).mockResolvedValueOnce({
mtimeMs: 123,
} as unknown as fs.Stats);
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
JSON.stringify(validConfig),
);
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(validConfig);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/home/test', '.qwen', 'ide', '12345-3333.lock'),
'utf8',
);
expect(fs.promises.readFile).not.toHaveBeenCalledWith(
path.join('/home/test', '.qwen', 'ide', 'not-a-config-file.txt'),
'utf8',
);
});
it('should match env port string to a number port in the config', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333';
const config1 = { port: 1111, workspacePath: '/test/workspace' };
const config2 = { port: 3333, workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValueOnce(['12345-1111.lock', '12345-3333.lock']); // ~/.qwen/ide scan
vi.mocked(fs.promises.stat)
.mockResolvedValueOnce({ mtimeMs: 1 } as unknown as fs.Stats)
.mockResolvedValueOnce({ mtimeMs: 2 } as unknown as fs.Stats);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(config2);
expect(result).toEqual(config);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
});

View File

@@ -573,106 +573,105 @@ export class IdeClient {
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
| undefined
> {
if (!this.ideProcessInfo) {
return undefined;
}
// Preferred: lock file(s) in global qwen dir (~/.qwen/ide/<pid>-<port>.lock)
// 1) If QWEN_CODE_IDE_SERVER_PORT is set, prefer ~/.qwen/ide/<pid>-<port>.lock
// 2) Otherwise (or on failure), scan ~/.qwen/ide for <pid>-*.lock and select:
// - valid workspace path (validateWorkspacePath)
const ideDir = Storage.getGlobalIdeDir();
const idePid = this.ideProcessInfo.pid;
const portFromEnv = this.getPortFromEnv();
if (portFromEnv) {
try {
const lockFile = path.join(ideDir, `${idePid}-${portFromEnv}.lock`);
const ideDir = Storage.getGlobalIdeDir();
const lockFile = path.join(ideDir, `${portFromEnv}.lock`);
const lockFileContents = await fs.promises.readFile(lockFile, 'utf8');
return JSON.parse(lockFileContents);
} catch (_) {
// Fall through to scanning / legacy discovery.
// Fall through to legacy discovery.
}
}
try {
const fileRegex = new RegExp(`^${idePid}-\\d+\\.lock$`);
const lockFiles = (await fs.promises.readdir(ideDir)).filter((file) =>
fileRegex.test(file),
);
// Legacy discovery for VSCode extension < v0.5.1.
return this.getLegacyConnectionConfig(portFromEnv);
}
const fileContents = await Promise.all(
lockFiles.map(async (file) => {
const fullPath = path.join(ideDir, file);
try {
const stat = await fs.promises.stat(fullPath);
const content = await fs.promises.readFile(fullPath, 'utf8');
try {
const parsed = JSON.parse(content);
return { file, mtimeMs: stat.mtimeMs, parsed };
} catch (e) {
logger.debug('Failed to parse JSON from lock file: ', e);
return { file, mtimeMs: stat.mtimeMs, parsed: undefined };
}
} catch (e) {
// If we can't stat/read the file, treat it as very old so it doesn't
// win ties, and skip parsing by returning undefined content.
logger.debug('Failed to read/stat IDE lock file:', e);
return { file, mtimeMs: -Infinity, parsed: undefined };
}
}),
);
const validWorkspaces = fileContents
.filter(({ parsed }) => parsed !== undefined)
.sort((a, b) => b.mtimeMs - a.mtimeMs)
.map(({ parsed }) => parsed)
.filter((content) => {
const { isValid } = IdeClient.validateWorkspacePath(
content.workspacePath,
process.cwd(),
);
return isValid;
});
if (validWorkspaces.length > 0) {
if (validWorkspaces.length === 1) {
return validWorkspaces[0];
}
if (validWorkspaces.length > 1 && portFromEnv) {
const matchingPort = validWorkspaces.find(
(content) => String(content.port) === portFromEnv,
);
if (matchingPort) {
return matchingPort;
}
}
if (validWorkspaces.length > 1) {
return validWorkspaces[0];
}
// Legacy connection files were written in the global temp directory.
private async getLegacyConnectionConfig(
portFromEnv?: string,
): Promise<
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
| undefined
> {
if (this.ideProcessInfo) {
try {
const portFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${this.ideProcessInfo.pid}.json`,
);
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
return JSON.parse(portFileContents);
} catch (_) {
// For older/newer extension versions, the file name matches the pattern
// /^qwen-code-ide-server-${pid}-\d+\.json$/. If multiple IDE
// windows are open, multiple files matching the pattern are expected to
// exist.
}
} catch (_) {
// Fall through to legacy discovery mechanisms.
}
// For backwards compatability: single file in system temp dir named by PID.
try {
const portFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${this.ideProcessInfo.pid}.json`,
);
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
return JSON.parse(portFileContents);
} catch (_) {
// For older/newer extension versions, the file name matches the pattern
// /^qwen-code-ide-server-${pid}-\d+\.json$/. If multiple IDE
// windows are open, multiple files matching the pattern are expected to
// exist.
if (portFromEnv) {
try {
const portFile = path.join(
os.tmpdir(),
`qwen-code-ide-server-${portFromEnv}.json`,
);
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
return JSON.parse(portFileContents);
} catch (_) {
// Ignore and fall through.
}
}
return undefined;
}
protected async getAllConnectionConfigs(
ideDir: string,
): Promise<
ConnectionConfig & Array<{ workspacePath?: string; ideInfo?: IdeInfo }>
> {
const fileRegex = new RegExp('^\\d+\\.lock$');
let lockFiles: string[];
try {
lockFiles = (await fs.promises.readdir(ideDir)).filter((file) =>
fileRegex.test(file),
);
} catch (e) {
logger.debug('Failed to read IDE connection directory:', e);
return [];
}
const fileContents = await Promise.all(
lockFiles.map(async (file) => {
const fullPath = path.join(ideDir, file);
try {
const stat = await fs.promises.stat(fullPath);
const content = await fs.promises.readFile(fullPath, 'utf8');
try {
const parsed = JSON.parse(content);
return { file, mtimeMs: stat.mtimeMs, parsed };
} catch (e) {
logger.debug('Failed to parse JSON from lock file: ', e);
return { file, mtimeMs: stat.mtimeMs, parsed: undefined };
}
} catch (e) {
// If we can't stat/read the file, treat it as very old so it doesn't
// win ties, and skip parsing by returning undefined content.
logger.debug('Failed to read/stat IDE lock file:', e);
return { file, mtimeMs: -Infinity, parsed: undefined };
}
}),
);
return fileContents
.filter(({ parsed }) => parsed !== undefined)
.sort((a, b) => b.mtimeMs - a.mtimeMs)
.map(({ parsed }) => parsed);
}
private createProxyAwareFetch() {
// ignore proxy for '127.0.0.1' by deafult to allow connecting to the ide mcp server
const existingNoProxy = process.env['NO_PROXY'] || '';