From 4f2b2d0a3e5d469347038a32f9bd8cac98e36d4e Mon Sep 17 00:00:00 2001 From: xuewenjie Date: Fri, 12 Dec 2025 15:10:16 +0800 Subject: [PATCH] fix: optimize windows process tree retrieval to prevent hang --- packages/core/src/ide/process-utils.test.ts | 181 ++++++++------------ packages/core/src/ide/process-utils.ts | 84 ++++++--- 2 files changed, 129 insertions(+), 136 deletions(-) diff --git a/packages/core/src/ide/process-utils.test.ts b/packages/core/src/ide/process-utils.test.ts index e6c68f14..d4ca1150 100644 --- a/packages/core/src/ide/process-utils.test.ts +++ b/packages/core/src/ide/process-utils.test.ts @@ -65,132 +65,93 @@ describe('getIdeProcessInfo', () => { describe('on Windows', () => { it('should traverse up and find the great-grandchild of the root process', 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')); + // Fallback for getProcessInfo calls if any (should not happen in new logic for Windows traversal) + return Promise.resolve({ stdout: '' }); }); const result = await getIdeProcessInfo(); + // 1000 -> 900 -> 800 -> 700 (root child) + // Great-grandchild of root (700) is 900. + // Wait, logic is: + // 700 (root child) -> 800 (grandchild) -> 900 (great-grandchild) -> 1000 (current) + // The code looks for the grandchild of the root. + // Root is 0 (conceptually). Child of root is 700. Grandchild is 800. + // The code says: + // "We've found the grandchild of the root (`currentPid`). The IDE process is its child, which we've stored in `previousPid`." + + // Let's trace the loop in `getIdeProcessInfoForWindows`: + // currentPid = 1000, previousPid = 1000 + // Loop 1: + // proc = 1000. parentPid = 900. + // parentProc = 900. parentProc.parentPid = 800 != 0. + // previousPid = 1000. currentPid = 900. + // Loop 2: + // proc = 900. parentPid = 800. + // parentProc = 800. parentProc.parentPid = 700 != 0. + // previousPid = 900. currentPid = 800. + // Loop 3: + // proc = 800. parentPid = 700. + // parentProc = 700. parentProc.parentPid = 0. + // MATCH! + // ideProc = processMap.get(previousPid) = processMap.get(900) = powershell.exe + // return { pid: 900, command: '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}' }); // Malformed JSON will throw in JSON.parse + + // If JSON.parse fails, getProcessTreeForWindows returns empty map. + // Then getIdeProcessInfoForWindows returns current pid. + + // Wait, mockedExec throws? No, JSON.parse throws. + // getProcessTreeForWindows catches error and returns empty map. const result = await getIdeProcessInfo(); - expect(result).toEqual({ pid: 1000, command: 'fallback.exe' }); - }); - - it('should handle PowerShell errors without crashing the process chain', 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, - }); - - 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: '' }); - } - return Promise.reject(new Error('Invalid command for mock')); - }); - - 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' }); + expect(result).toEqual({ pid: 1000, command: '' }); }); }); }); diff --git a/packages/core/src/ide/process-utils.ts b/packages/core/src/ide/process-utils.ts index 170b1df1..c4138921 100644 --- a/packages/core/src/ide/process-utils.ts +++ b/packages/core/src/ide/process-utils.ts @@ -4,12 +4,13 @@ * 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; @@ -36,7 +37,13 @@ async function getProcessInfo(pid: number): Promise<{ '| ConvertTo-Json', '}', ].join(' '); - const { stdout } = await execAsync(`powershell "${powershellCommand}"`); + + const { stdout } = await execFileAsync('powershell', [ + '-NoProfile', + '-NonInteractive', + '-Command', + powershellCommand, + ]); const output = stdout.trim(); if (!output) return { parentPid: 0, name: '', command: '' }; const { @@ -124,6 +131,37 @@ async function getIdeProcessInfoForUnix(): Promise<{ return { pid: currentPid, command }; } +async function getProcessTreeForWindows(): Promise< + Map +> { + try { + const powershellCommand = + 'Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, Name, CommandLine | ConvertTo-Json -Depth 1'; + const { stdout } = await execFileAsync( + 'powershell', + ['-NoProfile', '-NonInteractive', '-Command', powershellCommand], + { maxBuffer: 10 * 1024 * 1024 }, + ); + + const processes = JSON.parse(stdout); + const map = new Map(); + + const list = Array.isArray(processes) ? processes : [processes]; + + for (const p of list) { + map.set(p.ProcessId, { + parentPid: p.ParentProcessId, + name: p.Name, + command: p.CommandLine || '', + }); + } + return map; + } catch (e) { + console.error('Failed to get process tree:', e); + return new Map(); + } +} + /** * Finds the IDE process info on Windows. * @@ -135,39 +173,33 @@ async function getIdeProcessInfoForWindows(): Promise<{ pid: number; command: string; }> { + const processMap = await getProcessTreeForWindows(); let currentPid = process.pid; let previousPid = process.pid; for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) { - try { - const { parentPid } = await getProcessInfo(currentPid); + const proc = processMap.get(currentPid); + if (!proc) break; - 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 - } - } + const parentPid = proc.parentPid; - if (parentPid <= 0) { - break; // Reached the root + if (parentPid > 0) { + const parentProc = processMap.get(parentPid); + if (parentProc && parentProc.parentPid === 0) { + // We've found the grandchild of the root (`currentPid`). The IDE + // process is its child, which we've stored in `previousPid`. + const ideProc = processMap.get(previousPid); + return { pid: previousPid, command: ideProc?.command || '' }; } - previousPid = currentPid; - currentPid = parentPid; - } catch { - // Process in chain died - break; } + + if (parentPid <= 0) break; + previousPid = currentPid; + currentPid = parentPid; } - const { command } = await getProcessInfo(currentPid); - return { pid: currentPid, command }; + + const currentProc = processMap.get(currentPid); + return { pid: currentPid, command: currentProc?.command || '' }; } /**