diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d9b12c3eab6..492571fdc52 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/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 4541b75d664..b42996254c6 100644 --- a/apps/desktop/src/main/lib/terminal/manager.ts +++ b/apps/desktop/src/main/lib/terminal/manager.ts @@ -92,6 +92,8 @@ export class TerminalManager extends EventEmitter { this.sessions.set(paneId, session); + portManager.registerSession(session, workspaceId); + // Track terminal opened (only fires once per session creation) track("terminal_opened", { workspace_id: workspaceId, pane_id: paneId }); @@ -142,8 +144,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..c34f351c446 --- /dev/null +++ b/apps/desktop/src/main/lib/terminal/port-hints.ts @@ -0,0 +1,34 @@ +/** + * 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. + */ + +const HINT_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, + /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, + /next\.?js/i, + /vite/i, + /webpack.*compiled/i, + /express/i, + /fastify/i, +]; + +/** + * 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 { + 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..3e0d720c526 100644 --- a/apps/desktop/src/main/lib/terminal/port-manager.ts +++ b/apps/desktop/src/main/lib/terminal/port-manager.ts @@ -1,299 +1,247 @@ 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; + + 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; + + 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 { + 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 { + 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); + } + + for (const [paneId, { workspaceId, pids }] of panePortMap) { + const portInfos = getListeningPortsForPids(pids); + this.updatePortsForPane(paneId, workspaceId, portInfos); + } + + 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(); + + const validPortInfos = portInfos.filter( + (info) => !IGNORED_PORTS.has(info.port), + ); + + 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) { + 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 + ) { + const updatedPort: DetectedPort = { + ...existing, + pid: info.pid, + processName: info.processName, + address: info.address, + }; + this.ports.set(key, updatedPort); + this.emit("port:remove", existing); + this.emit("port:add", updatedPort); + } + } - // Don't add duplicate - if (this.ports.has(key)) { - return false; + 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 +251,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.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 new file mode 100644 index 00000000000..26de93623df --- /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(","); + 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 + // 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 }, + ); + + if (!output.trim()) return []; + + const ports: PortInfo[] = []; + const lines = output.trim().split("\n").slice(1); + + 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 < 10) continue; + + const processName = columns[0]; + const pid = Number.parseInt(columns[1], 10); + + // 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 + const match = name.match(/^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/); + if (match) { + const address = match[1] || match[2] || "*"; + const port = Number.parseInt(match[3], 10); + + 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 { + 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); + + if (port < 1 || port > 65535) continue; + + 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(); + return name.replace(/\.exe$/i, "") || "unknown"; + } + } catch { + // 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 {} + } + 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 572d09bc4f1..400a4226d71 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 @@ -201,7 +201,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=="],