diff --git a/packages/core/src/ide/process-utils.test.ts b/packages/core/src/ide/process-utils.test.ts index e6c68f14..a049406d 100644 --- a/packages/core/src/ide/process-utils.test.ts +++ b/packages/core/src/ide/process-utils.test.ts @@ -50,7 +50,7 @@ describe('getIdeProcessInfo', () => { expect(result).toEqual({ pid: 700, command: '/usr/lib/vscode/code' }); }); - it('should return parent process info if grandparent lookup fails', async () => { + it('should return shell process info if grandparent lookup fails', async () => { (os.platform as Mock).mockReturnValue('linux'); mockedExec .mockResolvedValueOnce({ stdout: '800 /bin/bash' }) // pid 1000 -> ppid 800 (shell) @@ -63,134 +63,96 @@ describe('getIdeProcessInfo', () => { }); describe('on Windows', () => { - it('should traverse up and find the great-grandchild of the root process', async () => { + it('should return great-grandparent process using heuristic', async () => { (os.platform as Mock).mockReturnValue('win32'); - const processInfoMap = new Map([ - [ - 1000, - { - stdout: - '{"Name":"node.exe","ParentProcessId":900,"CommandLine":"node.exe"}', - }, - ], - [ - 900, - { - stdout: - '{"Name":"powershell.exe","ParentProcessId":800,"CommandLine":"powershell.exe"}', - }, - ], - [ - 800, - { - stdout: - '{"Name":"code.exe","ParentProcessId":700,"CommandLine":"code.exe"}', - }, - ], - [ - 700, - { - stdout: - '{"Name":"wininit.exe","ParentProcessId":0,"CommandLine":"wininit.exe"}', - }, - ], - ]); - mockedExec.mockImplementation((command: string) => { - const pidMatch = command.match(/ProcessId=(\d+)/); - if (pidMatch) { - const pid = parseInt(pidMatch[1], 10); - return Promise.resolve(processInfoMap.get(pid)); + + const processes = [ + { + ProcessId: 1000, + ParentProcessId: 900, + Name: 'node.exe', + CommandLine: 'node.exe', + }, + { + ProcessId: 900, + ParentProcessId: 800, + Name: 'powershell.exe', + CommandLine: 'powershell.exe', + }, + { + ProcessId: 800, + ParentProcessId: 700, + Name: 'code.exe', + CommandLine: 'code.exe', + }, + { + ProcessId: 700, + ParentProcessId: 0, + Name: 'wininit.exe', + CommandLine: 'wininit.exe', + }, + ]; + + mockedExec.mockImplementation((file: string, _args: string[]) => { + if (file === 'powershell') { + return Promise.resolve({ stdout: JSON.stringify(processes) }); } - return Promise.reject(new Error('Invalid command for mock')); + return Promise.resolve({ stdout: '' }); }); const result = await getIdeProcessInfo(); + // Process chain: 1000 (node.exe) -> 900 (powershell.exe) -> 800 (code.exe) -> 700 (wininit.exe) + // ancestors = [1000, 900, 800, 700], length = 4 + // Heuristic: return ancestors[length-3] = ancestors[1] = 900 (powershell.exe) expect(result).toEqual({ pid: 900, command: 'powershell.exe' }); }); - it('should handle non-existent process gracefully', async () => { + it('should handle empty process list gracefully', async () => { (os.platform as Mock).mockReturnValue('win32'); - mockedExec - .mockResolvedValueOnce({ stdout: '' }) // Non-existent PID returns empty due to -ErrorAction SilentlyContinue - .mockResolvedValueOnce({ - stdout: - '{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}', - }); // Fallback call + mockedExec.mockResolvedValue({ stdout: '[]' }); const result = await getIdeProcessInfo(); - expect(result).toEqual({ pid: 1000, command: 'fallback.exe' }); + // Should return current pid and empty command because process not found in map + expect(result).toEqual({ pid: 1000, command: '' }); }); it('should handle malformed JSON output gracefully', async () => { (os.platform as Mock).mockReturnValue('win32'); - mockedExec - .mockResolvedValueOnce({ stdout: '{"invalid":json}' }) // Malformed JSON - .mockResolvedValueOnce({ - stdout: - '{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}', - }); // Fallback call + mockedExec.mockResolvedValue({ stdout: '{"invalid":json}' }); const result = await getIdeProcessInfo(); - expect(result).toEqual({ pid: 1000, command: 'fallback.exe' }); + expect(result).toEqual({ pid: 1000, command: '' }); }); - it('should handle PowerShell errors without crashing the process chain', async () => { + it('should return last ancestor if chain is too short', async () => { (os.platform as Mock).mockReturnValue('win32'); - const processInfoMap = new Map([ - [1000, { stdout: '' }], // First process doesn't exist (empty due to -ErrorAction) - [ - 1001, - { - stdout: - '{"Name":"parent.exe","ParentProcessId":800,"CommandLine":"parent.exe"}', - }, - ], - [ - 800, - { - stdout: - '{"Name":"ide.exe","ParentProcessId":0,"CommandLine":"ide.exe"}', - }, - ], - ]); - // Mock the process.pid to test traversal with missing processes - Object.defineProperty(process, 'pid', { - value: 1001, - configurable: true, - }); + const processes = [ + { + ProcessId: 1000, + ParentProcessId: 900, + Name: 'node.exe', + CommandLine: 'node.exe', + }, + { + ProcessId: 900, + ParentProcessId: 0, + Name: 'explorer.exe', + CommandLine: 'explorer.exe', + }, + ]; - mockedExec.mockImplementation((command: string) => { - const pidMatch = command.match(/ProcessId=(\d+)/); - if (pidMatch) { - const pid = parseInt(pidMatch[1], 10); - return Promise.resolve(processInfoMap.get(pid) || { stdout: '' }); + mockedExec.mockImplementation((file: string, _args: string[]) => { + if (file === 'powershell') { + return Promise.resolve({ stdout: JSON.stringify(processes) }); } - return Promise.reject(new Error('Invalid command for mock')); + return Promise.resolve({ stdout: '' }); }); const result = await getIdeProcessInfo(); - // Should return the current process command since traversal continues despite missing processes - expect(result).toEqual({ pid: 1001, command: 'parent.exe' }); - - // Reset process.pid - Object.defineProperty(process, 'pid', { - value: 1000, - configurable: true, - }); - }); - - it('should handle partial JSON data with defaults', async () => { - (os.platform as Mock).mockReturnValue('win32'); - mockedExec - .mockResolvedValueOnce({ stdout: '{"Name":"partial.exe"}' }) // Missing ParentProcessId, defaults to 0 - .mockResolvedValueOnce({ - stdout: - '{"Name":"root.exe","ParentProcessId":0,"CommandLine":"root.exe"}', - }); // Get grandparent info - - const result = await getIdeProcessInfo(); - expect(result).toEqual({ pid: 1000, command: 'root.exe' }); + // ancestors = [1000, 900], length = 2 (< 3) + // Heuristic: return ancestors[length-1] = ancestors[1] = 900 (explorer.exe) + expect(result).toEqual({ pid: 900, command: 'explorer.exe' }); }); }); }); diff --git a/packages/core/src/ide/process-utils.ts b/packages/core/src/ide/process-utils.ts index 170b1df1..617c5650 100644 --- a/packages/core/src/ide/process-utils.ts +++ b/packages/core/src/ide/process-utils.ts @@ -4,74 +4,28 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { exec } from 'node:child_process'; +import { exec, execFile } from 'node:child_process'; import { promisify } from 'node:util'; import os from 'node:os'; import path from 'node:path'; const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); const MAX_TRAVERSAL_DEPTH = 32; -/** - * Fetches the parent process ID, name, and command for a given process ID. - * - * @param pid The process ID to inspect. - * @returns A promise that resolves to the parent's PID, name, and command. - */ async function getProcessInfo(pid: number): Promise<{ parentPid: number; name: string; command: string; }> { - try { - const platform = os.platform(); - if (platform === 'win32') { - const powershellCommand = [ - '$p = Get-CimInstance Win32_Process', - `-Filter 'ProcessId=${pid}'`, - '-ErrorAction SilentlyContinue;', - 'if ($p) {', - '@{Name=$p.Name;ParentProcessId=$p.ParentProcessId;CommandLine=$p.CommandLine}', - '| ConvertTo-Json', - '}', - ].join(' '); - const { stdout } = await execAsync(`powershell "${powershellCommand}"`); - const output = stdout.trim(); - if (!output) return { parentPid: 0, name: '', command: '' }; - const { - Name = '', - ParentProcessId = 0, - CommandLine = '', - } = JSON.parse(output); - return { - parentPid: ParentProcessId, - name: Name, - command: CommandLine ?? '', - }; - } else { - const command = `ps -o ppid=,command= -p ${pid}`; - const { stdout } = await execAsync(command); - const trimmedStdout = stdout.trim(); - if (!trimmedStdout) { - return { parentPid: 0, name: '', command: '' }; - } - const ppidString = trimmedStdout.split(/\s+/)[0]; - const parentPid = parseInt(ppidString, 10); - const fullCommand = trimmedStdout.substring(ppidString.length).trim(); - const processName = path.basename(fullCommand.split(' ')[0]); - return { - parentPid: isNaN(parentPid) ? 1 : parentPid, - name: processName, - command: fullCommand, - }; - } - } catch (_e) { - console.debug(`Failed to get process info for pid ${pid}:`, _e); - return { parentPid: 0, name: '', command: '' }; - } + // Only used for Unix systems (macOS and Linux) + const { stdout } = await execAsync(`ps -p ${pid} -o ppid=,comm=`); + const [ppidStr, ...commandParts] = stdout.trim().split(/\s+/); + const parentPid = parseInt(ppidStr, 10); + const command = commandParts.join(' '); + return { parentPid, name: path.basename(command), command }; } - /** * Finds the IDE process info on Unix-like systems. * @@ -106,15 +60,15 @@ async function getIdeProcessInfoForUnix(): Promise<{ } catch { // Ignore if getting grandparent fails, we'll just use the parent pid. } - const { command } = await getProcessInfo(idePid); - return { pid: idePid, command }; + const { command: ideCommand } = await getProcessInfo(idePid); + return { pid: idePid, command: ideCommand }; } if (parentPid <= 1) { break; // Reached the root } currentPid = parentPid; - } catch { + } catch (_e) { // Process in chain died break; } @@ -124,50 +78,104 @@ async function getIdeProcessInfoForUnix(): Promise<{ return { pid: currentPid, command }; } +interface ProcessInfo { + pid: number; + parentPid: number; + name: string; + command: string; +} + +interface RawProcessInfo { + ProcessId?: number; + ParentProcessId?: number; + Name?: string; + CommandLine?: string; +} + /** - * Finds the IDE process info on Windows. - * - * The strategy is to find the great-grandchild of the root process. - * - * @returns A promise that resolves to the PID and command of the IDE process. + * Fetches the entire process table on Windows. */ +async function getProcessTableWindows(): Promise> { + const processMap = new Map(); + try { + const powershellCommand = + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress'; + const { stdout } = await execFileAsync( + 'powershell', + ['-NoProfile', '-NonInteractive', '-Command', powershellCommand], + { maxBuffer: 10 * 1024 * 1024 }, + ); + + if (!stdout.trim()) { + return processMap; + } + + let processes: RawProcessInfo | RawProcessInfo[]; + try { + processes = JSON.parse(stdout); + } catch (_e) { + return processMap; + } + + if (!Array.isArray(processes)) { + processes = [processes]; + } + + for (const p of processes) { + if (p && typeof p.ProcessId === 'number') { + processMap.set(p.ProcessId, { + pid: p.ProcessId, + parentPid: p.ParentProcessId || 0, + name: p.Name || '', + command: p.CommandLine || '', + }); + } + } + } catch (_e) { + // Fallback or error handling if PowerShell fails + } + return processMap; +} + async function getIdeProcessInfoForWindows(): Promise<{ pid: number; command: string; }> { - let currentPid = process.pid; - let previousPid = process.pid; + // Fetch the entire process table in one go. + const processMap = await getProcessTableWindows(); - for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) { - try { - const { parentPid } = await getProcessInfo(currentPid); + const myPid = process.pid; + const myProc = processMap.get(myPid); - if (parentPid > 0) { - try { - const { parentPid: grandParentPid } = await getProcessInfo(parentPid); - if (grandParentPid === 0) { - // We've found the grandchild of the root (`currentPid`). The IDE - // process is its child, which we've stored in `previousPid`. - const { command } = await getProcessInfo(previousPid); - return { pid: previousPid, command }; - } - } catch { - // getting grandparent failed, proceed - } - } + if (!myProc) { + // Fallback: return current process info if snapshot fails + return { pid: myPid, command: '' }; + } - if (parentPid <= 0) { - break; // Reached the root - } - previousPid = currentPid; - currentPid = parentPid; - } catch { - // Process in chain died + // Perform tree traversal in memory + const ancestors: ProcessInfo[] = []; + let curr: ProcessInfo | undefined = myProc; + + for (let i = 0; i < MAX_TRAVERSAL_DEPTH && curr; i++) { + ancestors.push(curr); + + if (curr.parentPid === 0 || !processMap.has(curr.parentPid)) { + // Parent process not in map, stop traversal break; } + curr = processMap.get(curr.parentPid); } - const { command } = await getProcessInfo(currentPid); - return { pid: currentPid, command }; + + // Use heuristic: return the great-grandparent (ancestors[length-3]) + if (ancestors.length >= 3) { + const target = ancestors[ancestors.length - 3]; + return { pid: target.pid, command: target.command }; + } else if (ancestors.length > 0) { + const target = ancestors[ancestors.length - 1]; + return { pid: target.pid, command: target.command }; + } + + return { pid: myPid, command: myProc.command }; } /**