From eef17eb7975a2affcc7ebb14709299bbf6c39a01 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 5 Jan 2026 21:19:21 -0800 Subject: [PATCH 1/2] feat(desktop): replace regex-based port detection with process-based scanning - Add pidtree dependency for cross-platform process tree traversal - Create port-scanner.ts with lsof (macOS/Linux) and netstat (Windows) support - Create port-hints.ts for lightweight output hint detection to trigger immediate scans - Rewrite port-manager.ts with process-based architecture: - Periodic scanning every 2.5s of all registered terminal sessions - Immediate scanning triggered by output hints (localhost:PORT, etc.) - Process tree traversal to find all child processes listening on ports - Update DetectedPort type with pid, processName, and address fields - Update UI tooltip to show process name instead of context line This fixes port detection for TUI apps like turborepo that use cursor positioning instead of linear output, making regex parsing unreliable. --- apps/desktop/package.json | 1 + apps/desktop/src/main/lib/terminal/manager.ts | 7 +- .../src/main/lib/terminal/port-hints.ts | 41 ++ .../src/main/lib/terminal/port-manager.ts | 472 ++++++++---------- .../src/main/lib/terminal/port-scanner.ts | 211 ++++++++ apps/desktop/src/main/lib/terminal/session.ts | 10 +- .../WorkspaceSidebar/PortsList/PortsList.tsx | 6 +- apps/desktop/src/shared/types/ports.ts | 4 +- bun.lock | 5 +- 9 files changed, 475 insertions(+), 282 deletions(-) create mode 100644 apps/desktop/src/main/lib/terminal/port-hints.ts create mode 100644 apps/desktop/src/main/lib/terminal/port-scanner.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 696bb9f3347..6f9dcc8c351 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -92,6 +92,7 @@ "node-addon-api": "^7.1.0", "node-pty": "1.1.0-beta30", "os-locale": "^6.0.2", + "pidtree": "^0.6.0", "posthog-js": "1.310.1", "posthog-node": "^5.18.0", "react": "^19.2.3", diff --git a/apps/desktop/src/main/lib/terminal/manager.ts b/apps/desktop/src/main/lib/terminal/manager.ts index 4541b75d664..7684c62d51e 100644 --- a/apps/desktop/src/main/lib/terminal/manager.ts +++ b/apps/desktop/src/main/lib/terminal/manager.ts @@ -92,6 +92,9 @@ export class TerminalManager extends EventEmitter { this.sessions.set(paneId, session); + // Register session with port manager for process-based port detection + portManager.registerSession(session, workspaceId); + // Track terminal opened (only fires once per session creation) track("terminal_opened", { workspace_id: workspaceId, pane_id: paneId }); @@ -142,8 +145,8 @@ export class TerminalManager extends EventEmitter { await closeSessionHistory(session, exitCode); - // Clean up detected ports for this pane - portManager.removePortsForPane(paneId); + // Unregister from port manager (also removes detected ports) + portManager.unregisterSession(paneId); this.emit(`exit:${paneId}`, exitCode, signal); diff --git a/apps/desktop/src/main/lib/terminal/port-hints.ts b/apps/desktop/src/main/lib/terminal/port-hints.ts new file mode 100644 index 00000000000..37e62b217fe --- /dev/null +++ b/apps/desktop/src/main/lib/terminal/port-hints.ts @@ -0,0 +1,41 @@ +/** + * Lightweight patterns to detect when terminal output suggests a port may have been opened. + * These are used as hints to trigger an immediate process-based scan, not as the source of truth. + */ + +// Quick patterns that suggest a server just started listening on a port +const HINT_PATTERNS = [ + // URL patterns + /localhost:\d{2,5}/i, + /127\.0\.0\.1:\d{2,5}/, + /0\.0\.0\.0:\d{2,5}/, + /https?:\/\/[^:/]+:\d{2,5}/i, + + // Common server startup messages + /listening (?:on|at)/i, + /server (?:running|started|is running)/i, + /ready (?:on|at|in)/i, + /started (?:on|at)/i, + /bound to (?:port)?/i, + /development server/i, + /serving (?:on|at)/i, + + // Framework-specific patterns + /next\.?js/i, // Next.js + /vite/i, // Vite + /webpack.*compiled/i, // Webpack dev server + /express/i, // Express + /fastify/i, // Fastify +]; + +/** + * Check if terminal output contains hints that a port may have been opened. + * This is a lightweight check - false positives are acceptable since we verify + * with actual process scanning. + */ +export function containsPortHint(data: string): boolean { + // Quick length check - very short output unlikely to contain port info + if (data.length < 10) return false; + + return HINT_PATTERNS.some((pattern) => pattern.test(data)); +} diff --git a/apps/desktop/src/main/lib/terminal/port-manager.ts b/apps/desktop/src/main/lib/terminal/port-manager.ts index b848024fc85..cf02e6c0e65 100644 --- a/apps/desktop/src/main/lib/terminal/port-manager.ts +++ b/apps/desktop/src/main/lib/terminal/port-manager.ts @@ -1,299 +1,259 @@ import { EventEmitter } from "node:events"; -import net from "node:net"; import type { DetectedPort } from "shared/types"; +import { containsPortHint } from "./port-hints"; +import { getListeningPortsForPids, getProcessTree } from "./port-scanner"; +import type { TerminalSession } from "./types"; -// How often to check if ports are still running (in ms) -const HEALTH_CHECK_INTERVAL = 5000; +// How often to poll for port changes (in ms) +const SCAN_INTERVAL_MS = 2500; -// Timeout for connection check (in ms) - 2s provides margin for loaded machines -const CONNECTION_TIMEOUT = 2000; +// Delay before running an immediate scan triggered by output hint (in ms) +// This gives the server time to fully bind the port +const HINT_SCAN_DELAY_MS = 500; -/** - * Check if a port is listening on a specific host - */ -function checkPortOnHost(port: number, host: string): Promise { - return new Promise((resolve) => { - const socket = new net.Socket(); +// Ports to ignore (common system ports that are usually not dev servers) +const IGNORED_PORTS = new Set([22, 80, 443, 5432, 3306, 6379, 27017]); - const cleanup = () => { - socket.removeAllListeners(); - socket.destroy(); - }; - - socket.setTimeout(CONNECTION_TIMEOUT); - - socket.on("connect", () => { - cleanup(); - resolve(true); - }); - - socket.on("timeout", () => { - cleanup(); - resolve(false); - }); - - socket.on("error", () => { - cleanup(); - resolve(false); - }); - - socket.connect(port, host); - }); -} - -/** - * Check if a port is listening by attempting TCP connections on both IPv4 and IPv6 - */ -async function isPortListening(port: number): Promise { - // Check both IPv4 and IPv6, return true if either succeeds - const [ipv4, ipv6] = await Promise.all([ - checkPortOnHost(port, "127.0.0.1"), - checkPortOnHost(port, "::1"), - ]); - return ipv4 || ipv6; -} - -// Port detection patterns for common frameworks -const PORT_PATTERNS = [ - // Node.js/Express - "listening on port 3000" or "listening at :3000" - /listening (?:on|at) (?:port |:)?(\d{2,5})/i, - // Server started - "server running on port 3000" - /server (?:running|started|is running) (?:on|at) (?:port |:)?(\d{2,5})/i, - // localhost:PORT patterns - /localhost:(\d{2,5})/i, - // IP:PORT patterns - /127\.0\.0\.1:(\d{2,5})/i, - /0\.0\.0\.0:(\d{2,5})/i, - // HTTP URLs with port - /https?:\/\/[^:/]+:(\d{2,5})/i, - // Vite/Next.js/React - "ready on http://...:3000" or "started at http://...:3000" - /(?:ready|started|running) (?:on|at|in) (?:http:\/\/)?[^:]*:(\d{2,5})/i, - // Generic port binding - "bound to port 3000" or "binding to :3000" - /(?:bound to|binding to) (?:port )?:?(\d{2,5})/i, - // Fastify - "Server listening at" - /server listening at .*:(\d{2,5})/i, - // Django/Flask - "Development server is running" - /development server .*:(\d{2,5})/i, - // Python http.server - "Serving HTTP on 0.0.0.0 port 8000" - /serving (?:http|https) on .* port (\d{2,5})/i, - // Java/Spring Boot - "Tomcat started on port(s): 8080" - /started on port\(s\):? ?(\d{2,5})/i, - // Generic "on port X" pattern (catches many frameworks) - /\bon port (\d{2,5})\b/i, -]; - -// Ports to ignore (common system/ephemeral ports) -const IGNORED_PORTS = new Set([80, 443]); - -// Patterns indicating port is in use by something else (not this terminal) -const PORT_IN_USE_PATTERNS = [ - /port.+(?:is\s+)?(?:already\s+)?in\s+use/i, - /address\s+(?:already\s+)?in\s+use/i, - /EADDRINUSE/, - /port.+(?:is\s+)?(?:being\s+)?used\s+by/i, - /bind.*failed/i, - /cannot\s+bind/i, -]; - -// Delay before verifying a detected port (ms) - gives server time to fully start -const VERIFICATION_DELAY = 500; - -// Max buffer size for incomplete lines (bytes) - prevents memory issues with pathological input -const MAX_LINE_BUFFER = 4096; - -/** - * Check if a line indicates a port-in-use error (someone else owns the port) - */ -function isPortInUseError(line: string): boolean { - return PORT_IN_USE_PATTERNS.some((pattern) => pattern.test(line)); -} - -function extractPort(line: string): number | null { - // Skip lines that indicate port is in use by something else - if (isPortInUseError(line)) { - return null; - } - - for (const pattern of PORT_PATTERNS) { - const match = line.match(pattern); - if (match?.[1]) { - const port = Number.parseInt(match[1], 10); - // Valid port range: 1024-65535 (user ports), excluding common ignored ports - if (port >= 1024 && port <= 65535 && !IGNORED_PORTS.has(port)) { - return port; - } - } - } - return null; +interface RegisteredSession { + session: TerminalSession; + workspaceId: string; } class PortManager extends EventEmitter { private ports = new Map(); - private pendingVerification = new Set(); // Ports currently being verified - private healthCheckInterval: ReturnType | null = null; - private isCheckingHealth = false; - private lineBuffers = new Map(); // Buffer incomplete lines per pane + private sessions = new Map(); + private scanInterval: ReturnType | null = null; + private pendingHintScans = new Map>(); + private isScanning = false; constructor() { super(); - this.startHealthCheck(); + this.startPeriodicScan(); } - private makeKey(paneId: string, port: number): string { - return `${paneId}:${port}`; + /** + * Register a terminal session for port scanning + */ + registerSession(session: TerminalSession, workspaceId: string): void { + this.sessions.set(session.paneId, { session, workspaceId }); } /** - * Start periodic health checks for all tracked ports + * Unregister a terminal session and remove its ports */ - private startHealthCheck(): void { - if (this.healthCheckInterval) return; - - this.healthCheckInterval = setInterval(() => { - this.checkPortsHealth(); - }, HEALTH_CHECK_INTERVAL); - - // Don't prevent Node from exiting - this.healthCheckInterval.unref(); + unregisterSession(paneId: string): void { + this.sessions.delete(paneId); + this.removePortsForPane(paneId); + + // Cancel any pending hint scan for this pane + const pendingTimeout = this.pendingHintScans.get(paneId); + if (pendingTimeout) { + clearTimeout(pendingTimeout); + this.pendingHintScans.delete(paneId); + } } /** - * Stop the health check interval + * Check terminal output for hints that a port may have been opened. + * If a hint is detected, schedule an immediate scan for that pane. */ - stopHealthCheck(): void { - if (this.healthCheckInterval) { - clearInterval(this.healthCheckInterval); - this.healthCheckInterval = null; + checkOutputForHint(data: string, paneId: string): void { + if (!containsPortHint(data)) return; + + // Debounce: cancel any pending scan and schedule a new one + const existing = this.pendingHintScans.get(paneId); + if (existing) { + clearTimeout(existing); } + + const timeout = setTimeout(() => { + this.pendingHintScans.delete(paneId); + this.scanPane(paneId).catch(() => {}); + }, HINT_SCAN_DELAY_MS); + + this.pendingHintScans.set(paneId, timeout); } /** - * Check all tracked ports and remove any that are no longer listening + * Start periodic scanning of all registered sessions */ - private async checkPortsHealth(): Promise { - // Prevent concurrent health checks - if (this.isCheckingHealth || this.ports.size === 0) return; - this.isCheckingHealth = true; + private startPeriodicScan(): void { + if (this.scanInterval) return; - try { - // Check each tracked port - const checkPromises = Array.from(this.ports.values()).map( - async (detectedPort) => { - const isListening = await isPortListening(detectedPort.port); - if (!isListening) { - this.removePort(detectedPort.paneId, detectedPort.port); - } - }, - ); + this.scanInterval = setInterval(() => { + this.scanAllSessions().catch((error) => { + console.error("[PortManager] Scan error:", error); + }); + }, SCAN_INTERVAL_MS); - await Promise.all(checkPromises); - } finally { - this.isCheckingHealth = false; - } + // Don't prevent Node from exiting + this.scanInterval.unref(); } /** - * Check if a port number is already tracked by any pane + * Stop periodic scanning */ - private isPortTracked(port: number): boolean { - for (const detectedPort of this.ports.values()) { - if (detectedPort.port === port) { - return true; - } + stopPeriodicScan(): void { + if (this.scanInterval) { + clearInterval(this.scanInterval); + this.scanInterval = null; } - return false; + + // Clear all pending hint scans + for (const timeout of this.pendingHintScans.values()) { + clearTimeout(timeout); + } + this.pendingHintScans.clear(); } /** - * Schedule a port to be added after verification. - * This verifies the port is actually listening before adding it, - * which filters out false positives like "Port 3000 is in use" messages. - * Only one entry per port number is allowed globally to prevent - * tracking ports that belong to other panes. + * Scan a specific pane for ports */ - schedulePortVerification( - port: number, - paneId: string, - workspaceId: string, - contextLine: string, - ): void { - const key = this.makeKey(paneId, port); + private async scanPane(paneId: string): Promise { + const registered = this.sessions.get(paneId); + if (!registered) return; - // Don't verify if already tracked by this pane or already verifying - if (this.ports.has(key) || this.pendingVerification.has(key)) { - return; - } + const { session, workspaceId } = registered; + if (!session.isAlive) return; - // Don't track if this port is already tracked by another pane - if (this.isPortTracked(port)) { - return; + try { + const pid = session.pty.pid; + const pids = await getProcessTree(pid); + if (pids.length === 0) return; + + const portInfos = getListeningPortsForPids(pids); + this.updatePortsForPane(paneId, workspaceId, portInfos); + } catch (error) { + console.error(`[PortManager] Error scanning pane ${paneId}:`, error); } + } - this.pendingVerification.add(key); + /** + * Scan all registered sessions for ports + */ + private async scanAllSessions(): Promise { + // Prevent concurrent scans + if (this.isScanning) return; + this.isScanning = true; - // Wait a short time for the server to fully start, then verify - setTimeout(async () => { - try { - // Double-check port isn't tracked yet (could have been added while waiting) - if (this.isPortTracked(port)) { - return; + try { + // Collect all PIDs from all sessions, grouped by pane + const panePortMap = new Map< + string, + { workspaceId: string; pids: number[] } + >(); + + for (const [paneId, { session, workspaceId }] of this.sessions) { + if (!session.isAlive) continue; + + try { + const pid = session.pty.pid; + const pids = await getProcessTree(pid); + if (pids.length > 0) { + panePortMap.set(paneId, { workspaceId, pids }); + } + } catch { + // Session may have exited } - const isListening = await isPortListening(port); - if (isListening && !this.ports.has(key) && !this.isPortTracked(port)) { - this.addPortDirect(port, paneId, workspaceId, contextLine); + } + + // Scan ports for each pane + for (const [paneId, { workspaceId, pids }] of panePortMap) { + const portInfos = getListeningPortsForPids(pids); + this.updatePortsForPane(paneId, workspaceId, portInfos); + } + + // Clean up ports for sessions that no longer exist + for (const [key, port] of this.ports) { + if (!this.sessions.has(port.paneId)) { + this.ports.delete(key); + this.emit("port:remove", port); } - } finally { - this.pendingVerification.delete(key); } - }, VERIFICATION_DELAY); + } finally { + this.isScanning = false; + } } /** - * Directly add a port without verification (internal use) + * Update ports for a specific pane, emitting add/remove events as needed */ - private addPortDirect( - port: number, + private updatePortsForPane( paneId: string, workspaceId: string, - contextLine: string, - ): boolean { - const key = this.makeKey(paneId, port); + portInfos: Array<{ + port: number; + pid: number; + address: string; + processName: string; + }>, + ): void { + const now = Date.now(); + + // Filter out ignored ports + const validPortInfos = portInfos.filter( + (info) => !IGNORED_PORTS.has(info.port), + ); + + // Track which ports we've seen for this pane + const seenKeys = new Set(); + + for (const info of validPortInfos) { + const key = this.makeKey(paneId, info.port); + seenKeys.add(key); + + const existing = this.ports.get(key); + if (!existing) { + // New port detected + const detectedPort: DetectedPort = { + port: info.port, + pid: info.pid, + processName: info.processName, + paneId, + workspaceId, + detectedAt: now, + address: info.address, + }; + this.ports.set(key, detectedPort); + this.emit("port:add", detectedPort); + } else if ( + existing.pid !== info.pid || + existing.processName !== info.processName + ) { + // Port still exists but process changed - update it + const updatedPort: DetectedPort = { + ...existing, + pid: info.pid, + processName: info.processName, + address: info.address, + }; + this.ports.set(key, updatedPort); + // Emit remove then add to notify of the change + this.emit("port:remove", existing); + this.emit("port:add", updatedPort); + } + } - // Don't add duplicate - if (this.ports.has(key)) { - return false; + // Remove ports that are no longer listening for this pane + for (const [key, port] of this.ports) { + if (port.paneId === paneId && !seenKeys.has(key)) { + this.ports.delete(key); + this.emit("port:remove", port); + } } + } - const detectedPort: DetectedPort = { - port, - paneId, - workspaceId, - detectedAt: Date.now(), - contextLine: contextLine.trim().slice(0, 100), // Limit context line length - }; - - this.ports.set(key, detectedPort); - this.emit("port:add", detectedPort); - return true; + private makeKey(paneId: string, port: number): string { + return `${paneId}:${port}`; } /** - * Remove a specific port + * Remove all ports for a specific pane */ - removePort(paneId: string, port: number): void { - const key = this.makeKey(paneId, port); - const detectedPort = this.ports.get(key); - - if (detectedPort) { - this.ports.delete(key); - this.emit("port:remove", detectedPort); - } - } - removePortsForPane(paneId: string): void { const portsToRemove: DetectedPort[] = []; - for (const [key, port] of this.ports.entries()) { + for (const [key, port] of this.ports) { if (port.paneId === paneId) { portsToRemove.push(port); this.ports.delete(key); @@ -303,56 +263,30 @@ class PortManager extends EventEmitter { for (const port of portsToRemove) { this.emit("port:remove", port); } - - // Clear the line buffer for this pane - this.lineBuffers.delete(paneId); } + /** + * Get all detected ports + */ getAllPorts(): DetectedPort[] { return Array.from(this.ports.values()).sort( (a, b) => b.detectedAt - a.detectedAt, ); } + /** + * Get ports for a specific workspace + */ getPortsByWorkspace(workspaceId: string): DetectedPort[] { return this.getAllPorts().filter((p) => p.workspaceId === workspaceId); } /** - * Scan terminal output for port patterns. - * Detected ports are verified before being added to ensure they're actually listening. - * Handles chunked output by buffering incomplete lines per pane. + * Force an immediate scan of all sessions + * Useful for testing or when you know ports have changed */ - scanOutput(data: string, paneId: string, workspaceId: string): void { - // Prepend any buffered incomplete line from previous chunk - const buffered = this.lineBuffers.get(paneId) || ""; - const combined = buffered + data; - - // Split by newlines - const parts = combined.split(/\r?\n/); - - // If data doesn't end with a newline, the last part is incomplete - buffer it - const endsWithNewline = /[\r\n]$/.test(data); - const completeLines = endsWithNewline ? parts : parts.slice(0, -1); - const incompleteLine = endsWithNewline ? "" : (parts.at(-1) ?? ""); - - // Update buffer (with size limit to prevent memory issues) - if (incompleteLine && incompleteLine.length <= MAX_LINE_BUFFER) { - this.lineBuffers.set(paneId, incompleteLine); - } else { - this.lineBuffers.delete(paneId); - } - - // Process complete lines - for (const line of completeLines) { - if (!line.trim()) continue; - - const port = extractPort(line); - if (port !== null) { - // Schedule verification - port will only be added if it's actually listening - this.schedulePortVerification(port, paneId, workspaceId, line); - } - } + async forceScan(): Promise { + await this.scanAllSessions(); } } diff --git a/apps/desktop/src/main/lib/terminal/port-scanner.ts b/apps/desktop/src/main/lib/terminal/port-scanner.ts new file mode 100644 index 00000000000..45866c780f5 --- /dev/null +++ b/apps/desktop/src/main/lib/terminal/port-scanner.ts @@ -0,0 +1,211 @@ +import { execSync } from "node:child_process"; +import os from "node:os"; +import pidtree from "pidtree"; + +export interface PortInfo { + port: number; + pid: number; + address: string; + processName: string; +} + +/** + * Get all child PIDs of a process (including the process itself) + */ +export async function getProcessTree(pid: number): Promise { + try { + return await pidtree(pid, { root: true }); + } catch { + // Process may have exited + return []; + } +} + +/** + * Get listening TCP ports for a set of PIDs + * Cross-platform implementation using lsof (macOS/Linux) or netstat (Windows) + */ +export function getListeningPortsForPids(pids: number[]): PortInfo[] { + if (pids.length === 0) return []; + + const platform = os.platform(); + + if (platform === "darwin" || platform === "linux") { + return getListeningPortsLsof(pids); + } + if (platform === "win32") { + return getListeningPortsWindows(pids); + } + + return []; +} + +/** + * macOS/Linux implementation using lsof + */ +function getListeningPortsLsof(pids: number[]): PortInfo[] { + try { + const pidArg = pids.join(","); + // -p: filter by PIDs + // -iTCP: only TCP connections + // -sTCP:LISTEN: only listening sockets + // -P: don't convert port numbers to names + // -n: don't resolve hostnames + // -F: output in parseable format (pid, command, name fields) + const output = execSync( + `lsof -p ${pidArg} -iTCP -sTCP:LISTEN -P -n 2>/dev/null || true`, + { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }, + ); + + if (!output.trim()) return []; + + const ports: PortInfo[] = []; + const lines = output.trim().split("\n").slice(1); // Skip header + + for (const line of lines) { + if (!line.trim()) continue; + + // Format: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME + // Example: node 12345 user 23u IPv4 0x1234 0t0 TCP *:3000 (LISTEN) + const columns = line.split(/\s+/); + if (columns.length < 9) continue; + + const processName = columns[0]; + const pid = Number.parseInt(columns[1], 10); + const name = columns[columns.length - 1]; // Last column before (LISTEN) + + // Parse address:port from NAME column + // Formats: *:3000, 127.0.0.1:3000, [::1]:3000, [::]:3000 + const match = name.match(/^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/); + if (match) { + const address = match[1] || match[2] || "*"; + const port = Number.parseInt(match[3], 10); + + // Skip invalid ports + if (port < 1 || port > 65535) continue; + + ports.push({ + port, + pid, + address: address === "*" ? "0.0.0.0" : address, + processName, + }); + } + } + + return ports; + } catch { + return []; + } +} + +/** + * Windows implementation using netstat + */ +function getListeningPortsWindows(pids: number[]): PortInfo[] { + try { + // netstat -ano shows all connections with PIDs + const output = execSync("netstat -ano", { + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + }); + + const pidSet = new Set(pids); + const ports: PortInfo[] = []; + const processNames = new Map(); + + for (const line of output.split("\n")) { + if (!line.includes("LISTENING")) continue; + + // Format: TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 12345 + const columns = line.trim().split(/\s+/); + if (columns.length < 5) continue; + + const pid = Number.parseInt(columns[columns.length - 1], 10); + if (!pidSet.has(pid)) continue; + + const localAddr = columns[1]; + // Parse address:port - handles both IPv4 and IPv6 + // IPv4: 0.0.0.0:3000 + // IPv6: [::]:3000 + const match = localAddr.match(/^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/); + if (match) { + const address = match[1] || match[2] || "0.0.0.0"; + const port = Number.parseInt(match[3], 10); + + // Skip invalid ports + if (port < 1 || port > 65535) continue; + + // Get process name (cached to avoid repeated calls) + if (!processNames.has(pid)) { + processNames.set(pid, getProcessNameWindows(pid)); + } + + ports.push({ + port, + pid, + address, + processName: processNames.get(pid) || "unknown", + }); + } + } + + return ports; + } catch { + return []; + } +} + +/** + * Get process name for a PID on Windows + */ +function getProcessNameWindows(pid: number): string { + try { + const output = execSync( + `wmic process where processid=${pid} get name 2>nul`, + { encoding: "utf-8" }, + ); + const lines = output.trim().split("\n"); + if (lines.length >= 2) { + const name = lines[1].trim(); + // Remove .exe extension if present + return name.replace(/\.exe$/i, "") || "unknown"; + } + } catch { + // Try PowerShell as fallback (wmic is deprecated) + try { + const output = execSync( + `powershell -Command "(Get-Process -Id ${pid}).ProcessName"`, + { encoding: "utf-8" }, + ); + return output.trim() || "unknown"; + } catch { + // Ignore + } + } + return "unknown"; +} + +/** + * Get process name for a PID (cross-platform) + */ +export function getProcessName(pid: number): string { + const platform = os.platform(); + + if (platform === "win32") { + return getProcessNameWindows(pid); + } + + // macOS/Linux + try { + const output = execSync(`ps -p ${pid} -o comm= 2>/dev/null || true`, { + encoding: "utf-8", + }); + const name = output.trim(); + // On macOS, comm may be truncated. The full path can be gotten with -o command= + // but comm is usually sufficient for display purposes + return name || "unknown"; + } catch { + return "unknown"; + } +} diff --git a/apps/desktop/src/main/lib/terminal/session.ts b/apps/desktop/src/main/lib/terminal/session.ts index 08b5c559f00..50f6671ff9a 100644 --- a/apps/desktop/src/main/lib/terminal/session.ts +++ b/apps/desktop/src/main/lib/terminal/session.ts @@ -96,10 +96,8 @@ export async function createSession( const { scrollback: recoveredScrollback, wasRecovered } = await recoverScrollback(existingScrollback, workspaceId, paneId); - // Scan recovered scrollback for ports (verification will check if still listening) - if (wasRecovered && recoveredScrollback) { - portManager.scanOutput(recoveredScrollback, paneId, workspaceId); - } + // Note: Port detection is now process-based (via PortManager periodic scanning), + // so we don't need to scan recovered scrollback for port patterns. const ptyProcess = spawnPty({ shell, @@ -166,8 +164,8 @@ export function setupDataHandler( session.scrollback += dataToStore; session.historyWriter?.write(dataToStore); - // Scan for port patterns in terminal output - portManager.scanOutput(dataToStore, session.paneId, session.workspaceId); + // Check for hints that a port may have been opened (triggers immediate scan) + portManager.checkOutputForHint(dataToStore, session.paneId); session.dataBatcher.write(data); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx index 7d8dcfe3d81..1c0ce2c5cb7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx @@ -199,7 +199,7 @@ function PortBadge({ port, isCurrentWorkspace }: PortBadgeProps) {
localhost:{port.port}
-
- {port.contextLine} +
+ {port.processName} (pid {port.pid})
Click to jump to terminal diff --git a/apps/desktop/src/shared/types/ports.ts b/apps/desktop/src/shared/types/ports.ts index fca544821ef..fff8a53f978 100644 --- a/apps/desktop/src/shared/types/ports.ts +++ b/apps/desktop/src/shared/types/ports.ts @@ -1,7 +1,9 @@ export interface DetectedPort { port: number; + pid: number; + processName: string; paneId: string; workspaceId: string; detectedAt: number; - contextLine: string; + address: string; } diff --git a/bun.lock b/bun.lock index cbb3e7cbcd7..28805463828 100644 --- a/bun.lock +++ b/bun.lock @@ -120,7 +120,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "0.0.42", + "version": "0.0.43", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -183,6 +183,7 @@ "node-addon-api": "^7.1.0", "node-pty": "1.1.0-beta30", "os-locale": "^6.0.2", + "pidtree": "^0.6.0", "posthog-js": "1.310.1", "posthog-node": "^5.18.0", "react": "^19.2.3", @@ -2925,6 +2926,8 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], + "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], From 95944509b672d398fff131efdf65ab8f2de1e92b Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 6 Jan 2026 13:53:58 -0800 Subject: [PATCH 2/2] Update ports and remove redundant comments --- .../src/lib/trpc/routers/projects/projects.ts | 3 - apps/desktop/src/main/lib/terminal/manager.ts | 1 - .../src/main/lib/terminal/port-hints.ts | 17 +- .../src/main/lib/terminal/port-manager.ts | 12 - .../main/lib/terminal/port-scanner.test.ts | 326 ++++++++++++++++++ .../src/main/lib/terminal/port-scanner.ts | 26 +- 6 files changed, 344 insertions(+), 41 deletions(-) create mode 100644 apps/desktop/src/main/lib/terminal/port-scanner.test.ts diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 09f9311fb6e..29d45c1e181 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -726,7 +726,6 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { return null; } - // If we already have the github owner cached, return the avatar URL if (project.githubOwner) { console.log( "[getGitHubAvatar] Using cached owner:", @@ -738,7 +737,6 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { }; } - // Fetch the owner from GitHub console.log( "[getGitHubAvatar] Fetching owner for:", project.mainRepoPath, @@ -752,7 +750,6 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { console.log("[getGitHubAvatar] Fetched owner:", owner); - // Cache the owner localDb .update(projects) .set({ githubOwner: owner }) diff --git a/apps/desktop/src/main/lib/terminal/manager.ts b/apps/desktop/src/main/lib/terminal/manager.ts index 7684c62d51e..b42996254c6 100644 --- a/apps/desktop/src/main/lib/terminal/manager.ts +++ b/apps/desktop/src/main/lib/terminal/manager.ts @@ -92,7 +92,6 @@ export class TerminalManager extends EventEmitter { this.sessions.set(paneId, session); - // Register session with port manager for process-based port detection portManager.registerSession(session, workspaceId); // Track terminal opened (only fires once per session creation) diff --git a/apps/desktop/src/main/lib/terminal/port-hints.ts b/apps/desktop/src/main/lib/terminal/port-hints.ts index 37e62b217fe..c34f351c446 100644 --- a/apps/desktop/src/main/lib/terminal/port-hints.ts +++ b/apps/desktop/src/main/lib/terminal/port-hints.ts @@ -3,15 +3,11 @@ * These are used as hints to trigger an immediate process-based scan, not as the source of truth. */ -// Quick patterns that suggest a server just started listening on a port const HINT_PATTERNS = [ - // URL patterns /localhost:\d{2,5}/i, /127\.0\.0\.1:\d{2,5}/, /0\.0\.0\.0:\d{2,5}/, /https?:\/\/[^:/]+:\d{2,5}/i, - - // Common server startup messages /listening (?:on|at)/i, /server (?:running|started|is running)/i, /ready (?:on|at|in)/i, @@ -19,13 +15,11 @@ const HINT_PATTERNS = [ /bound to (?:port)?/i, /development server/i, /serving (?:on|at)/i, - - // Framework-specific patterns - /next\.?js/i, // Next.js - /vite/i, // Vite - /webpack.*compiled/i, // Webpack dev server - /express/i, // Express - /fastify/i, // Fastify + /next\.?js/i, + /vite/i, + /webpack.*compiled/i, + /express/i, + /fastify/i, ]; /** @@ -34,7 +28,6 @@ const HINT_PATTERNS = [ * with actual process scanning. */ export function containsPortHint(data: string): boolean { - // Quick length check - very short output unlikely to contain port info if (data.length < 10) return false; return HINT_PATTERNS.some((pattern) => pattern.test(data)); diff --git a/apps/desktop/src/main/lib/terminal/port-manager.ts b/apps/desktop/src/main/lib/terminal/port-manager.ts index cf02e6c0e65..3e0d720c526 100644 --- a/apps/desktop/src/main/lib/terminal/port-manager.ts +++ b/apps/desktop/src/main/lib/terminal/port-manager.ts @@ -60,7 +60,6 @@ class PortManager extends EventEmitter { checkOutputForHint(data: string, paneId: string): void { if (!containsPortHint(data)) return; - // Debounce: cancel any pending scan and schedule a new one const existing = this.pendingHintScans.get(paneId); if (existing) { clearTimeout(existing); @@ -99,7 +98,6 @@ class PortManager extends EventEmitter { this.scanInterval = null; } - // Clear all pending hint scans for (const timeout of this.pendingHintScans.values()) { clearTimeout(timeout); } @@ -132,12 +130,10 @@ class PortManager extends EventEmitter { * Scan all registered sessions for ports */ private async scanAllSessions(): Promise { - // Prevent concurrent scans if (this.isScanning) return; this.isScanning = true; try { - // Collect all PIDs from all sessions, grouped by pane const panePortMap = new Map< string, { workspaceId: string; pids: number[] } @@ -157,13 +153,11 @@ class PortManager extends EventEmitter { } } - // Scan ports for each pane for (const [paneId, { workspaceId, pids }] of panePortMap) { const portInfos = getListeningPortsForPids(pids); this.updatePortsForPane(paneId, workspaceId, portInfos); } - // Clean up ports for sessions that no longer exist for (const [key, port] of this.ports) { if (!this.sessions.has(port.paneId)) { this.ports.delete(key); @@ -190,12 +184,10 @@ class PortManager extends EventEmitter { ): void { const now = Date.now(); - // Filter out ignored ports const validPortInfos = portInfos.filter( (info) => !IGNORED_PORTS.has(info.port), ); - // Track which ports we've seen for this pane const seenKeys = new Set(); for (const info of validPortInfos) { @@ -204,7 +196,6 @@ class PortManager extends EventEmitter { const existing = this.ports.get(key); if (!existing) { - // New port detected const detectedPort: DetectedPort = { port: info.port, pid: info.pid, @@ -220,7 +211,6 @@ class PortManager extends EventEmitter { existing.pid !== info.pid || existing.processName !== info.processName ) { - // Port still exists but process changed - update it const updatedPort: DetectedPort = { ...existing, pid: info.pid, @@ -228,13 +218,11 @@ class PortManager extends EventEmitter { address: info.address, }; this.ports.set(key, updatedPort); - // Emit remove then add to notify of the change this.emit("port:remove", existing); this.emit("port:add", updatedPort); } } - // Remove ports that are no longer listening for this pane for (const [key, port] of this.ports) { if (port.paneId === paneId && !seenKeys.has(key)) { this.ports.delete(key); diff --git a/apps/desktop/src/main/lib/terminal/port-scanner.test.ts b/apps/desktop/src/main/lib/terminal/port-scanner.test.ts new file mode 100644 index 00000000000..ce36d111bcc --- /dev/null +++ b/apps/desktop/src/main/lib/terminal/port-scanner.test.ts @@ -0,0 +1,326 @@ +import { describe, expect, it } from "bun:test"; + +/** + * Tests for lsof output parsing logic. + * + * The lsof output format is: + * COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME + * Example: node 12345 user 23u IPv4 0x1234 0t0 TCP *:3000 (LISTEN) + * + * The NAME column (e.g., "*:3000") is the second-to-last column, + * with "(LISTEN)" being the last column. + */ + +interface PortInfo { + port: number; + pid: number; + address: string; + processName: string; +} + +/** + * Parse lsof output to extract port information. + * Extracted from getListeningPortsLsof for testability. + * + * @param output - Raw lsof output + * @param allowedPids - Set of PIDs to filter by. If provided, only ports from these PIDs are returned. + * This is critical because lsof ignores -p filter when PIDs don't exist, + * returning ALL listening ports instead. + */ +function parseLsofOutput( + output: string, + allowedPids?: Set, +): PortInfo[] { + if (!output.trim()) return []; + + const ports: PortInfo[] = []; + const lines = output.trim().split("\n").slice(1); // Skip header + + for (const line of lines) { + if (!line.trim()) continue; + + // Format: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME (LISTEN) + // Example: node 12345 user 23u IPv4 0x1234 0t0 TCP *:3000 (LISTEN) + const columns = line.split(/\s+/); + if (columns.length < 10) continue; + + const processName = columns[0]; + const pid = Number.parseInt(columns[1], 10); + + // Filter by allowed PIDs if provided + // This guards against lsof returning all ports when -p filter is ignored + if (allowedPids && !allowedPids.has(pid)) continue; + + const name = columns[columns.length - 2]; // NAME column (e.g., *:3000), before (LISTEN) + + // Parse address:port from NAME column + // Formats: *:3000, 127.0.0.1:3000, [::1]:3000, [::]:3000 + const match = name.match(/^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/); + if (match) { + const address = match[1] || match[2] || "*"; + const port = Number.parseInt(match[3], 10); + + // Skip invalid ports + if (port < 1 || port > 65535) continue; + + ports.push({ + port, + pid, + address: address === "*" ? "0.0.0.0" : address, + processName, + }); + } + } + + return ports; +} + +describe("port-scanner", () => { + describe("parseLsofOutput", () => { + it("should parse standard lsof output with (LISTEN) suffix", () => { + const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +node 12345 user 23u IPv4 0x1234567890ab 0t0 TCP *:3000 (LISTEN)`; + + const ports = parseLsofOutput(output); + + expect(ports).toHaveLength(1); + expect(ports[0]).toEqual({ + port: 3000, + pid: 12345, + address: "0.0.0.0", + processName: "node", + }); + }); + + it("should parse localhost address", () => { + const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +node 12345 user 23u IPv4 0x1234567890ab 0t0 TCP 127.0.0.1:8080 (LISTEN)`; + + const ports = parseLsofOutput(output); + + expect(ports).toHaveLength(1); + expect(ports[0]).toEqual({ + port: 8080, + pid: 12345, + address: "127.0.0.1", + processName: "node", + }); + }); + + it("should parse IPv6 addresses", () => { + const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +node 12345 user 23u IPv6 0x1234567890ab 0t0 TCP [::1]:3000 (LISTEN)`; + + const ports = parseLsofOutput(output); + + expect(ports).toHaveLength(1); + expect(ports[0]).toEqual({ + port: 3000, + pid: 12345, + address: "::1", + processName: "node", + }); + }); + + it("should parse IPv6 wildcard addresses", () => { + const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +node 12345 user 23u IPv6 0x1234567890ab 0t0 TCP [::]:8000 (LISTEN)`; + + const ports = parseLsofOutput(output); + + expect(ports).toHaveLength(1); + expect(ports[0]).toEqual({ + port: 8000, + pid: 12345, + address: "::", + processName: "node", + }); + }); + + it("should parse multiple ports", () => { + const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +node 12345 user 23u IPv4 0x1234567890ab 0t0 TCP *:3000 (LISTEN) +node 12345 user 24u IPv4 0x1234567890ac 0t0 TCP *:3001 (LISTEN) +python 67890 user 5u IPv4 0x1234567890ad 0t0 TCP 127.0.0.1:8000 (LISTEN)`; + + const ports = parseLsofOutput(output); + + expect(ports).toHaveLength(3); + expect(ports[0]).toEqual({ + port: 3000, + pid: 12345, + address: "0.0.0.0", + processName: "node", + }); + expect(ports[1]).toEqual({ + port: 3001, + pid: 12345, + address: "0.0.0.0", + processName: "node", + }); + expect(ports[2]).toEqual({ + port: 8000, + pid: 67890, + address: "127.0.0.1", + processName: "python", + }); + }); + + it("should handle empty output", () => { + const ports = parseLsofOutput(""); + expect(ports).toHaveLength(0); + }); + + it("should handle header-only output", () => { + const output = + "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME"; + const ports = parseLsofOutput(output); + expect(ports).toHaveLength(0); + }); + + it("should skip lines with insufficient columns", () => { + const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +node 12345 user 23u IPv4 0x1234567890ab 0t0 TCP *:3000 (LISTEN) +short line +node 67890 user 24u IPv4 0x1234567890ac 0t0 TCP *:4000 (LISTEN)`; + + const ports = parseLsofOutput(output); + + expect(ports).toHaveLength(2); + expect(ports[0].port).toBe(3000); + expect(ports[1].port).toBe(4000); + }); + + it("should skip invalid port numbers", () => { + const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +node 12345 user 23u IPv4 0x1234567890ab 0t0 TCP *:0 (LISTEN) +node 12345 user 24u IPv4 0x1234567890ac 0t0 TCP *:70000 (LISTEN) +node 12345 user 25u IPv4 0x1234567890ad 0t0 TCP *:3000 (LISTEN)`; + + const ports = parseLsofOutput(output); + + expect(ports).toHaveLength(1); + expect(ports[0].port).toBe(3000); + }); + + it("should handle real-world lsof output format", () => { + // Real output from macOS lsof command + const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +rapportd 947 kietho 8u IPv4 0x9e27f4f0c86f6338 0t0 TCP *:59251 (LISTEN) +ControlCe 1020 kietho 8u IPv4 0xe6bd39002aa591ca 0t0 TCP *:7000 (LISTEN) +postgres 3457 kietho 8u IPv4 0xb4db4c0cd4dfeb63 0t0 TCP 127.0.0.1:5432 (LISTEN)`; + + const ports = parseLsofOutput(output); + + expect(ports).toHaveLength(3); + expect(ports[0]).toEqual({ + port: 59251, + pid: 947, + address: "0.0.0.0", + processName: "rapportd", + }); + expect(ports[1]).toEqual({ + port: 7000, + pid: 1020, + address: "0.0.0.0", + processName: "ControlCe", + }); + expect(ports[2]).toEqual({ + port: 5432, + pid: 3457, + address: "127.0.0.1", + processName: "postgres", + }); + }); + + it("should not parse (LISTEN) as the port name", () => { + // This was the bug: using columns[columns.length - 1] would get "(LISTEN)" + // instead of the actual NAME field like "*:3000" + const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +node 12345 user 23u IPv4 0x1234567890ab 0t0 TCP *:3000 (LISTEN)`; + + const ports = parseLsofOutput(output); + + expect(ports).toHaveLength(1); + // Should extract port 3000, not fail to parse "(LISTEN)" + expect(ports[0].port).toBe(3000); + expect(ports[0].address).toBe("0.0.0.0"); + }); + + it("should handle process names with different lengths", () => { + const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +n 12345 user 23u IPv4 0x1234567890ab 0t0 TCP *:3000 (LISTEN) +verylongprocessname 67890 user 24u IPv4 0x1234567890ac 0t0 TCP *:4000 (LISTEN)`; + + const ports = parseLsofOutput(output); + + expect(ports).toHaveLength(2); + expect(ports[0].processName).toBe("n"); + expect(ports[0].port).toBe(3000); + expect(ports[1].processName).toBe("verylongprocessname"); + expect(ports[1].port).toBe(4000); + }); + }); + + describe("parseLsofOutput with PID filtering", () => { + it("should filter ports by allowed PIDs", () => { + const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +node 12345 user 23u IPv4 0x1234567890ab 0t0 TCP *:3000 (LISTEN) +python 67890 user 5u IPv4 0x1234567890ad 0t0 TCP *:8000 (LISTEN) +ruby 99999 user 6u IPv4 0x1234567890ae 0t0 TCP *:9000 (LISTEN)`; + + // Only allow PID 12345 and 99999 + const allowedPids = new Set([12345, 99999]); + const ports = parseLsofOutput(output, allowedPids); + + expect(ports).toHaveLength(2); + expect(ports[0].pid).toBe(12345); + expect(ports[0].port).toBe(3000); + expect(ports[1].pid).toBe(99999); + expect(ports[1].port).toBe(9000); + }); + + it("should return empty when no PIDs match", () => { + const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +node 12345 user 23u IPv4 0x1234567890ab 0t0 TCP *:3000 (LISTEN) +python 67890 user 5u IPv4 0x1234567890ad 0t0 TCP *:8000 (LISTEN)`; + + // Request PIDs that don't exist in output + const allowedPids = new Set([11111, 22222]); + const ports = parseLsofOutput(output, allowedPids); + + expect(ports).toHaveLength(0); + }); + + it("should return all ports when allowedPids is not provided", () => { + const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +node 12345 user 23u IPv4 0x1234567890ab 0t0 TCP *:3000 (LISTEN) +python 67890 user 5u IPv4 0x1234567890ad 0t0 TCP *:8000 (LISTEN)`; + + // No PID filter + const ports = parseLsofOutput(output); + + expect(ports).toHaveLength(2); + }); + + it("should handle lsof returning unrelated ports when -p filter fails", () => { + // This simulates the bug: we request PID 12345, but lsof ignores -p + // and returns ALL listening ports (947, 1020, 3457, etc.) + const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +rapportd 947 kietho 8u IPv4 0x9e27f4f0c86f6338 0t0 TCP *:59251 (LISTEN) +ControlCe 1020 kietho 8u IPv4 0xe6bd39002aa591ca 0t0 TCP *:7000 (LISTEN) +postgres 3457 kietho 8u IPv4 0xb4db4c0cd4dfeb63 0t0 TCP 127.0.0.1:5432 (LISTEN) +node 12345 kietho 23u IPv4 0x1234567890ab 0t0 TCP *:3000 (LISTEN)`; + + // We only requested PID 12345 (our terminal's process tree) + const allowedPids = new Set([12345]); + const ports = parseLsofOutput(output, allowedPids); + + // Should ONLY return port 3000 from PID 12345 + // NOT the system ports from rapportd, ControlCenter, postgres + expect(ports).toHaveLength(1); + expect(ports[0].port).toBe(3000); + expect(ports[0].pid).toBe(12345); + }); + }); +}); diff --git a/apps/desktop/src/main/lib/terminal/port-scanner.ts b/apps/desktop/src/main/lib/terminal/port-scanner.ts index 45866c780f5..26de93623df 100644 --- a/apps/desktop/src/main/lib/terminal/port-scanner.ts +++ b/apps/desktop/src/main/lib/terminal/port-scanner.ts @@ -46,12 +46,14 @@ export function getListeningPortsForPids(pids: number[]): PortInfo[] { function getListeningPortsLsof(pids: number[]): PortInfo[] { try { const pidArg = pids.join(","); + const pidSet = new Set(pids); // -p: filter by PIDs // -iTCP: only TCP connections // -sTCP:LISTEN: only listening sockets // -P: don't convert port numbers to names // -n: don't resolve hostnames - // -F: output in parseable format (pid, command, name fields) + // Note: lsof may ignore -p filter if PIDs don't exist or have no matches, + // so we must validate PIDs in the output against our requested set const output = execSync( `lsof -p ${pidArg} -iTCP -sTCP:LISTEN -P -n 2>/dev/null || true`, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }, @@ -60,7 +62,7 @@ function getListeningPortsLsof(pids: number[]): PortInfo[] { if (!output.trim()) return []; const ports: PortInfo[] = []; - const lines = output.trim().split("\n").slice(1); // Skip header + const lines = output.trim().split("\n").slice(1); for (const line of lines) { if (!line.trim()) continue; @@ -68,11 +70,16 @@ function getListeningPortsLsof(pids: number[]): PortInfo[] { // Format: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME // Example: node 12345 user 23u IPv4 0x1234 0t0 TCP *:3000 (LISTEN) const columns = line.split(/\s+/); - if (columns.length < 9) continue; + if (columns.length < 10) continue; const processName = columns[0]; const pid = Number.parseInt(columns[1], 10); - const name = columns[columns.length - 1]; // Last column before (LISTEN) + + // CRITICAL: Verify the PID is in our requested set + // lsof ignores -p filter when PIDs don't exist, returning all TCP listeners + if (!pidSet.has(pid)) continue; + + const name = columns[columns.length - 2]; // NAME column (e.g., *:3000), before (LISTEN) // Parse address:port from NAME column // Formats: *:3000, 127.0.0.1:3000, [::1]:3000, [::]:3000 @@ -81,7 +88,6 @@ function getListeningPortsLsof(pids: number[]): PortInfo[] { const address = match[1] || match[2] || "*"; const port = Number.parseInt(match[3], 10); - // Skip invalid ports if (port < 1 || port > 65535) continue; ports.push({ @@ -104,7 +110,6 @@ function getListeningPortsLsof(pids: number[]): PortInfo[] { */ function getListeningPortsWindows(pids: number[]): PortInfo[] { try { - // netstat -ano shows all connections with PIDs const output = execSync("netstat -ano", { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024, @@ -133,10 +138,8 @@ function getListeningPortsWindows(pids: number[]): PortInfo[] { const address = match[1] || match[2] || "0.0.0.0"; const port = Number.parseInt(match[3], 10); - // Skip invalid ports if (port < 1 || port > 65535) continue; - // Get process name (cached to avoid repeated calls) if (!processNames.has(pid)) { processNames.set(pid, getProcessNameWindows(pid)); } @@ -168,20 +171,17 @@ function getProcessNameWindows(pid: number): string { const lines = output.trim().split("\n"); if (lines.length >= 2) { const name = lines[1].trim(); - // Remove .exe extension if present return name.replace(/\.exe$/i, "") || "unknown"; } } catch { - // Try PowerShell as fallback (wmic is deprecated) + // wmic is deprecated, try PowerShell as fallback try { const output = execSync( `powershell -Command "(Get-Process -Id ${pid}).ProcessName"`, { encoding: "utf-8" }, ); return output.trim() || "unknown"; - } catch { - // Ignore - } + } catch {} } return "unknown"; }