diff --git a/cspell.json b/cspell.json index 33845480f2..624314dc93 100644 --- a/cspell.json +++ b/cspell.json @@ -46,9 +46,12 @@ "pgsql", "pids", "Pids", + "pylsp", "PYTHONHOME", + "pythonw", "Reselecting", "taskkill", + "tlnp", "unconfigured", "Unconfigured", "unittests", diff --git a/src/kernels/deepnote/deepnoteServerStarter.node.ts b/src/kernels/deepnote/deepnoteServerStarter.node.ts index 53c892cb92..116c952692 100644 --- a/src/kernels/deepnote/deepnoteServerStarter.node.ts +++ b/src/kernels/deepnote/deepnoteServerStarter.node.ts @@ -590,144 +590,546 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension } /** - * Cleans up any orphaned deepnote-toolkit processes from previous VS Code sessions. - * This prevents port conflicts when starting new servers. + * Check if a process is a deepnote-toolkit related process by examining its command line. */ - private async cleanupOrphanedProcesses(): Promise { + private async isDeepnoteRelatedProcess(pid: number): Promise { try { - logger.info('Checking for orphaned deepnote-toolkit processes...'); const processService = await this.processServiceFactory.create(undefined); - // Find all deepnote-toolkit server processes - let command: string; - let args: string[]; - if (process.platform === 'win32') { - // Windows: use tasklist and findstr - command = 'tasklist'; - args = ['/FI', 'IMAGENAME eq python.exe', '/FO', 'CSV', '/NH']; + // Windows: prefer PowerShell CIM, fallback to WMIC + let cmdLine = ''; + try { + const ps = await processService.exec( + 'powershell.exe', + [ + '-NoProfile', + '-Command', + `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine` + ], + { throwOnStdErr: false } + ); + cmdLine = (ps.stdout || '').toLowerCase(); + } catch { + // Ignore PowerShell errors, will fallback to WMIC + } + if (!cmdLine) { + const result = await processService.exec( + 'wmic', + ['process', 'where', `ProcessId=${pid}`, 'get', 'CommandLine'], + { throwOnStdErr: false } + ); + cmdLine = (result.stdout || '').toLowerCase(); + } + if (cmdLine) { + // Use regex to match path separators for more robust detection + const inVenv = /[\\/](deepnote-venvs)[\\/]/i.test(cmdLine); + return inVenv || cmdLine.includes('deepnote_toolkit'); + } } else { - // Unix-like: use ps and grep - command = 'ps'; - args = ['aux']; + // Unix-like: use ps with -ww to avoid truncation of long command lines + const result = await processService.exec('ps', ['-ww', '-p', pid.toString(), '-o', 'command='], { + throwOnStdErr: false + }); + if (result.stdout) { + const cmdLine = result.stdout.toLowerCase(); + // Use regex to match path separators for more robust detection + const inVenv = /[\\/](deepnote-venvs)[\\/]/i.test(cmdLine); + return inVenv || cmdLine.includes('deepnote_toolkit'); + } } + } catch (ex) { + logger.debug(`Failed to check if process ${pid} is deepnote-related: ${ex}`); + } + return false; + } - const result = await processService.exec(command, args, { throwOnStdErr: false }); + /** + * Check if a process is still alive. + */ + private async isProcessAlive(pid: number): Promise { + try { + const processService = await this.processServiceFactory.create(undefined); + if (process.platform === 'win32') { + // Use CSV format for reliable parsing + const result = await processService.exec('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], { + throwOnStdErr: false + }); - if (result.stdout) { + // Parse CSV output to find matching PID const lines = result.stdout.split('\n'); - const candidatePids: number[] = []; - for (const line of lines) { - // Look for processes running deepnote_toolkit server - if (line.includes('deepnote_toolkit') && line.includes('server')) { - // Extract PID based on platform - let pid: number | undefined; - - if (process.platform === 'win32') { - // Windows CSV format: "python.exe","12345",... - const match = line.match(/"python\.exe","(\d+)"/); - if (match) { - pid = parseInt(match[1], 10); + // Skip INFO: messages and empty lines + if (line.trim().startsWith('INFO:') || line.trim() === '') { + continue; + } + + try { + // CSV format: "ImageName","PID","SessionName","Session#","MemUsage" + // Example: "python.exe","12345","Console","1","50,000 K" + // PID is in the second column (index 1) + const match = line.match(/"[^"]*","(\d+)"/); + if (match) { + const linePid = parseInt(match[1], 10); + if (!isNaN(linePid) && linePid === pid) { + return true; + } + } + } catch { + // Ignore parse errors for individual lines + continue; + } + } + return false; + } else { + // Use kill -0 to check if process exists (doesn't actually kill) + // If it succeeds, process exists; if it fails, process doesn't exist + try { + await processService.exec('kill', ['-0', pid.toString()], { throwOnStdErr: false }); + return true; + } catch { + return false; + } + } + } catch { + return false; + } + } + + /** + * Attempt graceful kill (SIGTERM) then escalate to SIGKILL if needed. + */ + private async killProcessGracefully( + pid: number, + processService: import('../../platform/common/process/types.node').IProcessService + ): Promise { + try { + // Try graceful termination first (SIGTERM) + logger.debug(`Attempting graceful termination of process ${pid} (SIGTERM)...`); + await processService.exec('kill', [pid.toString()], { throwOnStdErr: false }); + + // Wait a bit for graceful shutdown + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Check if still alive + const stillAlive = await this.isProcessAlive(pid); + if (stillAlive) { + logger.debug(`Process ${pid} did not terminate gracefully, escalating to SIGKILL...`); + await processService.exec('kill', ['-9', pid.toString()], { throwOnStdErr: false }); + } else { + logger.debug(`Process ${pid} terminated gracefully`); + } + } catch (ex) { + logger.debug(`Error during graceful kill of process ${pid}: ${ex}`); + } + } + + /** + * Find and kill orphaned deepnote-toolkit processes using specific ports. + * This is useful for cleaning up LSP servers and Jupyter servers that may be stuck. + * Only kills processes that are both orphaned AND deepnote-related. + */ + private async cleanupProcessesByPort(port: number): Promise { + try { + const processService = await this.processServiceFactory.create(undefined); + + if (process.platform === 'win32') { + // Windows: use netstat to find LISTENING processes on port + const result = await processService.exec('netstat', ['-ano'], { throwOnStdErr: false }); + if (result.stdout) { + const lines = result.stdout.split('\n'); + const uniquePids = new Set(); + + // Parse and deduplicate PIDs + for (const line of lines) { + if (!line.includes('LISTENING')) continue; + const parts = line.trim().split(/\s+/); + // Windows netstat columns: Proto LocalAddress ForeignAddress State PID + const local = parts[1] || ''; + const m = local.match(/:(\d+)]?$/); // handles IPv4 and [::]:port + const localPort = m ? parseInt(m[1], 10) : NaN; + if (localPort === port) { + const pid = parseInt(parts[parts.length - 1], 10); + if (!isNaN(pid) && pid > 0) uniquePids.add(pid); + } + } + + // Process each unique PID + for (const pid of uniquePids) { + // Check if it's deepnote-related first + const isDeepnoteRelated = await this.isDeepnoteRelatedProcess(pid); + if (!isDeepnoteRelated) { + logger.debug(`Process ${pid} on port ${port} is not deepnote-related, skipping`); + continue; + } + + const isOrphaned = await this.isProcessOrphaned(pid); + if (isOrphaned) { + logger.info( + `Found orphaned deepnote-related process ${pid} using port ${port}, killing process tree...` + ); + // Try without /F first (graceful) + try { + await processService.exec('taskkill', ['/T', '/PID', pid.toString()], { + throwOnStdErr: false + }); + logger.debug(`Gracefully killed process ${pid}`); + + // Clean up lock file after successful kill + await this.deleteLockFile(pid); + } catch (gracefulError) { + // If graceful kill failed, use /F (force) + logger.debug(`Graceful kill failed for ${pid}, using /F flag...`); + try { + await processService.exec('taskkill', ['/F', '/T', '/PID', pid.toString()], { + throwOnStdErr: false + }); + + // Clean up lock file after successful force kill + await this.deleteLockFile(pid); + } catch (forceError) { + logger.debug(`Force kill also failed for ${pid}: ${forceError}`); + } } } else { - // Unix format: user PID ... - const parts = line.trim().split(/\s+/); - if (parts.length > 1) { - pid = parseInt(parts[1], 10); + logger.debug(`Deepnote-related process ${pid} on port ${port} has active parent, skipping`); + } + } + } + } else { + // Unix-like: try lsof first, fallback to ss + let uniquePids = new Set(); + + // Try lsof with LISTEN filter and -nP to avoid slow DNS/service lookups + try { + const lsofResult = await processService.exec( + 'lsof', + ['-sTCP:LISTEN', '-i', `:${port}`, '-t', '-nP'], + { + throwOnStdErr: false + } + ); + if (lsofResult.stdout) { + const pids = lsofResult.stdout + .trim() + .split('\n') + .map((p) => parseInt(p.trim(), 10)) + .filter((p) => !isNaN(p) && p > 0); + pids.forEach((pid) => uniquePids.add(pid)); + } + } catch (lsofError) { + logger.debug(`lsof failed or unavailable, trying ss: ${lsofError}`); + } + + // Fallback to ss if lsof didn't find anything or failed + if (uniquePids.size === 0) { + try { + // Try ss with filter first + const ssResult = await processService.exec('ss', ['-tlnp', `sport = :${port}`], { + throwOnStdErr: false + }); + + // Check if filtered call succeeded (some ss builds reject the filter) + const filterFailed = + !ssResult.stdout || + ssResult.stdout.trim().length === 0 || + (ssResult.stderr && ssResult.stderr.includes('filter')); + + if (filterFailed) { + logger.debug(`ss filter not supported, trying without filter...`); + // Run ss without filter and parse full output + const ssUnfilteredResult = await processService.exec('ss', ['-tlnp'], { + throwOnStdErr: false + }); + + if (ssUnfilteredResult.stdout) { + const lines = ssUnfilteredResult.stdout.split('\n'); + for (const line of lines) { + // Match lines containing the port (e.g., ":8888" or ":8888 ") + const portPattern = new RegExp(`:${port}\\b`); + if (portPattern.test(line)) { + // Extract pid= from matching lines + const pidMatches = line.matchAll(/pid=(\d+)/g); + for (const match of pidMatches) { + const pid = parseInt(match[1], 10); + if (!isNaN(pid) && pid > 0) { + uniquePids.add(pid); + } + } + } + } + } + } else if (ssResult.stdout) { + // Filtered call succeeded, parse its output + const pidMatches = ssResult.stdout.matchAll(/pid=(\d+)/g); + for (const match of pidMatches) { + const pid = parseInt(match[1], 10); + if (!isNaN(pid) && pid > 0) { + uniquePids.add(pid); + } } } + } catch (ssError) { + logger.debug(`ss also failed: ${ssError}`); + } + } + + if (uniquePids.size === 0) { + logger.debug(`No processes found listening on port ${port}`); + return; + } + + // Process each unique PID + for (const pid of uniquePids) { + // Check if it's deepnote-related first + const isDeepnoteRelated = await this.isDeepnoteRelatedProcess(pid); + if (!isDeepnoteRelated) { + logger.debug(`Process ${pid} on port ${port} is not deepnote-related, skipping`); + continue; + } + + const isOrphaned = await this.isProcessOrphaned(pid); + if (isOrphaned) { + logger.info( + `Found orphaned deepnote-related process ${pid} using port ${port}, attempting graceful kill...` + ); + await this.killProcessGracefully(pid, processService); + + // Clean up lock file after successful kill + await this.deleteLockFile(pid); + } else { + logger.debug(`Deepnote-related process ${pid} on port ${port} has active parent, skipping`); + } + } + } + } catch (ex) { + logger.debug(`Failed to cleanup processes on port ${port}: ${ex}`); + } + } + + /** + * Cleans up any orphaned deepnote-toolkit processes from previous VS Code sessions. + * This prevents port conflicts when starting new servers. + */ + private async cleanupOrphanedProcesses(): Promise { + try { + const startTime = Date.now(); + logger.info('Checking for orphaned deepnote-toolkit processes...'); + + // First, clean up any orphaned processes using known ports + // This catches LSP servers (2087) and Jupyter servers (8888-8895) that may be stuck + await this.cleanupProcessesByPort(2087); // Python LSP server + + // Scan common Jupyter port range (8888-8895) + // Jupyter typically uses 8888 but will increment if that port is busy + for (let port = 8888; port <= 8895; port++) { + await this.cleanupProcessesByPort(port); + } + + const portCleanupTime = Date.now() - startTime; + logger.debug(`Port-based cleanup completed in ${portCleanupTime}ms`); + + const processService = await this.processServiceFactory.create(undefined); + + // Find all deepnote-toolkit server processes and related child processes + const candidatePids: number[] = []; - if (pid && !isNaN(pid)) { - candidatePids.push(pid); + if (process.platform === 'win32') { + // Windows: tasklist CSV doesn't include command lines, so we need a two-step approach: + // 1. Get all python.exe and pythonw.exe PIDs + // 2. Check each PID's command line to see if it's deepnote-related + + // Step 1: Get all Python process PIDs + const pythonPids: number[] = []; + + // Check python.exe + const pythonResult = await processService.exec( + 'tasklist', + ['/FI', 'IMAGENAME eq python.exe', '/FO', 'CSV', '/NH'], + { throwOnStdErr: false } + ); + if (pythonResult.stdout) { + const lines = pythonResult.stdout.split('\n'); + for (const line of lines) { + // Windows CSV format: "python.exe","12345",... + const match = line.match(/"python\.exe","(\d+)"/); + if (match) { + const pid = parseInt(match[1], 10); + if (!isNaN(pid)) { + pythonPids.push(pid); + } } } } - if (candidatePids.length > 0) { - logger.info( - `Found ${candidatePids.length} deepnote-toolkit server process(es): ${candidatePids.join(', ')}` - ); + // Check pythonw.exe + const pythonwResult = await processService.exec( + 'tasklist', + ['/FI', 'IMAGENAME eq pythonw.exe', '/FO', 'CSV', '/NH'], + { throwOnStdErr: false } + ); + if (pythonwResult.stdout) { + const lines = pythonwResult.stdout.split('\n'); + for (const line of lines) { + // Windows CSV format: "pythonw.exe","12345",... + const match = line.match(/"pythonw\.exe","(\d+)"/); + if (match) { + const pid = parseInt(match[1], 10); + if (!isNaN(pid)) { + pythonPids.push(pid); + } + } + } + } - const pidsToKill: number[] = []; - const pidsToSkip: Array<{ pid: number; reason: string }> = []; - - // Check each process to determine if it should be killed - for (const pid of candidatePids) { - // Check if there's a lock file for this PID - const lockData = await this.readLockFile(pid); - - if (lockData) { - // Lock file exists - check if it belongs to a different session - if (lockData.sessionId !== this.sessionId) { - // Different session - check if the process is actually orphaned - const isOrphaned = await this.isProcessOrphaned(pid); - if (isOrphaned) { - logger.info( - `PID ${pid} belongs to session ${lockData.sessionId} and is orphaned - will kill` - ); - pidsToKill.push(pid); - } else { - pidsToSkip.push({ - pid, - reason: `belongs to active session ${lockData.sessionId.substring(0, 8)}...` - }); + logger.debug(`Found ${pythonPids.length} Python process(es) on Windows`); + + // Step 2: Check each Python PID to see if it's deepnote-related + for (const pid of pythonPids) { + const isDeepnoteRelated = await this.isDeepnoteRelatedProcess(pid); + if (isDeepnoteRelated) { + candidatePids.push(pid); + } + } + } else { + // Unix-like: use ps with full command line + const result = await processService.exec('ps', ['aux'], { throwOnStdErr: false }); + + if (result.stdout) { + const lines = result.stdout.split('\n'); + + for (const line of lines) { + // Look for processes running deepnote_toolkit server or related child processes + // This includes: + // - deepnote_toolkit server (main server process) + // - pylsp (Python LSP server child process) + // - jupyter (Jupyter server child process) + const matchesPattern = + (line.includes('deepnote_toolkit') && line.includes('server')) || + (line.includes('pylsp') && line.includes('2087')) || // LSP server on port 2087 + (line.includes('jupyter') && line.includes('deepnote')); + + if (matchesPattern) { + // Unix format: user PID ... + const parts = line.trim().split(/\s+/); + if (parts.length > 1) { + const pid = parseInt(parts[1], 10); + if (!isNaN(pid)) { + // Validate with isDeepnoteRelatedProcess before adding to candidates + const isDeepnoteRelated = await this.isDeepnoteRelatedProcess(pid); + if (isDeepnoteRelated) { + candidatePids.push(pid); + } } - } else { - // Same session - this shouldn't happen during startup, but skip it - pidsToSkip.push({ pid, reason: 'belongs to current session' }); } - } else { - // No lock file - check if orphaned before killing + } + } + } + } + + if (candidatePids.length > 0) { + logger.info(`Found ${candidatePids.length} deepnote-related process(es): ${candidatePids.join(', ')}`); + + const pidsToKill: number[] = []; + const pidsToSkip: Array<{ pid: number; reason: string }> = []; + + // Check each process to determine if it should be killed + for (const pid of candidatePids) { + // Re-verify it's deepnote-related before any kill/lock logic + const isDeepnoteRelated = await this.isDeepnoteRelatedProcess(pid); + if (!isDeepnoteRelated) { + logger.debug(`PID ${pid} is no longer deepnote-related, skipping`); + pidsToSkip.push({ pid, reason: 'not deepnote-related' }); + continue; + } + + // Check if there's a lock file for this PID (only main server processes have lock files) + const lockData = await this.readLockFile(pid); + + if (lockData) { + // Lock file exists - this is a main server process + // Check if it belongs to a different session + if (lockData.sessionId !== this.sessionId) { + // Different session - check if the process is actually orphaned const isOrphaned = await this.isProcessOrphaned(pid); if (isOrphaned) { - logger.info(`PID ${pid} has no lock file and is orphaned - will kill`); + logger.info( + `PID ${pid} belongs to session ${lockData.sessionId} and is orphaned - will kill` + ); pidsToKill.push(pid); } else { - pidsToSkip.push({ pid, reason: 'no lock file but has active parent process' }); + pidsToSkip.push({ + pid, + reason: `belongs to active session ${lockData.sessionId.substring(0, 8)}...` + }); } + } else { + // Same session - this shouldn't happen during startup, but skip it + pidsToSkip.push({ pid, reason: 'belongs to current session' }); + } + } else { + // No lock file - could be a child process (LSP, Jupyter) or orphaned main process + // Check if orphaned before killing + const isOrphaned = await this.isProcessOrphaned(pid); + if (isOrphaned) { + logger.info(`PID ${pid} has no lock file and is orphaned - will kill`); + pidsToKill.push(pid); + } else { + pidsToSkip.push({ pid, reason: 'no lock file but has active parent process' }); } } + } - // Log skipped processes - if (pidsToSkip.length > 0) { - for (const { pid, reason } of pidsToSkip) { - logger.info(`Skipping PID ${pid}: ${reason}`); - } + // Log skipped processes + if (pidsToSkip.length > 0) { + for (const { pid, reason } of pidsToSkip) { + logger.info(`Skipping PID ${pid}: ${reason}`); } + } - // Kill orphaned processes - if (pidsToKill.length > 0) { - logger.info(`Killing ${pidsToKill.length} orphaned process(es): ${pidsToKill.join(', ')}`); - this.outputChannel.appendLine( - `Cleaning up ${pidsToKill.length} orphaned deepnote-toolkit process(es)...` - ); + // Kill orphaned processes + if (pidsToKill.length > 0) { + logger.info(`Killing ${pidsToKill.length} orphaned process(es): ${pidsToKill.join(', ')}`); + this.outputChannel.appendLine( + `Cleaning up ${pidsToKill.length} orphaned deepnote-related process(es)...` + ); - for (const pid of pidsToKill) { - try { - if (process.platform === 'win32') { + for (const pid of pidsToKill) { + try { + if (process.platform === 'win32') { + // Windows: Try graceful kill first (without /F), then force kill if needed + logger.debug(`Attempting graceful termination of process ${pid}...`); + try { + await processService.exec('taskkill', ['/T', '/PID', pid.toString()], { + throwOnStdErr: false + }); + logger.debug(`Gracefully killed process ${pid}`); + } catch (gracefulError) { + // If graceful kill failed, use /F (force) + logger.debug(`Graceful kill failed for ${pid}, using /F flag...`); await processService.exec('taskkill', ['/F', '/T', '/PID', pid.toString()], { throwOnStdErr: false }); - } else { - await processService.exec('kill', ['-9', pid.toString()], { throwOnStdErr: false }); } - logger.info(`Killed orphaned process ${pid}`); - - // Clean up the lock file after killing - await this.deleteLockFile(pid); - } catch (ex) { - logger.warn(`Failed to kill process ${pid}: ${ex}`); + } else { + // Unix: Use helper that tries SIGTERM then SIGKILL + await this.killProcessGracefully(pid, processService); } - } + logger.info(`Killed orphaned process ${pid}`); - this.outputChannel.appendLine('✓ Cleanup complete'); - } else { - logger.info('No orphaned deepnote-toolkit processes found (all processes are active)'); + // Clean up the lock file after killing (if it exists) + await this.deleteLockFile(pid); + } catch (ex) { + logger.warn(`Failed to kill process ${pid}: ${ex}`); + } } + + this.outputChannel.appendLine('✓ Cleanup complete'); } else { - logger.info('No deepnote-toolkit server processes found'); + logger.info('No orphaned deepnote-related processes found (all processes are active)'); } + } else { + logger.info('No deepnote-related processes found'); } } catch (ex) { // Don't fail startup if cleanup fails