diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d89bd4e0e92..147e46f6604 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -92,6 +92,7 @@ "@superset/macos-process-metrics": "workspace:*", "@superset/macos-window-blur": "workspace:*", "@superset/panes": "workspace:*", + "@superset/port-scanner": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", diff --git a/apps/desktop/plans/20260108-2251-static-ports-json.md b/apps/desktop/plans/20260108-2251-static-ports-json.md index 63d737066cc..c5a25f9af37 100644 --- a/apps/desktop/plans/20260108-2251-static-ports-json.md +++ b/apps/desktop/plans/20260108-2251-static-ports-json.md @@ -1,5 +1,12 @@ # Static Ports Configuration via ports.json +> Superseded semantics: this plan documents the original static-port replacement +> design. Current behavior treats `.superset/ports.json` as supplemental label +> metadata only: it names dynamically detected listening ports, but does not +> create port rows, hide unlabeled ports, or replace dynamic discovery. See +> `plans/20260422-v2-remote-ports.md` and `apps/docs/content/docs/ports.mdx` +> for the current contract. + This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. Reference: This plan follows conventions from AGENTS.md and the ExecPlan template at `.agents/commands/create-plan.md`. diff --git a/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts index 6ea584a03ee..a1473144904 100644 --- a/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts +++ b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts @@ -9,15 +9,15 @@ import { workspaces, worktrees, } from "@superset/local-db"; -import { observable } from "@trpc/server/observable"; -import { and, eq, ne } from "drizzle-orm"; -import { app } from "electron"; -import { localDb } from "main/lib/local-db"; import { getProcessCommand, getProcessName, getProcessTree, -} from "main/lib/terminal/port-scanner"; +} from "@superset/port-scanner"; +import { observable } from "@trpc/server/observable"; +import { and, eq, ne } from "drizzle-orm"; +import { app } from "electron"; +import { localDb } from "main/lib/local-db"; import { getTerminalHostClient } from "main/lib/terminal-host/client"; import { z } from "zod"; import { publicProcedure, router } from "../.."; diff --git a/apps/desktop/src/lib/trpc/routers/ports/label-cache.ts b/apps/desktop/src/lib/trpc/routers/ports/label-cache.ts new file mode 100644 index 00000000000..bee307b4032 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ports/label-cache.ts @@ -0,0 +1,153 @@ +import { statSync } from "node:fs"; +import { join } from "node:path"; +import { workspaces } from "@superset/local-db"; +import { eq } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; +import { loadStaticPorts } from "main/lib/static-ports"; +import { PORTS_FILE_NAME, PROJECT_SUPERSET_DIR_NAME } from "shared/constants"; +import { getWorkspacePath } from "../workspaces/utils/worktree"; + +interface LabelCacheEntry { + labels: Map | null; + portsFileSignature: string | null; + worktreePath: string | null; +} + +function getPortsFileSignature(worktreePath: string): string | null { + try { + const stat = statSync( + join(worktreePath, PROJECT_SUPERSET_DIR_NAME, PORTS_FILE_NAME), + ); + return `${stat.mtimeMs}:${stat.size}`; + } catch (error) { + if (isMissingPathError(error)) return null; + throw error; + } +} + +function isMissingPathError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === "ENOENT" || code === "ENOTDIR"; +} + +function safeGetPortsFileSignature(worktreePath: string): string | null { + try { + return getPortsFileSignature(worktreePath); + } catch (error) { + console.warn("[ports] Failed to stat static port labels:", { + worktreePath, + error, + }); + return null; + } +} + +function safeLoadLabelsForWorktree( + worktreePath: string, +): Map | null { + try { + return loadLabelsForWorktree(worktreePath); + } catch (error) { + console.warn("[ports] Failed to load static port labels:", { + worktreePath, + error, + }); + return null; + } +} + +/** + * Resolve `ports.json` labels per workspace on demand, then memoize. + * + * Why memoize: `getAll` runs on every `port:add`/`port:remove` event (the + * renderer calls `utils.ports.getAll.invalidate()` in usePortsData). A dev + * server that flaps 5 ports cascades into 5 `getAll` calls × N workspaces of + * sync SQLite reads on the main thread. Cache once; ports.json rarely changes. + * + * `labels: null` with a resolved worktree means "no labels file" — still + * cached so we don't re-check the filesystem every event. A missing worktree is + * not cached because workspace hydration can race first reads. + * + * Lives in its own module so workspace-delete paths in `workspaces/utils/*` + * can call `invalidatePortLabelCache` without creating a ports ↔ workspaces + * import cycle. + */ +const labelCache = new Map(); + +function loadLabelsForWorktree( + worktreePath: string, +): Map | null { + const result = loadStaticPorts(worktreePath); + if (!result.exists || result.error || !result.ports) { + return null; + } + + const labels = new Map(); + for (const p of result.ports) { + labels.set(p.port, p.label); + } + return labels; +} + +function setLabelCache( + workspaceId: string, + worktreePath: string | null, + labels: Map | null, +): Map | null { + const portsFileSignature = worktreePath + ? safeGetPortsFileSignature(worktreePath) + : null; + labelCache.set(workspaceId, { + labels, + portsFileSignature, + worktreePath, + }); + return labels; +} + +export function getLabelsForWorkspace( + workspaceId: string, +): Map | null { + const cached = labelCache.get(workspaceId); + if (cached) { + if (cached.worktreePath === null) { + labelCache.delete(workspaceId); + } else { + const currentSignature = safeGetPortsFileSignature(cached.worktreePath); + if (currentSignature === cached.portsFileSignature) return cached.labels; + return setLabelCache( + workspaceId, + cached.worktreePath, + safeLoadLabelsForWorktree(cached.worktreePath), + ); + } + } + + const ws = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .get(); + const worktreePath = ws ? getWorkspacePath(ws) : null; + if (!worktreePath) { + return null; + } + + return setLabelCache( + workspaceId, + worktreePath, + safeLoadLabelsForWorktree(worktreePath), + ); +} + +/** + * Invalidate the label cache. Call when a workspace is deleted. Edits to + * `ports.json` are detected by the cached file signature. + */ +export function invalidatePortLabelCache(workspaceId?: string): void { + if (workspaceId === undefined) { + labelCache.clear(); + } else { + labelCache.delete(workspaceId); + } +} diff --git a/apps/desktop/src/lib/trpc/routers/ports/ports.ts b/apps/desktop/src/lib/trpc/routers/ports/ports.ts index 3f7b50e0072..b19f59f35e8 100644 --- a/apps/desktop/src/lib/trpc/routers/ports/ports.ts +++ b/apps/desktop/src/lib/trpc/routers/ports/ports.ts @@ -8,6 +8,8 @@ import { z } from "zod"; import { publicProcedure, router } from "../.."; import { getWorkspacePath } from "../workspaces/utils/worktree"; +export { invalidatePortLabelCache } from "./label-cache"; + type PortEvent = | { type: "add"; port: DetectedPort } | { type: "remove"; port: DetectedPort }; @@ -69,9 +71,10 @@ export const createPortsRouter = () => { detected: true, pid: port.pid, processName: port.processName, - paneId: port.paneId, + terminalId: port.terminalId, detectedAt: port.detectedAt, address: port.address, + hostUrl: null, }; }); @@ -89,9 +92,10 @@ export const createPortsRouter = () => { detected: false, pid: null, processName: null, - paneId: null, + terminalId: null, detectedAt: null, address: null, + hostUrl: null, }); } } @@ -122,7 +126,8 @@ export const createPortsRouter = () => { kill: publicProcedure .input( z.object({ - paneId: z.string(), + workspaceId: z.string(), + terminalId: z.string(), port: z.number().int().positive(), }), ) diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 6d7aa24fc2f..613d7fe7c61 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -29,6 +29,7 @@ import type { SimpleGitProgressEvent } from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { resolveDefaultEditor } from "../external"; +import { invalidatePortLabelCache } from "../ports/label-cache"; import { activateProject, getBranchWorkspace, @@ -1890,6 +1891,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { .delete(workspaces) .where(inArray(workspaces.id, closedWorkspaceIds)) .run(); + for (const id of closedWorkspaceIds) invalidatePortLabelCache(id); } localDb diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts index 785ec815871..b5cd9415695 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts @@ -11,6 +11,7 @@ import { import { and, desc, eq, isNotNull, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; +import { invalidatePortLabelCache } from "../../ports/label-cache"; import { computeNextProjectChildTabOrder } from "./project-children-order"; /** @@ -273,6 +274,7 @@ export function clearWorkspaceDeletingStatus(workspaceId: string): void { */ export function deleteWorkspace(workspaceId: string): void { localDb.delete(workspaces).where(eq(workspaces.id, workspaceId)).run(); + invalidatePortLabelCache(workspaceId); } /** diff --git a/apps/desktop/src/main/lib/browser-mcp-bridge/pane-resolver.ts b/apps/desktop/src/main/lib/browser-mcp-bridge/pane-resolver.ts index 0b72949c9e4..ae0dd91ba05 100644 --- a/apps/desktop/src/main/lib/browser-mcp-bridge/pane-resolver.ts +++ b/apps/desktop/src/main/lib/browser-mcp-bridge/pane-resolver.ts @@ -2,7 +2,7 @@ import { getProcessCommand, getProcessName, getProcessTree, -} from "main/lib/terminal/port-scanner"; +} from "@superset/port-scanner"; import { getTerminalHostClient } from "main/lib/terminal-host/client"; import { bindingStore } from "../../../lib/trpc/routers/browser-automation/index"; diff --git a/apps/desktop/src/main/lib/static-ports/loader.test.ts b/apps/desktop/src/main/lib/static-ports/loader.test.ts index c738b291795..6abd101a371 100644 --- a/apps/desktop/src/main/lib/static-ports/loader.test.ts +++ b/apps/desktop/src/main/lib/static-ports/loader.test.ts @@ -236,6 +236,23 @@ describe("loadStaticPorts", () => { expect(result.error).toBe("ports[1].port must be an integer"); }); + test("returns error when a port entry is duplicated", () => { + writeFileSync( + PORTS_FILE, + JSON.stringify({ + ports: [ + { port: 3000, label: "Frontend" }, + { port: 3000, label: "Duplicate" }, + ], + }), + ); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.exists).toBe(true); + expect(result.ports).toBeNull(); + expect(result.error).toBe("ports[1].port duplicates an earlier entry"); + }); + test("accepts valid boundary port numbers", () => { const config = { ports: [ diff --git a/apps/desktop/src/main/lib/static-ports/loader.ts b/apps/desktop/src/main/lib/static-ports/loader.ts index b03684183de..e1b918c17ad 100644 --- a/apps/desktop/src/main/lib/static-ports/loader.ts +++ b/apps/desktop/src/main/lib/static-ports/loader.ts @@ -1,72 +1,9 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { parseStaticPortsConfig } from "@superset/port-scanner"; import { PORTS_FILE_NAME, PROJECT_SUPERSET_DIR_NAME } from "shared/constants"; import type { StaticPortsResult } from "shared/types"; -interface PortEntry { - port: unknown; - label: unknown; -} - -interface PortsConfig { - ports: unknown; -} - -/** - * Validate a single port entry from the ports.json configuration. - * - * @param entry - The port entry object to validate - * @param index - The index of the entry in the ports array (for error messages) - * @returns Validation result with either the validated port/label or an error message - */ -function validatePortEntry( - entry: PortEntry, - index: number, -): - | { valid: true; port: number; label: string } - | { valid: false; error: string } { - if (typeof entry !== "object" || entry === null) { - return { valid: false, error: `ports[${index}] must be an object` }; - } - - if (!("port" in entry)) { - return { - valid: false, - error: `ports[${index}] is missing required field 'port'`, - }; - } - - if (!("label" in entry)) { - return { - valid: false, - error: `ports[${index}] is missing required field 'label'`, - }; - } - - const { port, label } = entry; - - if (typeof port !== "number" || !Number.isInteger(port)) { - return { valid: false, error: `ports[${index}].port must be an integer` }; - } - - if (port < 1 || port > 65535) { - return { - valid: false, - error: `ports[${index}].port must be between 1 and 65535`, - }; - } - - if (typeof label !== "string") { - return { valid: false, error: `ports[${index}].label must be a string` }; - } - - if (label.trim() === "") { - return { valid: false, error: `ports[${index}].label cannot be empty` }; - } - - return { valid: true, port, label: label.trim() }; -} - /** * Load and validate static ports configuration from a worktree's .superset/ports.json file. * @@ -96,56 +33,12 @@ export function loadStaticPorts(worktreePath: string): StaticPortsResult { }; } - let parsed: PortsConfig; - try { - parsed = JSON.parse(content) as PortsConfig; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { - exists: true, - ports: null, - error: `Invalid JSON in ports.json: ${message}`, - }; - } - - if (typeof parsed !== "object" || parsed === null) { - return { - exists: true, - ports: null, - error: "ports.json must contain a JSON object", - }; - } - - if (!("ports" in parsed)) { - return { - exists: true, - ports: null, - error: "ports.json is missing required field 'ports'", - }; - } - - if (!Array.isArray(parsed.ports)) { - return { - exists: true, - ports: null, - error: "'ports' field must be an array", - }; - } - - const validatedPorts: Array<{ port: number; label: string }> = []; - - for (let i = 0; i < parsed.ports.length; i++) { - const entry = parsed.ports[i] as PortEntry; - const result = validatePortEntry(entry, i); - - if (!result.valid) { - return { exists: true, ports: null, error: result.error }; - } - - validatedPorts.push({ port: result.port, label: result.label }); + const parsed = parseStaticPortsConfig(content); + if (parsed.ports === null) { + return { exists: true, ports: null, error: parsed.error }; } - return { exists: true, ports: validatedPorts, error: null }; + return { exists: true, ports: parsed.ports, error: null }; } /** diff --git a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts index 08f216e9d4a..b2a89e7fa7c 100644 --- a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts +++ b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts @@ -204,8 +204,8 @@ mock.module("@superset/local-db", () => ({ mock.module("../port-manager", () => ({ portManager: { - upsertDaemonSession: () => {}, - unregisterDaemonSession: () => {}, + upsertSession: () => {}, + unregisterSession: () => {}, checkOutputForHint: () => {}, }, })); diff --git a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts index b4fb5cffe70..2974e9db3eb 100644 --- a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts @@ -138,7 +138,7 @@ export class DaemonTerminalManager extends EventEmitter { // Enable port scanning before user opens terminal tabs for (const session of preservedSessions) { - portManager.upsertDaemonSession( + portManager.upsertSession( session.paneId, session.workspaceId, session.pid, @@ -210,7 +210,7 @@ export class DaemonTerminalManager extends EventEmitter { session.pid = null; } - portManager.unregisterDaemonSession(paneId); + portManager.unregisterSession(paneId); this.historyManager.closeHistoryWriter(paneId, exitCode); const reason = session?.exitReason ?? @@ -511,7 +511,7 @@ export class DaemonTerminalManager extends EventEmitter { rows: effectiveRows, }); - portManager.upsertDaemonSession(paneId, workspaceId, response.pid); + portManager.upsertSession(paneId, workspaceId, response.pid); const snapshotAnsi = response.snapshot.snapshotAnsi || ""; const snapshotAnsiBytes = Buffer.byteLength(snapshotAnsi, "utf8"); @@ -722,7 +722,7 @@ export class DaemonTerminalManager extends EventEmitter { session.pid = null; } - portManager.unregisterDaemonSession(paneId); + portManager.unregisterSession(paneId); if (deleteHistory && session) { await this.historyManager.cleanupHistory(paneId, session.workspaceId); @@ -855,7 +855,7 @@ export class DaemonTerminalManager extends EventEmitter { session.pid = null; } - portManager.unregisterDaemonSession(paneId); + portManager.unregisterSession(paneId); await this.historyManager.cleanupHistory(paneId, workspaceId); await this.client.kill({ sessionId: paneId, deleteHistory: true }); }), @@ -974,7 +974,7 @@ export class DaemonTerminalManager extends EventEmitter { await this.client.killAll({}); } for (const paneId of sessionIds) { - portManager.unregisterDaemonSession(paneId); + portManager.unregisterSession(paneId); } this.daemonAliveSessionIds.clear(); this.daemonSessionIdsHydrated = true; diff --git a/apps/desktop/src/main/lib/terminal/port-manager.test.ts b/apps/desktop/src/main/lib/terminal/port-manager.test.ts deleted file mode 100644 index c70a09e6a88..00000000000 --- a/apps/desktop/src/main/lib/terminal/port-manager.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; -import type { TerminalSession } from "./types"; - -/** - * Regression tests for #3372 ("excessive lsof spawning"). - * - * Three behaviors the fix guarantees: - * 1. No scans run when there are no registered sessions (lifecycle). - * 2. At most one scan is in flight at any moment, even under a flood of - * hint-matching output (concurrency / coalescing). - * 3. stopPeriodicScan aborts any in-flight child so it cannot outlive us - * (no orphan lsof). - * - * The hint regexes that previously matched routine log noise ("port 22", - * trailing ":12345") must no longer trigger scans; the three "listening on …" - * patterns still must. - */ - -interface ScannerSpy { - getProcessTree: number; - getListeningPortsForPids: number; - inFlight: number; - maxInFlight: number; - lastSignal: AbortSignal | undefined; - aborted: number; -} - -const spy: ScannerSpy = { - getProcessTree: 0, - getListeningPortsForPids: 0, - inFlight: 0, - maxInFlight: 0, - lastSignal: undefined, - aborted: 0, -}; - -let lsofDelayMs = 0; - -mock.module("./port-scanner", () => ({ - getProcessTree: async (pid: number) => { - spy.getProcessTree++; - return [pid, pid + 1]; - }, - getListeningPortsForPids: async (_pids: number[], signal?: AbortSignal) => { - spy.getListeningPortsForPids++; - spy.inFlight++; - spy.maxInFlight = Math.max(spy.maxInFlight, spy.inFlight); - spy.lastSignal = signal; - try { - if (lsofDelayMs > 0) { - // Match production: getListeningPortsLsof catches all errors and - // returns []. If we get aborted we just resolve with [] early. - await new Promise((resolve) => { - const timer = setTimeout(resolve, lsofDelayMs); - signal?.addEventListener("abort", () => { - clearTimeout(timer); - spy.aborted++; - resolve(); - }); - }); - } - return []; - } finally { - spy.inFlight--; - } - }, -})); - -mock.module("../tree-kill", () => ({ - treeKillWithEscalation: async () => ({ success: true }), -})); - -const { portManager } = await import("./port-manager"); - -const HINT_DEBOUNCE_MS = 500; -const PAST_DEBOUNCE_MS = HINT_DEBOUNCE_MS + 50; - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -const makeSession = (paneId: string, pid: number): TerminalSession => - ({ paneId, isAlive: true, pty: { pid } }) as unknown as TerminalSession; - -// biome-ignore lint/suspicious/noExplicitAny: reach into private singleton state -const pmInternals = () => portManager as any; - -function resetSpy(): void { - spy.getProcessTree = 0; - spy.getListeningPortsForPids = 0; - spy.inFlight = 0; - spy.maxInFlight = 0; - spy.lastSignal = undefined; - spy.aborted = 0; - lsofDelayMs = 0; -} - -function resetManager(): void { - const internals = pmInternals(); - for (const paneId of Array.from(internals.sessions.keys())) { - portManager.unregisterSession(paneId); - } - for (const paneId of Array.from(internals.daemonSessions.keys())) { - portManager.unregisterDaemonSession(paneId); - } - portManager.stopPeriodicScan(); -} - -beforeEach(() => { - resetSpy(); - resetManager(); -}); - -afterEach(() => { - resetManager(); -}); - -describe("PortManager — #3372 lifecycle (interval runs only with sessions)", () => { - it("forceScan is a no-op when no sessions are registered", async () => { - await portManager.forceScan(); - expect(spy.getProcessTree).toBe(0); - expect(spy.getListeningPortsForPids).toBe(0); - }); - - it("first registered session starts the interval; last unregister stops it", () => { - expect(pmInternals().scanInterval).toBeNull(); - - portManager.registerSession(makeSession("p1", 1000), "ws1"); - expect(pmInternals().scanInterval).not.toBeNull(); - - portManager.unregisterSession("p1"); - expect(pmInternals().scanInterval).toBeNull(); - }); - - it("daemon sessions control the interval the same way", () => { - portManager.upsertDaemonSession("pd1", "ws1", 2000); - expect(pmInternals().scanInterval).not.toBeNull(); - - portManager.unregisterDaemonSession("pd1"); - expect(pmInternals().scanInterval).toBeNull(); - }); - - it("mixed session types: interval stops only when all are gone", () => { - portManager.registerSession(makeSession("p1", 1000), "ws1"); - portManager.upsertDaemonSession("pd1", "ws2", 2000); - - portManager.unregisterSession("p1"); - expect(pmInternals().scanInterval).not.toBeNull(); - - portManager.unregisterDaemonSession("pd1"); - expect(pmInternals().scanInterval).toBeNull(); - }); - - it("re-registering after idle restarts the interval", () => { - portManager.registerSession(makeSession("p1", 1000), "ws1"); - portManager.unregisterSession("p1"); - expect(pmInternals().scanInterval).toBeNull(); - - portManager.registerSession(makeSession("p2", 1001), "ws1"); - expect(pmInternals().scanInterval).not.toBeNull(); - }); -}); - -describe("PortManager — #3372 concurrency (at most one lsof in flight)", () => { - it("bulk scan batches every session into a single lsof call", async () => { - for (let i = 0; i < 10; i++) { - portManager.registerSession(makeSession(`p${i}`, 1000 + i), `ws${i}`); - } - await portManager.forceScan(); - - expect(spy.getListeningPortsForPids).toBe(1); - expect(spy.maxInFlight).toBe(1); - }); - - it("a flood of hints coalesces into one follow-up, never concurrent", async () => { - lsofDelayMs = 30; - portManager.registerSession(makeSession("p1", 1000), "ws1"); - - const firstScan = portManager.forceScan(); - - // 100 hints while the first scan is running — all on the hot path. - for (let i = 0; i < 100; i++) { - portManager.checkOutputForHint("listening on port 3000\n"); - } - - await firstScan; - await sleep(PAST_DEBOUNCE_MS); // let the single debounced follow-up run - - expect(spy.maxInFlight).toBe(1); - // Exact — one initial scan + one coalesced follow-up, never more, never fewer. - expect(spy.getListeningPortsForPids).toBe(2); - }); -}); - -describe("PortManager — #3372 hint regex narrowing", () => { - beforeEach(() => { - portManager.registerSession(makeSession("p1", 1000), "ws1"); - resetSpy(); - }); - - it("does NOT scan on a bare 'port 22' (old loose pattern)", async () => { - portManager.checkOutputForHint("connection reached port 22\n"); - await sleep(PAST_DEBOUNCE_MS); - expect(spy.getListeningPortsForPids).toBe(0); - }); - - it("does NOT scan on a trailing ':12345' (old loose pattern)", async () => { - portManager.checkOutputForHint("commit abc123def:12345\n"); - await sleep(PAST_DEBOUNCE_MS); - expect(spy.getListeningPortsForPids).toBe(0); - }); - - it("DOES scan on 'listening on port 3000'", async () => { - portManager.checkOutputForHint("listening on port 3000\n"); - await sleep(PAST_DEBOUNCE_MS); - expect(spy.getListeningPortsForPids).toBe(1); - }); - - it("DOES scan on 'server running at http://localhost:3000'", async () => { - portManager.checkOutputForHint("server running at http://localhost:3000\n"); - await sleep(PAST_DEBOUNCE_MS); - expect(spy.getListeningPortsForPids).toBe(1); - }); - - it("DOES scan on 'ready on http://localhost:5173' (Vite-style)", async () => { - portManager.checkOutputForHint("ready on http://localhost:5173\n"); - await sleep(PAST_DEBOUNCE_MS); - expect(spy.getListeningPortsForPids).toBe(1); - }); -}); - -describe("PortManager — #3372 teardown (no orphan children)", () => { - it("stopPeriodicScan aborts any in-flight lsof", async () => { - lsofDelayMs = 200; - portManager.registerSession(makeSession("p1", 1000), "ws1"); - - const scanPromise = portManager.forceScan(); - // Wait for the lsof stub to start. - await sleep(10); - expect(spy.inFlight).toBe(1); - - portManager.stopPeriodicScan(); - - // The promise resolves (port-scanner swallows its own errors). - await scanPromise; - - expect(spy.aborted).toBeGreaterThanOrEqual(1); - expect(spy.inFlight).toBe(0); - }); - - it("in-flight lsof receives the AbortSignal from the manager", async () => { - lsofDelayMs = 50; - portManager.registerSession(makeSession("p1", 1000), "ws1"); - - const scanPromise = portManager.forceScan(); - await sleep(10); - - expect(spy.lastSignal).toBeDefined(); - expect(spy.lastSignal?.aborted).toBe(false); - - await scanPromise; - }); -}); diff --git a/apps/desktop/src/main/lib/terminal/port-manager.ts b/apps/desktop/src/main/lib/terminal/port-manager.ts index eca34d43ac9..7b6dcbbbf1a 100644 --- a/apps/desktop/src/main/lib/terminal/port-manager.ts +++ b/apps/desktop/src/main/lib/terminal/port-manager.ts @@ -1,476 +1,6 @@ -import { EventEmitter } from "node:events"; -import type { DetectedPort } from "shared/types"; +import { PortManager } from "@superset/port-scanner"; import { treeKillWithEscalation } from "../tree-kill"; -import { - getListeningPortsForPids, - getProcessTree, - type PortInfo, -} from "./port-scanner"; -import type { TerminalSession } from "./types"; -// How often to poll for port changes (in ms) -const SCAN_INTERVAL_MS = 2500; - -// Delay before scanning after a port hint is detected (in ms) -const HINT_SCAN_DELAY_MS = 500; - -// Ports to ignore (common system ports that are usually not dev servers) -const IGNORED_PORTS = new Set([22, 80, 443, 5432, 3306, 6379, 27017]); - -/** - * Check if terminal output contains hints that a port may have been opened. - * Restricted to phrases that strongly imply a server just started listening; - * looser patterns like a bare "port 22" or trailing ":12345" are omitted - * because they match routine log output (ssh banners, timestamps, etc.) and - * triggered excessive lsof scans — see issue #3372. - */ -function containsPortHint(data: string): boolean { - const portPatterns = [ - /listening\s+on\s+(?:port\s+)?(\d+)/i, - /server\s+(?:started|running)\s+(?:on|at)\s+(?:http:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0)?:?(\d+)/i, - /ready\s+on\s+(?:http:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0)?:?(\d+)/i, - ]; - return portPatterns.some((pattern) => pattern.test(data)); -} - -interface RegisteredSession { - session: TerminalSession; - workspaceId: string; -} - -/** - * Daemon session registration for port scanning. - * Unlike RegisteredSession, this tracks sessions in the daemon process - * where we only have the PID (not a TerminalSession object). - */ -interface DaemonSession { - workspaceId: string; - /** PTY process ID - null if not yet spawned or exited */ - pid: number | null; -} - -interface ScanState { - panePortMap: Map; - pidOwnerMap: Map; - allPids: Set; - emptyTreePanes: Set; -} - -class PortManager extends EventEmitter { - private ports = new Map(); - private sessions = new Map(); - /** Daemon-mode sessions: paneId → { workspaceId, pid } */ - private daemonSessions = new Map(); - private scanInterval: ReturnType | null = null; - private hintScanTimeout: ReturnType | null = null; - private isScanning = false; - /** Set when a hint arrives during a scan; triggers one follow-up scan. */ - private scanRequested = false; - /** Aborts any in-flight scan children (lsof/netstat) on teardown. */ - private scanAbort: AbortController | null = null; - - /** - * Register a terminal session for port scanning - */ - registerSession(session: TerminalSession, workspaceId: string): void { - this.sessions.set(session.paneId, { session, workspaceId }); - this.ensurePeriodicScanRunning(); - } - - /** - * Unregister a terminal session and remove its ports - */ - unregisterSession(paneId: string): void { - this.sessions.delete(paneId); - this.removePortsForPane(paneId); - this.stopPeriodicScanIfIdle(); - } - - /** - * Register or update a daemon-mode terminal session for port scanning. - * Use this when the terminal runs in the daemon process (terminal persistence mode). - * Can be called multiple times to update the PID when it becomes available or changes. - */ - upsertDaemonSession( - paneId: string, - workspaceId: string, - pid: number | null, - ): void { - this.daemonSessions.set(paneId, { workspaceId, pid }); - this.ensurePeriodicScanRunning(); - } - - /** - * Unregister a daemon-mode terminal session and remove its ports - */ - unregisterDaemonSession(paneId: string): void { - this.daemonSessions.delete(paneId); - this.removePortsForPane(paneId); - this.stopPeriodicScanIfIdle(); - } - - checkOutputForHint(data: string): void { - if (!containsPortHint(data)) return; - this.scheduleHintScan(); - } - - private hasAnySessions(): boolean { - return this.sessions.size > 0 || this.daemonSessions.size > 0; - } - - private ensurePeriodicScanRunning(): void { - if (this.scanInterval) return; - - this.scanAbort = new AbortController(); - this.scanInterval = setInterval(() => { - this.scanAllSessions().catch((error) => { - console.error("[PortManager] Scan error:", error); - }); - }, SCAN_INTERVAL_MS); - - // Don't prevent Node from exiting - this.scanInterval.unref(); - } - - private stopPeriodicScanIfIdle(): void { - if (!this.hasAnySessions()) this.stopPeriodicScan(); - } - - stopPeriodicScan(): void { - if (this.scanInterval) { - clearInterval(this.scanInterval); - this.scanInterval = null; - } - - if (this.hintScanTimeout) { - clearTimeout(this.hintScanTimeout); - this.hintScanTimeout = null; - } - - // Kill any in-flight lsof/netstat so it can't outlive us. - if (this.scanAbort) { - this.scanAbort.abort(); - this.scanAbort = null; - } - - this.scanRequested = false; - } - - /** - * Debounce hint-triggered scans into a single follow-up bulk scan. - * Hints arrive on every PTY data chunk; we only need one scan per burst. - */ - private scheduleHintScan(): void { - if (this.hintScanTimeout) return; - - this.hintScanTimeout = setTimeout(() => { - this.hintScanTimeout = null; - this.scanAllSessions().catch(() => {}); - }, HINT_SCAN_DELAY_MS); - this.hintScanTimeout.unref(); - } - - private createScanState(): ScanState { - return { - panePortMap: new Map(), - pidOwnerMap: new Map(), - allPids: new Set(), - emptyTreePanes: new Set(), - }; - } - - private async collectRegularSessionPids(scanState: ScanState): Promise { - const tasks: Promise[] = []; - for (const [paneId, { session, workspaceId }] of this.sessions) { - if (!session.isAlive) continue; - tasks.push( - this.collectPidTree({ - paneId, - workspaceId, - pid: session.pty.pid, - scanState, - }), - ); - } - await Promise.all(tasks); - } - - private async collectDaemonSessionPids(scanState: ScanState): Promise { - const tasks: Promise[] = []; - for (const [paneId, { workspaceId, pid }] of this.daemonSessions) { - if (pid === null) continue; - tasks.push( - this.collectPidTree({ - paneId, - workspaceId, - pid, - scanState, - }), - ); - } - await Promise.all(tasks); - } - - private async collectPidTree({ - paneId, - workspaceId, - pid, - scanState, - }: { - paneId: string; - workspaceId: string; - pid: number; - scanState: ScanState; - }): Promise { - try { - const pids = await getProcessTree(pid); - if (pids.length === 0) { - scanState.emptyTreePanes.add(paneId); - return; - } - - scanState.panePortMap.set(paneId, { workspaceId, pids }); - this.addPanePids({ paneId, workspaceId, pids, scanState }); - } catch { - // Session may have exited - } - } - - private addPanePids({ - paneId, - workspaceId, - pids, - scanState, - }: { - paneId: string; - workspaceId: string; - pids: number[]; - scanState: ScanState; - }): void { - for (const childPid of pids) { - scanState.allPids.add(childPid); - if (!scanState.pidOwnerMap.has(childPid)) { - scanState.pidOwnerMap.set(childPid, { paneId, workspaceId }); - } - } - } - - private async buildPortsByPane({ - allPids, - pidOwnerMap, - }: { - allPids: Set; - pidOwnerMap: ScanState["pidOwnerMap"]; - }): Promise> { - const portsByPane = new Map(); - const allPidList = Array.from(allPids); - if (allPidList.length === 0) return portsByPane; - - const portInfos = await getListeningPortsForPids( - allPidList, - this.scanAbort?.signal, - ); - for (const info of portInfos) { - const owner = pidOwnerMap.get(info.pid); - if (!owner) continue; - const existing = portsByPane.get(owner.paneId); - if (existing) { - existing.push(info); - } else { - portsByPane.set(owner.paneId, [info]); - } - } - - return portsByPane; - } - - private updatePortsFromScan({ - panePortMap, - portsByPane, - }: { - panePortMap: ScanState["panePortMap"]; - portsByPane: Map; - }): void { - for (const [paneId, { workspaceId }] of panePortMap) { - const portInfos = portsByPane.get(paneId) ?? []; - this.updatePortsForPane({ paneId, workspaceId, portInfos }); - } - } - - private clearEmptyTreePanes(emptyTreePanes: Set): void { - for (const paneId of emptyTreePanes) { - this.removePortsForPane(paneId); - } - } - - private cleanupUnregisteredPorts(): void { - for (const [key, port] of this.ports) { - const isRegistered = - this.sessions.has(port.paneId) || this.daemonSessions.has(port.paneId); - if (!isRegistered) { - this.ports.delete(key); - this.emit("port:remove", port); - } - } - } - - private async scanAllSessions(): Promise { - if (this.isScanning) { - // A hint or tick fired mid-scan; queue exactly one follow-up. - this.scanRequested = true; - return; - } - if (!this.hasAnySessions()) return; - this.isScanning = true; - - try { - const scanState = this.createScanState(); - await this.collectRegularSessionPids(scanState); - await this.collectDaemonSessionPids(scanState); - - const portsByPane = await this.buildPortsByPane({ - allPids: scanState.allPids, - pidOwnerMap: scanState.pidOwnerMap, - }); - - this.updatePortsFromScan({ - panePortMap: scanState.panePortMap, - portsByPane, - }); - this.clearEmptyTreePanes(scanState.emptyTreePanes); - this.cleanupUnregisteredPorts(); - } finally { - this.isScanning = false; - } - - if (this.scanRequested && this.hasAnySessions()) { - this.scanRequested = false; - await this.scanAllSessions(); - } - } - - private updatePortsForPane({ - paneId, - workspaceId, - portInfos, - }: { - paneId: string; - workspaceId: string; - portInfos: PortInfo[]; - }): 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); - } - } - - for (const [key, port] of this.ports) { - if (port.paneId === paneId && !seenKeys.has(key)) { - this.ports.delete(key); - this.emit("port:remove", port); - } - } - } - - private makeKey(paneId: string, port: number): string { - return `${paneId}:${port}`; - } - - removePortsForPane(paneId: string): void { - const portsToRemove: DetectedPort[] = []; - - for (const [key, port] of this.ports) { - if (port.paneId === paneId) { - portsToRemove.push(port); - this.ports.delete(key); - } - } - - for (const port of portsToRemove) { - this.emit("port:remove", port); - } - } - - getAllPorts(): DetectedPort[] { - return Array.from(this.ports.values()).sort( - (a, b) => b.detectedAt - a.detectedAt, - ); - } - - getPortsByWorkspace(workspaceId: string): DetectedPort[] { - return this.getAllPorts().filter((p) => p.workspaceId === workspaceId); - } - - async forceScan(): Promise { - await this.scanAllSessions(); - } - - /** - * Kill a process tree listening on a tracked port. - * Refuses to kill the terminal's shell process itself. - */ - killPort({ paneId, port }: { paneId: string; port: number }): Promise<{ - success: boolean; - error?: string; - }> { - const key = this.makeKey(paneId, port); - const detectedPort = this.ports.get(key); - - if (!detectedPort) { - return Promise.resolve({ - success: false, - error: "Port not found in tracked ports", - }); - } - - const session = this.sessions.get(paneId); - const daemonSession = this.daemonSessions.get(paneId); - const shellPid = session?.session.pty.pid ?? daemonSession?.pid; - - if (shellPid != null && detectedPort.pid === shellPid) { - return Promise.resolve({ - success: false, - error: "Cannot kill the terminal shell process", - }); - } - - return treeKillWithEscalation({ pid: detectedPort.pid }); - } -} - -export const portManager = new PortManager(); +export const portManager = new PortManager({ + killFn: treeKillWithEscalation, +}); diff --git a/apps/desktop/src/renderer/globals.css b/apps/desktop/src/renderer/globals.css index c841b9c1b16..7563e46d142 100644 --- a/apps/desktop/src/renderer/globals.css +++ b/apps/desktop/src/renderer/globals.css @@ -311,6 +311,20 @@ display: none; /* Chrome/Safari/Electron */ } + /* Fade out the right edge of a horizontally-scrolling container */ + .fade-edge-r { + mask-image: linear-gradient( + to right, + black calc(100% - 1.5rem), + transparent + ); + -webkit-mask-image: linear-gradient( + to right, + black calc(100% - 1.5rem), + transparent + ); + } + /* Dark mode scrollbar styling */ * { scrollbar-width: thin; diff --git a/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts index 376db75fbf9..cdc7521a73a 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts @@ -2,6 +2,7 @@ import { type AgentLifecyclePayload, type GitChangedPayload, getEventBus, + type PortChangedPayload, type TerminalLifecyclePayload, } from "@superset/workspace-client"; import type { FsWatchEvent } from "@superset/workspace-fs/client"; @@ -38,13 +39,25 @@ export function useWorkspaceEvent( enabled?: boolean, ): void; export function useWorkspaceEvent( - type: "git:changed" | "fs:events" | "agent:lifecycle" | "terminal:lifecycle", + type: "port:changed", + workspaceId: string, + callback: (payload: PortChangedPayload) => void, + enabled?: boolean, +): void; +export function useWorkspaceEvent( + type: + | "git:changed" + | "fs:events" + | "agent:lifecycle" + | "terminal:lifecycle" + | "port:changed", workspaceId: string, callback: | ((event: FsWatchEvent) => void) | ((payload: GitChangedPayload) => void) | ((payload: AgentLifecyclePayload) => void) - | ((payload: TerminalLifecyclePayload) => void), + | ((payload: TerminalLifecyclePayload) => void) + | ((payload: PortChangedPayload) => void), enabled = true, ): void { const hostUrl = useWorkspaceHostUrl(workspaceId); @@ -86,6 +99,15 @@ export function useWorkspaceEvent( }, ); cleanups.push(removeListener); + } else if (type === "port:changed") { + const removeListener = bus.on( + "port:changed", + workspaceId, + (_wid, payload) => { + (handler as (payload: PortChangedPayload) => void)(payload); + }, + ); + cleanups.push(removeListener); } else { const removeListener = bus.on( "git:changed", diff --git a/apps/desktop/src/renderer/hooks/ports/usePortKillActions/index.ts b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/index.ts new file mode 100644 index 00000000000..a5558ffb77c --- /dev/null +++ b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/index.ts @@ -0,0 +1,7 @@ +export { + killPortTarget, + type LocalPortKill, + type PortKillResult, + type PortKillTarget, +} from "./killPortTarget"; +export { usePortKillActions } from "./usePortKillActions"; diff --git a/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.test.ts b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.test.ts new file mode 100644 index 00000000000..3db93786e19 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +const remoteKillMock = mock(async () => ({ success: true })); + +mock.module("renderer/lib/host-service-client", () => ({ + getHostServiceClientByUrl: () => ({ + ports: { + kill: { + mutate: remoteKillMock, + }, + }, + }), +})); + +const { killPortTarget } = await import("./killPortTarget"); + +describe("killPortTarget", () => { + beforeEach(() => { + remoteKillMock.mockClear(); + remoteKillMock.mockResolvedValue({ success: true }); + }); + + it("routes host-owned ports through the host-service client", async () => { + const result = await killPortTarget({ + workspaceId: "workspace-1", + terminalId: "terminal-1", + port: 5173, + hostUrl: "http://host-service", + }); + + expect(result).toEqual({ success: true }); + expect(remoteKillMock).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + terminalId: "terminal-1", + port: 5173, + }); + }); + + it("routes local ports through the provided local kill function", async () => { + const localKill = mock(async () => ({ success: true })); + + const result = await killPortTarget( + { + workspaceId: "workspace-1", + terminalId: "terminal-1", + port: 3000, + hostUrl: null, + }, + localKill, + ); + + expect(result).toEqual({ success: true }); + expect(localKill).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + terminalId: "terminal-1", + port: 3000, + }); + }); + + it("normalizes thrown kill errors into failed results", async () => { + const result = await killPortTarget( + { + workspaceId: "workspace-1", + terminalId: "terminal-1", + port: 3000, + }, + async () => { + throw new Error("network down"); + }, + ); + + expect(result).toEqual({ success: false, error: "network down" }); + }); +}); diff --git a/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.ts b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.ts new file mode 100644 index 00000000000..6a93f3bd34f --- /dev/null +++ b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.ts @@ -0,0 +1,51 @@ +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; + +export type PortKillResult = { success: boolean; error?: string }; + +export interface PortKillTarget { + workspaceId: string; + terminalId: string; + port: number; + hostUrl?: string | null; +} + +export type LocalPortKill = (input: { + workspaceId: string; + terminalId: string; + port: number; +}) => Promise; + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +export async function killPortTarget( + target: PortKillTarget, + localKill?: LocalPortKill, +): Promise { + const payload = { + workspaceId: target.workspaceId, + terminalId: target.terminalId, + port: target.port, + }; + + try { + if (target.hostUrl) { + return await getHostServiceClientByUrl(target.hostUrl).ports.kill.mutate( + payload, + ); + } + + if (!localKill) { + return { + success: false, + error: "No host is available for this port", + }; + } + + return await localKill(payload); + } catch (error) { + return { success: false, error: toErrorMessage(error) }; + } +} diff --git a/apps/desktop/src/renderer/hooks/ports/usePortKillActions/usePortKillActions.ts b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/usePortKillActions.ts new file mode 100644 index 00000000000..8877610c53b --- /dev/null +++ b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/usePortKillActions.ts @@ -0,0 +1,90 @@ +import { toast } from "@superset/ui/sonner"; +import { type QueryKey, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; +import { + killPortTarget, + type LocalPortKill, + type PortKillResult, + type PortKillTarget, +} from "./killPortTarget"; + +interface UsePortKillActionsOptions { + localKill?: LocalPortKill; + refreshQueryKey?: QueryKey; + externalPending?: boolean; +} + +function getFailureDescription(result: PortKillResult): string | undefined { + return result.error && result.error.length > 0 ? result.error : undefined; +} + +export function usePortKillActions({ + localKill, + refreshQueryKey, + externalPending = false, +}: UsePortKillActionsOptions = {}) { + const queryClient = useQueryClient(); + const [pendingCount, setPendingCount] = useState(0); + + const refreshPorts = useCallback(async () => { + if (!refreshQueryKey) return; + try { + await queryClient.invalidateQueries({ queryKey: refreshQueryKey }); + } catch (error) { + console.error("[ports] Failed to refresh ports after kill:", error); + } + }, [queryClient, refreshQueryKey]); + + const killPort = useCallback( + async (port: TPort): Promise => { + setPendingCount((count) => count + 1); + try { + const result = await killPortTarget(port, localKill); + if (!result.success) { + toast.error(`Failed to close port ${port.port}`, { + description: getFailureDescription(result), + }); + } + return result; + } finally { + await refreshPorts(); + setPendingCount((count) => Math.max(0, count - 1)); + } + }, + [localKill, refreshPorts], + ); + + const killPorts = useCallback( + async (ports: TPort[]): Promise => { + if (ports.length === 0) return []; + + setPendingCount((count) => count + 1); + try { + const results = await Promise.all( + ports.map((port) => killPortTarget(port, localKill)), + ); + const failed = results.filter((result) => !result.success); + if (failed.length === 1) { + toast.error("Failed to close 1 port", { + description: getFailureDescription(failed[0] ?? { success: false }), + }); + } else if (failed.length > 1) { + toast.error(`Failed to close ${failed.length} ports`, { + description: getFailureDescription(failed[0] ?? { success: false }), + }); + } + return results; + } finally { + await refreshPorts(); + setPendingCount((count) => Math.max(0, count - 1)); + } + }, + [localKill, refreshPorts], + ); + + return { + killPort, + killPorts, + isPending: pendingCount > 0 || externalPending, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx index 67bd4c68c79..bb8cd3977ad 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx @@ -22,6 +22,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { DashboardSidebarHeader } from "./components/DashboardSidebarHeader"; +import { DashboardSidebarPortsList } from "./components/DashboardSidebarPortsList"; import { DashboardSidebarProjectSection } from "./components/DashboardSidebarProjectSection"; import { useDashboardSidebarData } from "./hooks/useDashboardSidebarData"; import { useDashboardSidebarShortcuts } from "./hooks/useDashboardSidebarShortcuts"; @@ -184,6 +185,7 @@ export function DashboardSidebar({ )} + {!isCollapsed && } ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/DashboardSidebarPortsList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/DashboardSidebarPortsList.tsx new file mode 100644 index 00000000000..8eaef9e8d38 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/DashboardSidebarPortsList.tsx @@ -0,0 +1,80 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { LuChevronRight, LuCircleAlert, LuRadioTower } from "react-icons/lu"; +import { STROKE_WIDTH } from "renderer/screens/main/components/WorkspaceSidebar/constants"; +import { usePortsStore } from "renderer/stores"; +import { DashboardSidebarPortGroup } from "./components/DashboardSidebarPortGroup"; +import { useDashboardSidebarPortsData } from "./hooks/useDashboardSidebarPortsData"; + +export function DashboardSidebarPortsList() { + const isCollapsed = usePortsStore((state) => state.isListCollapsed); + const toggleCollapsed = usePortsStore((state) => state.toggleListCollapsed); + const { totalPortCount, workspacePortGroups, portLoadErrors } = + useDashboardSidebarPortsData(); + const failedHostCount = portLoadErrors.length; + + if (totalPortCount === 0 && failedHostCount === 0) { + return null; + } + + return ( +
+
+ + + {failedHostCount > 0 && ( + + + + + + + +

+ {failedHostCount === 1 + ? "Could not load ports from 1 host" + : `Could not load ports from ${failedHostCount} hosts`} +

+
+
+ )} + 0 + ? "text-[10px] font-normal" + : "ml-auto text-[10px] font-normal" + } + > + {totalPortCount} + +
+ {!isCollapsed && ( +
+ {workspacePortGroups.map((group) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortBadge/DashboardSidebarPortBadge.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortBadge/DashboardSidebarPortBadge.tsx new file mode 100644 index 00000000000..df59547e439 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortBadge/DashboardSidebarPortBadge.tsx @@ -0,0 +1,146 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { useNavigate } from "@tanstack/react-router"; +import type { MouseEvent } from "react"; +import { LuExternalLink, LuLoaderCircle, LuX } from "react-icons/lu"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { getOpenTargetClickIntent } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent"; +import { STROKE_WIDTH } from "renderer/screens/main/components/WorkspaceSidebar/constants"; +import { useDashboardSidebarPortKill } from "../../hooks/useDashboardSidebarPortKill"; +import type { DashboardSidebarPort } from "../../hooks/useDashboardSidebarPortsData"; + +interface DashboardSidebarPortBadgeProps { + port: DashboardSidebarPort; +} + +export function DashboardSidebarPortBadge({ + port, +}: DashboardSidebarPortBadgeProps) { + const navigate = useNavigate(); + const openUrl = electronTrpc.external.openUrl.useMutation(); + const { isPending, killPort } = useDashboardSidebarPortKill(); + const canOpenInBrowser = port.hostType === "local-device"; + const hostLabel = + port.hostType === "local-device" ? "Local device" : "Remote host"; + + const handleWorkspaceClick = () => { + void navigateToV2Workspace(port.workspaceId, navigate, { + search: { + terminalId: port.terminalId, + focusRequestId: crypto.randomUUID(), + }, + }); + }; + + const handleOpenInBrowser = (event: MouseEvent) => { + if (!canOpenInBrowser) return; + + const url = `http://localhost:${port.port}`; + const intent = getOpenTargetClickIntent(event); + if (intent === "openExternally") { + if (openUrl.isPending) return; + openUrl.mutate(url); + return; + } + + void navigateToV2Workspace(port.workspaceId, navigate, { + search: { + openUrl: url, + openUrlTarget: intent === "openInNewTab" ? "new-tab" : "current-tab", + openUrlRequestId: crypto.randomUUID(), + }, + }); + }; + + const handleClose = () => { + if (isPending) return; + void killPort(port); + }; + + return ( + + +
+ + {canOpenInBrowser && ( + + )} + +
+
+ +
+ {port.label &&
{port.label}
} +
+ localhost:{port.port} +
+
{hostLabel}
+ {(port.processName || port.pid != null) && ( +
+ {port.processName} + {port.pid != null && ` (pid ${port.pid})`} +
+ )} + {!canOpenInBrowser && ( +
+ Browser open unavailable from this device +
+ )} +
+ Click to open workspace +
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortBadge/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortBadge/index.ts new file mode 100644 index 00000000000..098aca2af7d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortBadge/index.ts @@ -0,0 +1 @@ +export { DashboardSidebarPortBadge } from "./DashboardSidebarPortBadge"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortGroup/DashboardSidebarPortGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortGroup/DashboardSidebarPortGroup.tsx new file mode 100644 index 00000000000..958d0ff2c69 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortGroup/DashboardSidebarPortGroup.tsx @@ -0,0 +1,80 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { useNavigate } from "@tanstack/react-router"; +import { LuLoaderCircle, LuX } from "react-icons/lu"; +import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { STROKE_WIDTH } from "renderer/screens/main/components/WorkspaceSidebar/constants"; +import { useDashboardSidebarPortKill } from "../../hooks/useDashboardSidebarPortKill"; +import type { DashboardSidebarPortGroup as DashboardSidebarPortGroupType } from "../../hooks/useDashboardSidebarPortsData"; +import { DashboardSidebarPortBadge } from "../DashboardSidebarPortBadge"; + +interface DashboardSidebarPortGroupProps { + group: DashboardSidebarPortGroupType; +} + +export function DashboardSidebarPortGroup({ + group, +}: DashboardSidebarPortGroupProps) { + const navigate = useNavigate(); + const { isPending, killPorts } = useDashboardSidebarPortKill(); + + const handleWorkspaceClick = () => { + void navigateToV2Workspace(group.workspaceId, navigate); + }; + + const handleCloseAll = () => { + if (isPending) return; + void killPorts(group.ports); + }; + + return ( +
+
+ + + {group.hostType === "local-device" ? "Local" : "Remote"} + + + + + + +

Close all ports

+
+
+
+
+ {group.ports.map((port) => ( + + ))} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortGroup/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortGroup/index.ts new file mode 100644 index 00000000000..9ba80d10775 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortGroup/index.ts @@ -0,0 +1 @@ +export { DashboardSidebarPortGroup } from "./DashboardSidebarPortGroup"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortKill/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortKill/index.ts new file mode 100644 index 00000000000..db4316db7ad --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortKill/index.ts @@ -0,0 +1 @@ +export { useDashboardSidebarPortKill } from "./useDashboardSidebarPortKill"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortKill/useDashboardSidebarPortKill.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortKill/useDashboardSidebarPortKill.ts new file mode 100644 index 00000000000..cf06d656bd8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortKill/useDashboardSidebarPortKill.ts @@ -0,0 +1,10 @@ +import { usePortKillActions } from "renderer/hooks/ports/usePortKillActions"; +import type { DashboardSidebarPort } from "../useDashboardSidebarPortsData"; + +const HOST_PORTS_QUERY_PREFIX = ["host-service", "ports", "getAll"] as const; + +export function useDashboardSidebarPortKill() { + return usePortKillActions({ + refreshQueryKey: HOST_PORTS_QUERY_PREFIX, + }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/index.ts new file mode 100644 index 00000000000..5c46ecffe6a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/index.ts @@ -0,0 +1,5 @@ +export { + type DashboardSidebarPort, + type DashboardSidebarPortGroup, + useDashboardSidebarPortsData, +} from "./useDashboardSidebarPortsData"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.test.ts new file mode 100644 index 00000000000..5c16040b339 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from "bun:test"; +import type { PortChangedPayload } from "@superset/workspace-client"; +import { + applyPortEventsToHostPortsResult, + deriveHostPortQueryTargets, + groupDashboardSidebarPorts, + type HostPortsResult, +} from "./useDashboardSidebarPortsData.utils"; + +function createResult(): HostPortsResult { + return { + hostId: "host-1", + hostType: "local-device", + hostUrl: "http://localhost:4567", + ports: [ + { + port: 5173, + pid: 123, + processName: "node", + terminalId: "terminal-1", + workspaceId: "workspace-1", + detectedAt: 1, + address: "127.0.0.1", + label: "Frontend", + }, + ], + }; +} + +function createPortEvent( + eventType: PortChangedPayload["eventType"], + overrides: Partial = {}, +): PortChangedPayload { + return { + eventType, + label: "Vite", + occurredAt: 2, + port: { + port: 5173, + pid: 456, + processName: "vite", + terminalId: "terminal-1", + workspaceId: "workspace-1", + detectedAt: 2, + address: "0.0.0.0", + ...overrides, + }, + }; +} + +describe("applyPortEventsToHostPortsResult", () => { + it("applies a remove/add update as a single final port row", () => { + const result = applyPortEventsToHostPortsResult(createResult(), [ + createPortEvent("remove", { pid: 123, processName: "node" }), + createPortEvent("add"), + ]); + + expect(result?.ports).toHaveLength(1); + expect(result?.ports[0]).toMatchObject({ + port: 5173, + pid: 456, + processName: "vite", + address: "0.0.0.0", + label: "Vite", + }); + }); + + it("keeps the same cache object for a remove event that does not match", () => { + const initial = createResult(); + const result = applyPortEventsToHostPortsResult(initial, [ + createPortEvent("remove", { port: 3000 }), + ]); + + expect(result).toBe(initial); + }); + + it("creates an initial host result when an add event arrives before the snapshot", () => { + const result = applyPortEventsToHostPortsResult( + undefined, + [ + createPortEvent("add", { + port: 4000, + pid: 999, + processName: "newproc", + }), + ], + { + hostId: "host-1", + hostType: "local-device", + hostUrl: "http://localhost:4567", + }, + ); + + expect(result).toMatchObject({ + hostId: "host-1", + hostType: "local-device", + hostUrl: "http://localhost:4567", + }); + expect(result?.ports).toHaveLength(1); + expect(result?.ports[0]).toMatchObject({ + port: 4000, + pid: 999, + processName: "newproc", + address: "0.0.0.0", + label: "Vite", + }); + }); + + it("does not create an initial host result for a remove-only event", () => { + const result = applyPortEventsToHostPortsResult( + undefined, + [createPortEvent("remove")], + { + hostId: "host-1", + hostType: "local-device", + hostUrl: "http://localhost:4567", + }, + ); + + expect(result).toBeUndefined(); + }); + + it("appends a new add event to an existing snapshot", () => { + const result = applyPortEventsToHostPortsResult(createResult(), [ + createPortEvent("add", { port: 4000, pid: 999, processName: "newproc" }), + ]); + + expect(result?.ports).toHaveLength(2); + expect(result?.ports.find((port) => port.port === 4000)).toMatchObject({ + port: 4000, + pid: 999, + processName: "newproc", + label: "Vite", + }); + }); + + it("replaces an existing row on add for the same terminal port", () => { + const result = applyPortEventsToHostPortsResult(createResult(), [ + createPortEvent("add", { pid: 999, processName: "newproc" }), + ]); + + expect(result?.ports).toHaveLength(1); + expect(result?.ports[0]).toMatchObject({ + port: 5173, + pid: 999, + processName: "newproc", + label: "Vite", + }); + }); +}); + +describe("deriveHostPortQueryTargets", () => { + it("groups workspace ids by host, sorts them, and resolves local/remote host urls", () => { + const targets = deriveHostPortQueryTargets({ + activeHostUrl: "http://127.0.0.1:4567", + hosts: [ + { id: "remote-host", isOnline: true, machineId: "remote-machine" }, + { id: "local-host", isOnline: true, machineId: "local-machine" }, + ], + machineId: "local-machine", + relayUrl: "https://relay.example.com", + workspaces: [ + { + id: "workspace-b", + name: "Workspace B", + hostId: "local-host", + hostMachineId: "local-machine", + }, + { + id: "workspace-a", + name: "Workspace A", + hostId: "local-host", + hostMachineId: "local-machine", + }, + { + id: "workspace-c", + name: "Workspace C", + hostId: "remote-host", + hostMachineId: "remote-machine", + }, + ], + }); + + expect(targets).toEqual([ + { + id: "remote-host", + hostType: "remote-device", + hostUrl: "https://relay.example.com/hosts/remote-host", + workspaceIds: ["workspace-c"], + }, + { + id: "local-host", + hostType: "local-device", + hostUrl: "http://127.0.0.1:4567", + workspaceIds: ["workspace-a", "workspace-b"], + }, + ]); + }); + + it("skips offline remote hosts and local hosts without an active URL", () => { + const targets = deriveHostPortQueryTargets({ + activeHostUrl: null, + hosts: [ + { id: "offline-host", isOnline: false, machineId: "remote-machine" }, + { id: "local-host", isOnline: true, machineId: "local-machine" }, + ], + machineId: "local-machine", + relayUrl: "https://relay.example.com", + workspaces: [ + { + id: "workspace-remote", + name: "Remote", + hostId: "offline-host", + hostMachineId: "remote-machine", + }, + { + id: "workspace-local", + name: "Local", + hostId: "local-host", + hostMachineId: "local-machine", + }, + ], + }); + + expect(targets).toEqual([]); + }); +}); + +describe("groupDashboardSidebarPorts", () => { + it("groups ports by workspace and sorts workspaces and ports", () => { + const groups = groupDashboardSidebarPorts({ + hostPortResults: [ + { + hostId: "host-1", + hostType: "local-device", + hostUrl: "http://127.0.0.1:4567", + ports: [ + { + port: 5173, + pid: 100, + processName: "vite", + terminalId: "terminal-1", + workspaceId: "workspace-b", + detectedAt: 1, + address: "127.0.0.1", + label: "Frontend", + }, + { + port: 3000, + pid: 101, + processName: "next", + terminalId: "terminal-2", + workspaceId: "workspace-b", + detectedAt: 1, + address: "127.0.0.1", + label: "Web", + }, + { + port: 8080, + pid: 102, + processName: "api", + terminalId: "terminal-3", + workspaceId: "workspace-a", + detectedAt: 1, + address: "127.0.0.1", + label: "API", + }, + ], + }, + ], + machineId: "machine-1", + workspaces: [ + { + id: "workspace-b", + name: "Beta", + hostId: "host-1", + hostMachineId: "machine-1", + }, + { + id: "workspace-a", + name: "Alpha", + hostId: "host-1", + hostMachineId: "machine-1", + }, + ], + }); + + expect(groups.map((group) => group.workspaceName)).toEqual([ + "Alpha", + "Beta", + ]); + expect(groups[1]?.ports.map((port) => port.port)).toEqual([3000, 5173]); + expect(groups[0]?.hostType).toBe("local-device"); + }); + + it("drops ports whose workspace belongs to another host", () => { + const groups = groupDashboardSidebarPorts({ + hostPortResults: [ + { + hostId: "host-1", + hostType: "remote-device", + hostUrl: "https://relay.example.com/hosts/host-1", + ports: [ + { + port: 5173, + pid: 100, + processName: "vite", + terminalId: "terminal-1", + workspaceId: "workspace-1", + detectedAt: 1, + address: "127.0.0.1", + label: "Frontend", + }, + ], + }, + ], + machineId: "machine-1", + workspaces: [ + { + id: "workspace-1", + name: "Workspace", + hostId: "host-2", + hostMachineId: "machine-2", + }, + ], + }); + + expect(groups).toEqual([]); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.ts new file mode 100644 index 00000000000..2af64cf4899 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.ts @@ -0,0 +1,193 @@ +import { + getEventBus, + type PortChangedPayload, +} from "@superset/workspace-client"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useQueries, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo } from "react"; +import { env } from "renderer/env.renderer"; +import { getHostServiceWsToken } from "renderer/lib/host-service-auth"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + applyPortEventsToHostPortsResult, + type DashboardSidebarPortGroup, + type DashboardSidebarPortsLoadError, + deriveHostPortQueryTargets, + getHostPortsQueryKey, + groupDashboardSidebarPorts, + type HostPortsResult, +} from "./useDashboardSidebarPortsData.utils"; + +export type { + DashboardSidebarPort, + DashboardSidebarPortGroup, +} from "./useDashboardSidebarPortsData.utils"; + +const PORTS_FALLBACK_REFETCH_INTERVAL_MS = 30_000; +const PORT_EVENT_CACHE_BATCH_DELAY_MS = 100; + +export function useDashboardSidebarPortsData(): { + workspacePortGroups: DashboardSidebarPortGroup[]; + totalPortCount: number; + portLoadErrors: DashboardSidebarPortsLoadError[]; +} { + const collections = useCollections(); + const queryClient = useQueryClient(); + const { activeHostUrl, machineId } = useLocalHostService(); + + const { data: hosts = [] } = useLiveQuery( + (q) => + q.from({ hosts: collections.v2Hosts }).select(({ hosts }) => ({ + id: hosts.id, + isOnline: hosts.isOnline, + machineId: hosts.machineId, + })), + [collections], + ); + + const { data: workspaces = [] } = useLiveQuery( + (q) => + q + .from({ workspaces: collections.v2Workspaces }) + .leftJoin({ hosts: collections.v2Hosts }, ({ workspaces, hosts }) => + eq(workspaces.hostId, hosts.id), + ) + .select(({ workspaces, hosts }) => ({ + id: workspaces.id, + name: workspaces.name, + hostId: workspaces.hostId, + hostMachineId: hosts?.machineId ?? null, + })), + [collections], + ); + + const hostsToQuery = useMemo( + () => + deriveHostPortQueryTargets({ + activeHostUrl, + hosts, + machineId, + relayUrl: env.RELAY_URL, + workspaces, + }), + [activeHostUrl, hosts, machineId, workspaces], + ); + + const queries = useQueries({ + queries: hostsToQuery.map((host) => ({ + queryKey: getHostPortsQueryKey(host), + refetchInterval: PORTS_FALLBACK_REFETCH_INTERVAL_MS, + queryFn: async (): Promise => { + const client = getHostServiceClientByUrl(host.hostUrl); + const ports = await client.ports.getAll.query({ + workspaceIds: host.workspaceIds, + }); + return { + hostId: host.id, + hostType: host.hostType, + hostUrl: host.hostUrl, + ports, + }; + }, + })), + }); + + useEffect(() => { + const cleanups: Array<() => void> = []; + + for (const host of hostsToQuery) { + const workspaceIds = new Set(host.workspaceIds); + const pendingEvents: PortChangedPayload[] = []; + let cacheUpdateTimer: ReturnType | null = null; + const flushPortEvents = () => { + cacheUpdateTimer = null; + const events = pendingEvents.splice(0); + if (events.length === 0) return; + queryClient.setQueryData( + getHostPortsQueryKey(host), + (result) => + applyPortEventsToHostPortsResult(result, events, { + hostId: host.id, + hostType: host.hostType, + hostUrl: host.hostUrl, + }), + ); + }; + const enqueuePortEvent = (event: PortChangedPayload) => { + pendingEvents.push(event); + if (cacheUpdateTimer) return; + cacheUpdateTimer = setTimeout( + flushPortEvents, + PORT_EVENT_CACHE_BATCH_DELAY_MS, + ); + }; + const bus = getEventBus(host.hostUrl, () => + getHostServiceWsToken(host.hostUrl), + ); + const removeListener = bus.on( + "port:changed", + "*", + (workspaceId, event) => { + if (!workspaceIds.has(workspaceId)) return; + enqueuePortEvent(event); + }, + ); + const releaseBus = bus.retain(); + cleanups.push(() => { + if (cacheUpdateTimer) { + clearTimeout(cacheUpdateTimer); + cacheUpdateTimer = null; + } + flushPortEvents(); + removeListener(); + releaseBus(); + }); + } + + return () => { + for (const cleanup of cleanups) { + cleanup(); + } + }; + }, [hostsToQuery, queryClient]); + + const workspacePortGroups = useMemo( + () => + groupDashboardSidebarPorts({ + hostPortResults: queries.map((query) => query.data), + machineId, + workspaces, + }), + [queries, machineId, workspaces], + ); + + const totalPortCount = workspacePortGroups.reduce( + (sum, group) => sum + group.ports.length, + 0, + ); + + const portLoadErrors = queries.flatMap((query, index) => { + if (!query.isError && !query.isRefetchError) return []; + const host = hostsToQuery[index]; + if (!host) return []; + return [ + { + hostId: host.id, + hostType: host.hostType, + message: + query.error instanceof Error + ? query.error.message + : "Unable to load ports", + }, + ]; + }); + + return { + workspacePortGroups, + totalPortCount, + portLoadErrors, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.utils.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.utils.ts new file mode 100644 index 00000000000..3fbf9df26d8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.utils.ts @@ -0,0 +1,225 @@ +import type { PortChangedPayload } from "@superset/workspace-client"; +import type { DetectedPort } from "shared/types"; +import type { DashboardSidebarWorkspaceHostType } from "../../../../types"; + +export interface DashboardSidebarPort extends RemotePort { + hostId: string; + hostType: DashboardSidebarWorkspaceHostType; + hostUrl: string; +} + +interface RemotePort extends DetectedPort { + label: string | null; +} + +export interface DashboardSidebarPortGroup { + workspaceId: string; + workspaceName: string; + hostType: DashboardSidebarWorkspaceHostType; + ports: DashboardSidebarPort[]; +} + +export interface DashboardSidebarPortsLoadError { + hostId: string; + hostType: DashboardSidebarWorkspaceHostType; + message: string; +} + +export interface HostPortsResult { + hostId: string; + hostType: DashboardSidebarWorkspaceHostType; + hostUrl: string; + ports: RemotePort[]; +} + +type HostPortsMetadata = Pick< + HostPortsResult, + "hostId" | "hostType" | "hostUrl" +>; + +export interface HostPortsQueryTarget { + id: string; + hostType: DashboardSidebarWorkspaceHostType; + hostUrl: string; + workspaceIds: string[]; +} + +export interface DashboardSidebarHostRow { + id: string; + isOnline: boolean; + machineId: string | null | undefined; +} + +export interface DashboardSidebarWorkspaceRow { + id: string; + name: string; + hostId: string; + hostMachineId: string | null | undefined; +} + +export function getHostPortsQueryKey(host: HostPortsQueryTarget) { + return [ + "host-service", + "ports", + "getAll", + host.id, + host.hostUrl, + host.workspaceIds, + ] as const; +} + +function getPortCacheKey( + port: Pick, +): string { + return `${port.workspaceId}:${port.terminalId}:${port.port}`; +} + +export function applyPortEventsToHostPortsResult( + result: HostPortsResult | undefined, + events: PortChangedPayload[], + host?: HostPortsMetadata, +): HostPortsResult | undefined { + if (events.length === 0) return result; + + const initialResult = + result ?? + (events.some((event) => event.eventType === "add") && host + ? { ...host, ports: [] } + : undefined); + if (!initialResult) return result; + + let ports = initialResult.ports; + let changed = initialResult !== result; + + for (const event of events) { + const eventPortKey = getPortCacheKey(event.port); + const portsWithoutEventPort = ports.filter( + (port) => getPortCacheKey(port) !== eventPortKey, + ); + if (portsWithoutEventPort.length !== ports.length) { + changed = true; + } + + if (event.eventType === "add") { + ports = [...portsWithoutEventPort, { ...event.port, label: event.label }]; + changed = true; + } else { + ports = portsWithoutEventPort; + } + } + + if (!changed) return result; + return { ...initialResult, ports }; +} + +export function deriveHostPortQueryTargets({ + activeHostUrl, + hosts, + machineId, + relayUrl, + workspaces, +}: { + activeHostUrl: string | null; + hosts: DashboardSidebarHostRow[]; + machineId: string | null; + relayUrl: string; + workspaces: DashboardSidebarWorkspaceRow[]; +}): HostPortsQueryTarget[] { + const workspaceIdsByHostId = new Map(); + for (const workspace of workspaces) { + const existing = workspaceIdsByHostId.get(workspace.hostId); + if (existing) { + existing.push(workspace.id); + } else { + workspaceIdsByHostId.set(workspace.hostId, [workspace.id]); + } + } + for (const workspaceIds of workspaceIdsByHostId.values()) { + workspaceIds.sort(); + } + + return hosts.flatMap((host) => { + const workspaceIds = workspaceIdsByHostId.get(host.id); + if (!workspaceIds || workspaceIds.length === 0) return []; + + const isLocal = host.machineId === machineId; + if (!isLocal && !host.isOnline) return []; + + const hostUrl = isLocal ? activeHostUrl : `${relayUrl}/hosts/${host.id}`; + if (!hostUrl) return []; + + return [ + { + id: host.id, + hostType: isLocal + ? ("local-device" as const) + : ("remote-device" as const), + hostUrl, + workspaceIds, + }, + ]; + }); +} + +export function groupDashboardSidebarPorts({ + hostPortResults, + machineId, + workspaces, +}: { + hostPortResults: Array; + machineId: string | null; + workspaces: DashboardSidebarWorkspaceRow[]; +}): DashboardSidebarPortGroup[] { + const workspacesById = new Map( + workspaces.map((workspace) => [ + workspace.id, + { + name: workspace.name, + hostId: workspace.hostId, + hostType: + workspace.hostMachineId == null + ? ("cloud" as const) + : workspace.hostMachineId === machineId + ? ("local-device" as const) + : ("remote-device" as const), + }, + ]), + ); + const groupMap = new Map(); + + for (const result of hostPortResults) { + if (!result) continue; + + for (const port of result.ports) { + const workspace = workspacesById.get(port.workspaceId); + if (!workspace) continue; + if (workspace.hostId !== result.hostId) continue; + + const dashboardPort: DashboardSidebarPort = { + ...port, + hostId: result.hostId, + hostType: result.hostType, + hostUrl: result.hostUrl, + }; + + const existing = groupMap.get(port.workspaceId); + if (existing) { + existing.ports.push(dashboardPort); + } else { + groupMap.set(port.workspaceId, { + workspaceId: port.workspaceId, + workspaceName: workspace.name, + hostType: workspace.hostType, + ports: [dashboardPort], + }); + } + } + } + + return Array.from(groupMap.values()) + .map((group) => ({ + ...group, + ports: group.ports.sort((a, b) => a.port - b.port), + })) + .sort((a, b) => a.workspaceName.localeCompare(b.workspaceName)); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/index.ts new file mode 100644 index 00000000000..b231559d058 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/index.ts @@ -0,0 +1 @@ +export { DashboardSidebarPortsList } from "./DashboardSidebarPortsList"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts index d9d8fb99ebe..4e60faf7993 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts @@ -11,6 +11,15 @@ export interface WorkspaceSearchParams { column?: number; } +export interface V2WorkspaceSearchParams { + terminalId?: string; + chatSessionId?: string; + focusRequestId?: string; + openUrl?: string; + openUrlTarget?: "current-tab" | "new-tab"; + openUrlRequestId?: string; +} + /** * Navigate to a workspace and update localStorage to remember it as the last viewed workspace. * This ensures the workspace will be restored when the app is reopened. @@ -42,9 +51,15 @@ export function navigateToWorkspace( export function navigateToV2Workspace( workspaceId: string, navigate: UseNavigateResult, + options?: Omit & { + search?: V2WorkspaceSearchParams; + }, ): Promise { + const { search, ...rest } = options ?? {}; return navigate({ to: "/v2-workspace/$workspaceId", params: { workspaceId }, + search: search ?? {}, + ...rest, }); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/index.ts new file mode 100644 index 00000000000..96cf0e45780 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/index.ts @@ -0,0 +1,4 @@ +export { + getOpenUrlRequestConsumeKey, + useConsumeOpenUrlRequest, +} from "./useConsumeOpenUrlRequest"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/useConsumeOpenUrlRequest.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/useConsumeOpenUrlRequest.test.ts new file mode 100644 index 00000000000..f5570aa1aed --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/useConsumeOpenUrlRequest.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; +import { getOpenUrlRequestConsumeKey } from "./useConsumeOpenUrlRequest"; + +describe("getOpenUrlRequestConsumeKey", () => { + it("dedupes repeated URL open requests without a request id", () => { + expect( + getOpenUrlRequestConsumeKey({ + url: "http://localhost:3000", + target: "current-tab", + requestId: undefined, + }), + ).toBe("current-tab:http://localhost:3000"); + }); + + it("treats each request id as a fresh URL open command", () => { + expect( + getOpenUrlRequestConsumeKey({ + url: "http://localhost:3000", + target: "new-tab", + requestId: "request-1", + }), + ).toBe("new-tab:http://localhost:3000:request:request-1"); + expect( + getOpenUrlRequestConsumeKey({ + url: "http://localhost:3000", + target: "new-tab", + requestId: "request-2", + }), + ).toBe("new-tab:http://localhost:3000:request:request-2"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/useConsumeOpenUrlRequest.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/useConsumeOpenUrlRequest.ts new file mode 100644 index 00000000000..a6bb4db30df --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/useConsumeOpenUrlRequest.ts @@ -0,0 +1,51 @@ +import type { WorkspaceStore } from "@superset/panes"; +import { useEffect, useRef } from "react"; +import type { StoreApi } from "zustand/vanilla"; +import type { PaneViewerData } from "../../types"; +import { + openUrlInV2Workspace, + type V2WorkspaceUrlOpenTarget, +} from "../../utils/openUrlInV2Workspace"; + +interface UseConsumeOpenUrlRequestArgs { + store: StoreApi>; + url: string | undefined; + target: V2WorkspaceUrlOpenTarget | undefined; + requestId: string | undefined; +} + +export function useConsumeOpenUrlRequest({ + store, + url, + target, + requestId, +}: UseConsumeOpenUrlRequestArgs): void { + const consumedRef = useRef>(new Set()); + + useEffect(() => { + if (!url) return; + const resolvedTarget = target ?? "current-tab"; + const key = getOpenUrlRequestConsumeKey({ + url, + target: resolvedTarget, + requestId, + }); + if (consumedRef.current.has(key)) return; + consumedRef.current.add(key); + openUrlInV2Workspace({ store, target: resolvedTarget, url }); + }, [store, target, url, requestId]); +} + +export function getOpenUrlRequestConsumeKey({ + url, + target, + requestId, +}: { + url: string; + target: V2WorkspaceUrlOpenTarget; + requestId: string | undefined; +}): string { + return requestId + ? `${target}:${url}:request:${requestId}` + : `${target}:${url}`; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 6b8ce9b3f55..620fce80328 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -21,10 +21,10 @@ import { import { electronTrpcClient } from "renderer/lib/trpc-client"; import { useOpenInExternalEditor } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor"; import type { - BrowserPaneData, PaneViewerData, TerminalPaneData, } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { openUrlInV2Workspace } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace"; import { useWorkspaceWsUrl } from "renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; import { ScrollToBottomButton } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/ScrollToBottomButton"; import { TerminalSearch } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalSearch"; @@ -288,11 +288,10 @@ export function TerminalPane({ console.error("[v2 Terminal] Failed to open URL:", url, error); }); } else { - ctx.store.getState().openPane({ - pane: { - kind: "browser", - data: { url, mode: "generic" } satisfies BrowserPaneData, - }, + openUrlInV2Workspace({ + store: ctx.store, + target: "current-tab", + url, }); } }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 889eb771bfb..086ffcab8da 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -52,6 +52,7 @@ import { V2PresetsBar } from "./components/V2PresetsBar"; import { WorkspaceEmptyState } from "./components/WorkspaceEmptyState"; import { WorkspaceSidebar } from "./components/WorkspaceSidebar"; import { useConsumeAutomationRunLink } from "./hooks/useConsumeAutomationRunLink"; +import { useConsumeOpenUrlRequest } from "./hooks/useConsumeOpenUrlRequest"; import { useConsumePendingLaunch } from "./hooks/useConsumePendingLaunch"; import { useDefaultContextMenuActions } from "./hooks/useDefaultContextMenuActions"; import { usePaneRegistry } from "./hooks/usePaneRegistry"; @@ -73,11 +74,26 @@ import type { PaneViewerData, TerminalPaneData, } from "./types"; +import type { V2WorkspaceUrlOpenTarget } from "./utils/openUrlInV2Workspace"; interface WorkspaceSearch { terminalId?: string; chatSessionId?: string; focusRequestId?: string; + openUrl?: string; + openUrlTarget?: V2WorkspaceUrlOpenTarget; + openUrlRequestId?: string; +} + +function parseOpenUrlTarget( + value: unknown, +): V2WorkspaceUrlOpenTarget | undefined { + if (value === "current-tab" || value === "new-tab") return value; + return undefined; +} + +function parseNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; } export const Route = createFileRoute( @@ -85,11 +101,12 @@ export const Route = createFileRoute( )({ component: V2WorkspacePage, validateSearch: (raw: Record): WorkspaceSearch => ({ - terminalId: typeof raw.terminalId === "string" ? raw.terminalId : undefined, - chatSessionId: - typeof raw.chatSessionId === "string" ? raw.chatSessionId : undefined, - focusRequestId: - typeof raw.focusRequestId === "string" ? raw.focusRequestId : undefined, + terminalId: parseNonEmptyString(raw.terminalId), + chatSessionId: parseNonEmptyString(raw.chatSessionId), + focusRequestId: parseNonEmptyString(raw.focusRequestId), + openUrl: parseNonEmptyString(raw.openUrl), + openUrlTarget: parseOpenUrlTarget(raw.openUrlTarget), + openUrlRequestId: parseNonEmptyString(raw.openUrlRequestId), }), }); @@ -130,7 +147,14 @@ function getNodeAtPathInLayout( function V2WorkspacePage() { const { workspaceId } = Route.useParams(); - const { terminalId, chatSessionId, focusRequestId } = Route.useSearch(); + const { + terminalId, + chatSessionId, + focusRequestId, + openUrl, + openUrlTarget, + openUrlRequestId, + } = Route.useSearch(); const collections = useCollections(); const { data: workspaces } = useLiveQuery( @@ -158,6 +182,9 @@ function V2WorkspacePage() { terminalId={terminalId} chatSessionId={chatSessionId} focusRequestId={focusRequestId} + openUrl={openUrl} + openUrlTarget={openUrlTarget} + openUrlRequestId={openUrlRequestId} /> ); } @@ -200,6 +227,9 @@ function WorkspaceContent({ terminalId, chatSessionId, focusRequestId, + openUrl, + openUrlTarget, + openUrlRequestId, }: { projectId: string; workspaceId: string; @@ -207,6 +237,9 @@ function WorkspaceContent({ terminalId?: string; chatSessionId?: string; focusRequestId?: string; + openUrl?: string; + openUrlTarget?: V2WorkspaceUrlOpenTarget; + openUrlRequestId?: string; }) { const navigate = useNavigate(); const { localWorkspaceState, store } = useV2WorkspacePaneLayout({ @@ -247,6 +280,12 @@ function WorkspaceContent({ }, }, ); + useConsumeOpenUrlRequest({ + store, + url: openUrl, + target: openUrlTarget, + requestId: openUrlRequestId, + }); const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ id: workspaceId, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/getSidebarClickIntent.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/getSidebarClickIntent.test.ts new file mode 100644 index 00000000000..6d5e0ce876b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/getSidebarClickIntent.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "bun:test"; +import { + getOpenTargetClickIntent, + getSidebarClickIntent, + type ModifierClickEvent, +} from "./getSidebarClickIntent"; + +function event(init: Partial = {}): ModifierClickEvent { + return { + ctrlKey: init.ctrlKey ?? false, + metaKey: init.metaKey ?? false, + shiftKey: init.shiftKey ?? false, + }; +} + +describe("getOpenTargetClickIntent", () => { + it("maps plain, shift, and mod clicks to shared open targets", () => { + expect(getOpenTargetClickIntent(event())).toBe("openInCurrentTab"); + expect(getOpenTargetClickIntent(event({ shiftKey: true }))).toBe( + "openInNewTab", + ); + expect(getOpenTargetClickIntent(event({ metaKey: true }))).toBe( + "openExternally", + ); + expect(getOpenTargetClickIntent(event({ ctrlKey: true }))).toBe( + "openExternally", + ); + }); +}); + +describe("getSidebarClickIntent", () => { + it("preserves file-sidebar labels over the shared open targets", () => { + expect(getSidebarClickIntent(event())).toBe("select"); + expect(getSidebarClickIntent(event({ shiftKey: true }))).toBe( + "openInNewTab", + ); + expect(getSidebarClickIntent(event({ metaKey: true }))).toBe( + "openInEditor", + ); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/getSidebarClickIntent.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/getSidebarClickIntent.ts index 28a612e9b62..ce8e2874a13 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/getSidebarClickIntent.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/getSidebarClickIntent.ts @@ -1,11 +1,29 @@ -import type { MouseEvent } from "react"; - export type SidebarClickIntent = "openInEditor" | "openInNewTab" | "select"; +export type OpenTargetClickIntent = + | "openExternally" + | "openInNewTab" + | "openInCurrentTab"; + +export interface ModifierClickEvent { + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; +} + +export function getOpenTargetClickIntent( + e: ModifierClickEvent, +): OpenTargetClickIntent { + if (e.metaKey || e.ctrlKey) return "openExternally"; + if (e.shiftKey) return "openInNewTab"; + return "openInCurrentTab"; +} + export function getSidebarClickIntent( - e: MouseEvent, + e: ModifierClickEvent, ): SidebarClickIntent { - if (e.metaKey || e.ctrlKey) return "openInEditor"; - if (e.shiftKey) return "openInNewTab"; + const intent = getOpenTargetClickIntent(e); + if (intent === "openExternally") return "openInEditor"; + if (intent === "openInNewTab") return "openInNewTab"; return "select"; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/index.ts index f8fc7dcefe5..f3b7308dcc4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/index.ts @@ -1,4 +1,7 @@ export { + getOpenTargetClickIntent, getSidebarClickIntent, + type ModifierClickEvent, + type OpenTargetClickIntent, type SidebarClickIntent, } from "./getSidebarClickIntent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace/index.ts new file mode 100644 index 00000000000..a4da2deb659 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace/index.ts @@ -0,0 +1,4 @@ +export { + openUrlInV2Workspace, + type V2WorkspaceUrlOpenTarget, +} from "./openUrlInV2Workspace"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace/openUrlInV2Workspace.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace/openUrlInV2Workspace.ts new file mode 100644 index 00000000000..7a3b834a02a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace/openUrlInV2Workspace.ts @@ -0,0 +1,30 @@ +import type { WorkspaceStore } from "@superset/panes"; +import type { StoreApi } from "zustand/vanilla"; +import type { BrowserPaneData, PaneViewerData } from "../../types"; + +export type V2WorkspaceUrlOpenTarget = "current-tab" | "new-tab"; + +export function openUrlInV2Workspace({ + store, + target, + url, +}: { + store: StoreApi>; + target: V2WorkspaceUrlOpenTarget; + url: string; +}): void { + const pane = { + kind: "browser", + // FORK NOTE: BrowserPaneData has a required `mode` field for v2 ("docs" | + // "preview" | "generic"). Default to "generic" for unknown URLs. + data: { url, mode: "generic" } satisfies BrowserPaneData, + }; + const state = store.getState(); + + if (target === "new-tab") { + state.addTab({ panes: [pane] }); + return; + } + + state.openPane({ pane }); +} 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 08c4e5730da..641185a8723 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 @@ -123,7 +123,7 @@ export function PortsList() { -

Learn about static port configuration

+

Learn about port labels

diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx index 0c787b53ab8..d7baf20a797 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx @@ -1,6 +1,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; import { useNavigate } from "@tanstack/react-router"; -import { LuExternalLink, LuX } from "react-icons/lu"; +import { LuExternalLink, LuLoaderCircle, LuX } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -14,43 +15,25 @@ interface MergedPortBadgeProps { export function MergedPortBadge({ port }: MergedPortBadgeProps) { const navigate = useNavigate(); - const setActiveTab = useTabsStore((s) => s.setActiveTab); - const setFocusedPane = useTabsStore((s) => s.setFocusedPane); const openInBrowserPane = useTabsStore((s) => s.openInBrowserPane); const { data: openLinksInApp } = electronTrpc.settings.getOpenLinksInApp.useQuery(); const openUrl = electronTrpc.external.openUrl.useMutation(); - const { killPort } = useKillPort(); + const { isPending, killPort } = useKillPort(); + // FORK NOTE: v1 PortsList lists both detected and configured-but-undetected + // static ports. `isDetected` toggles colour scheme and gates kill/open + // affordances. `canJumpToTerminal` mirrors the legacy paneId hint, now + // renamed to terminalId after the upstream port-scanner extraction. const isDetected = port.detected; - - const displayContent = port.label ? ( - <> - {port.label}{" "} - - {port.port} - - - ) : ( - {port.port} - ); - - const canJumpToTerminal = !!port.paneId; + const canJumpToTerminal = !!port.terminalId; const handleClick = () => { - if (!port.paneId) return; - - const pane = useTabsStore.getState().panes[port.paneId]; - if (!pane) return; - navigateToWorkspace(port.workspaceId, navigate); - setActiveTab(port.workspaceId, pane.tabId); - setFocusedPane(pane.tabId, port.paneId); }; const handleOpenInBrowser = () => { + if (openUrl.isPending) return; const url = `http://localhost:${port.port}`; if (openLinksInApp) { @@ -63,34 +46,48 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { }; const handleClose = () => { - killPort(port); + if (isPending) return; + void killPort(port); }; return (
{isDetected && ( <> )} @@ -124,12 +130,12 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) {
)} {!isDetected && ( -
+
Not detected
)} {canJumpToTerminal && ( -
+
Click to open workspace
)} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx index 198cc5720ea..52243e690af 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx @@ -1,6 +1,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; import { useNavigate } from "@tanstack/react-router"; -import { LuX } from "react-icons/lu"; +import { LuLoaderCircle, LuX } from "react-icons/lu"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { STROKE_WIDTH } from "../../../constants"; import { useKillPort } from "../../hooks/useKillPort"; @@ -13,7 +14,7 @@ interface WorkspacePortGroupProps { export function WorkspacePortGroup({ group }: WorkspacePortGroupProps) { const navigate = useNavigate(); - const { killPorts } = useKillPort(); + const { isPending, killPorts } = useKillPort(); const handleWorkspaceClick = () => { navigateToWorkspace(group.workspaceId, navigate); @@ -22,7 +23,10 @@ export function WorkspacePortGroup({ group }: WorkspacePortGroupProps) { const detectedPorts = group.ports.filter((p) => p.detected); const handleCloseAll = () => { - killPorts(detectedPorts); + if (isPending) return; + // FORK NOTE: only kill detected ports — configured-but-undetected static + // labels are display-only. + void killPorts(detectedPorts); }; return ( @@ -40,9 +44,21 @@ export function WorkspacePortGroup({ group }: WorkspacePortGroupProps) { @@ -52,7 +68,10 @@ export function WorkspacePortGroup({ group }: WorkspacePortGroupProps) {
{group.ports.map((port) => ( - + ))}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts index ac3bc3fda91..c42545f3a39 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts @@ -2,13 +2,18 @@ import { toast } from "@superset/ui/sonner"; import { electronTrpc } from "renderer/lib/electron-trpc"; import type { EnrichedPort } from "shared/types"; +// FORK NOTE: v1 PortsList uses terminalId-based kill routing on EnrichedPort. +// upstream PR #3676 introduced `usePortKillActions` for v2 with terminalId + +// hostUrl routing, but the v1 schema (terminalId, no hostUrl on local Electron +// ports) is incompatible — keep the inline implementation until v1 is retired. export function useKillPort() { const killMutation = electronTrpc.ports.kill.useMutation(); const killPort = async (port: EnrichedPort) => { - if (!port.paneId) return; + if (!port.terminalId) return; const result = await killMutation.mutateAsync({ - paneId: port.paneId, + workspaceId: port.workspaceId, + terminalId: port.terminalId, port: port.port, }); if (!result.success) { @@ -19,13 +24,14 @@ export function useKillPort() { }; const killPorts = async (ports: EnrichedPort[]) => { - const killable = ports.filter((p) => p.paneId != null); + const killable = ports.filter((p) => p.terminalId != null); if (killable.length === 0) return; const results = await Promise.all( killable.map((port) => killMutation.mutateAsync({ - paneId: port.paneId as string, + workspaceId: port.workspaceId, + terminalId: port.terminalId as string, port: port.port, }), ), diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts index bfd75f24542..1f6f947cb32 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts @@ -52,13 +52,10 @@ export function usePortsData() { const utils = electronTrpc.useUtils(); - const { data: detectedPorts } = electronTrpc.ports.getAll.useQuery( - undefined, - { - // Keep a low-frequency safety net in case subscription events are missed. - refetchInterval: PORTS_FALLBACK_REFETCH_INTERVAL_MS, - }, - ); + const { data: localPorts } = electronTrpc.ports.getAll.useQuery(undefined, { + // Keep a low-frequency safety net in case subscription events are missed. + refetchInterval: PORTS_FALLBACK_REFETCH_INTERVAL_MS, + }); electronTrpc.ports.subscribe.useSubscription(undefined, { onData: () => { @@ -68,11 +65,11 @@ export function usePortsData() { const showConfiguredOnly = usePortsStore((s) => s.showConfiguredOnly); - const ports = useMemo(() => { - const all = detectedPorts ?? []; + const ports = useMemo(() => { + const all = localPorts ?? []; if (!showConfiguredOnly) return all; return all.filter((p) => p.label != null); - }, [detectedPorts, showConfiguredOnly]); + }, [localPorts, showConfiguredOnly]); const workspaceNames = useMemo(() => { if (!allWorkspaceGroups) return {}; diff --git a/apps/desktop/src/shared/types/ports.ts b/apps/desktop/src/shared/types/ports.ts index c676bb7ed6c..dee910e8337 100644 --- a/apps/desktop/src/shared/types/ports.ts +++ b/apps/desktop/src/shared/types/ports.ts @@ -1,12 +1,4 @@ -export interface DetectedPort { - port: number; - pid: number; - processName: string; - paneId: string; - workspaceId: string; - detectedAt: number; - address: string; -} +export type { DetectedPort } from "@superset/port-scanner"; export interface StaticPort { port: number; @@ -29,7 +21,12 @@ export interface EnrichedPort { /** Detection info — only present when `detected` is true. */ pid: number | null; processName: string | null; - paneId: string | null; + terminalId: string | null; detectedAt: number | null; address: string | null; + /** + * null → port belongs to the local Electron port manager. + * string → URL of the remote host-service that owns this port; kill routes there. + */ + hostUrl: string | null; } diff --git a/apps/docs/content/docs/ports.mdx b/apps/docs/content/docs/ports.mdx index 668b5e99498..3ec399859ed 100644 --- a/apps/docs/content/docs/ports.mdx +++ b/apps/docs/content/docs/ports.mdx @@ -14,14 +14,16 @@ Superset does not assign per-workspace port ranges. It discovers listening ports - **View active ports** - See which processes are using which ports - **Kill processes** - Stop a process by clicking its port - **Workspace grouping** - Ports are grouped by the workspace that owns the process +- **Terminal focus** - Select a port to jump back to the terminal that owns it +- **Browser actions** - Open local web servers in the in-app browser or externally from the port action -## Static Port Configuration +## Port Labels -Override automatic port discovery with a static configuration file. Useful for: +Add friendly names to automatically detected ports with a workspace +configuration file. Useful for: -- Documenting ports that aren't auto-detected (databases, external services) - Providing meaningful labels for your team -- Projects where dynamic scanning doesn't work well +- Making common dev-server ports easier to scan in the UI Create `.superset/ports.json` in your repository: @@ -40,18 +42,26 @@ Create `.superset/ports.json` in your repository: - `label` - Display text shown in tooltip **Behavior:** -- Static config replaces dynamic port discovery -- Each workspace reads from its own worktree's file +- Dynamic port discovery is still authoritative +- `ports.json` only labels ports that Superset already detects as listening +- Ports without a matching label still appear +- Label entries for ports that are not currently listening are ignored +- Each workspace reads labels from its own worktree's file - Changes are detected automatically -- Ports open `localhost:PORT` in browser when clicked +- Ports can be opened at `localhost:PORT` from the browser action **Error Handling:** If `ports.json` is malformed: -- Error toast appears with details -- No ports displayed until fixed -- Dynamic detection is NOT used as fallback +- Labels from that file are ignored until fixed +- Detected ports still appear without those labels **Tips:** - Commit `.superset/ports.json` to share port labels with your team - **Pro tip:** If you want deterministic per-workspace port ranges, implement it in setup/teardown scripts by reserving a range in a shared file (for example `~/.superset/port-allocations.json`) during setup and releasing it during teardown. See this repo's examples: [`.superset/setup.sh`](https://github.com/superset-sh/superset/blob/main/.superset/setup.sh) and [`.superset/teardown.sh`](https://github.com/superset-sh/superset/blob/main/.superset/teardown.sh). + +## Discovery and Updates + +Port discovery runs in each host service, not in the desktop renderer. The host service watches terminal process trees, scans for listening ports, resolves any matching port label, and publishes `port:changed` events when ports appear or disappear. + +The desktop sidebar keeps one ports query per online host. It patches that cached host snapshot from port events, batching bursts so updates stay responsive without refetching every time a port changes. A slower fallback refetch still runs so the UI recovers if an event is missed during reconnect. diff --git a/bun.lock b/bun.lock index 354dc9f95ab..16203a6586f 100644 --- a/bun.lock +++ b/bun.lock @@ -167,6 +167,7 @@ "@superset/macos-process-metrics": "workspace:*", "@superset/macos-window-blur": "workspace:*", "@superset/panes": "workspace:*", + "@superset/port-scanner": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", @@ -810,6 +811,7 @@ "@mastra/core": "1.26.0-alpha.3", "@octokit/rest": "^22.0.1", "@superset/chat": "workspace:*", + "@superset/port-scanner": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@superset/workspace-fs": "workspace:*", @@ -823,6 +825,7 @@ "node-pty": "1.1.0", "simple-git": "^3.30.0", "superjson": "^2.2.5", + "tree-kill": "^1.2.2", "zod": "^4.3.5", }, "devDependencies": { @@ -902,6 +905,18 @@ "react": "19.2.0", }, }, + "packages/port-scanner": { + "name": "@superset/port-scanner", + "version": "0.1.0", + "dependencies": { + "pidtree": "^0.6.0", + }, + "devDependencies": { + "@superset/typescript": "workspace:*", + "bun-types": "^1.3.1", + "typescript": "^5.9.3", + }, + }, "packages/shared": { "name": "@superset/shared", "version": "0.1.0", @@ -2698,6 +2713,8 @@ "@superset/panes": ["@superset/panes@workspace:packages/panes"], + "@superset/port-scanner": ["@superset/port-scanner@workspace:packages/port-scanner"], + "@superset/relay": ["@superset/relay@workspace:apps/relay"], "@superset/shared": ["@superset/shared@workspace:packages/shared"], diff --git a/packages/host-service/package.json b/packages/host-service/package.json index 519cc71b3a5..1710dd91365 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -47,6 +47,7 @@ "@mastra/core": "1.26.0-alpha.3", "@octokit/rest": "^22.0.1", "@superset/chat": "workspace:*", + "@superset/port-scanner": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@superset/workspace-fs": "workspace:*", @@ -60,6 +61,7 @@ "node-pty": "1.1.0", "simple-git": "^3.30.0", "superjson": "^2.2.5", + "tree-kill": "^1.2.2", "zod": "^4.3.5" }, "devDependencies": { diff --git a/packages/host-service/src/events/event-bus.test.ts b/packages/host-service/src/events/event-bus.test.ts new file mode 100644 index 00000000000..0d0f5cd2d92 --- /dev/null +++ b/packages/host-service/src/events/event-bus.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "bun:test"; +import type { DetectedPort } from "@superset/port-scanner"; +import type { HostDb } from "../db"; +import { portManager } from "../ports/port-manager"; +import type { WorkspaceFilesystemManager } from "../runtime/filesystem"; +import { EventBus } from "./event-bus"; + +function createEventBus(): EventBus { + return new EventBus({ + db: {} as unknown as HostDb, + filesystem: { + resolveWorkspaceRoot: () => "/tmp/missing-workspace", + } as unknown as WorkspaceFilesystemManager, + }); +} + +describe("EventBus port events", () => { + it("broadcasts port changes from the shared port manager and removes listeners on close", () => { + const eventBus = createEventBus(); + const sentMessages: string[] = []; + const socket = { + readyState: 1, + send(data: string) { + sentMessages.push(data); + }, + close() {}, + }; + const port: DetectedPort = { + port: 5173, + pid: 123, + processName: "vite", + terminalId: "terminal-1", + workspaceId: "workspace-1", + detectedAt: 1_700_000_000_000, + address: "127.0.0.1", + }; + + eventBus.handleOpen(socket); + eventBus.start(); + eventBus.start(); + portManager.emit("port:add", port); + + expect(sentMessages).toHaveLength(1); + const message = JSON.parse(sentMessages[0] ?? "{}"); + expect(message).toMatchObject({ + type: "port:changed", + workspaceId: "workspace-1", + eventType: "add", + port, + label: null, + }); + expect(typeof message.occurredAt).toBe("number"); + + portManager.emit("port:remove", port); + expect(sentMessages).toHaveLength(2); + expect(JSON.parse(sentMessages[1] ?? "{}")).toMatchObject({ + type: "port:changed", + workspaceId: "workspace-1", + eventType: "remove", + port, + label: null, + }); + + eventBus.close(); + portManager.emit("port:add", port); + expect(sentMessages).toHaveLength(2); + }); +}); diff --git a/packages/host-service/src/events/event-bus.ts b/packages/host-service/src/events/event-bus.ts index 6dc3baff6e9..33763322e36 100644 --- a/packages/host-service/src/events/event-bus.ts +++ b/packages/host-service/src/events/event-bus.ts @@ -1,7 +1,10 @@ import type { NodeWebSocket } from "@hono/node-ws"; +import type { DetectedPort } from "@superset/port-scanner"; import type { FsWatchEvent } from "@superset/workspace-fs/host"; import type { Hono } from "hono"; import type { HostDb } from "../db"; +import { portManager } from "../ports/port-manager"; +import { getLabelsForWorkspace } from "../ports/static-ports"; import type { WorkspaceFilesystemManager } from "../runtime/filesystem"; import { GitWatcher } from "./git-watcher"; import type { ClientMessage, ServerMessage } from "./types"; @@ -56,6 +59,7 @@ export interface EventBusOptions { * * One connection per client. Carries: * - `git:changed` events (auto-pushed for all workspaces) + * - `port:changed` events (auto-pushed for all workspace terminals) * - `fs:events` (on-demand per client request) */ export class EventBus { @@ -63,6 +67,7 @@ export class EventBus { private readonly gitWatcher: GitWatcher; private readonly filesystem: WorkspaceFilesystemManager; private removeGitListener: (() => void) | null = null; + private removePortListeners: (() => void) | null = null; constructor(options: EventBusOptions) { this.filesystem = options.filesystem; @@ -70,6 +75,8 @@ export class EventBus { } start(): void { + if (this.removeGitListener || this.removePortListeners) return; + this.gitWatcher.start(); this.removeGitListener = this.gitWatcher.onChanged((event) => { this.broadcast({ @@ -78,11 +85,26 @@ export class EventBus { ...(event.paths !== undefined ? { paths: event.paths } : {}), }); }); + + const handlePortAdd = (port: DetectedPort) => { + this.broadcastPortChanged({ eventType: "add", port }); + }; + const handlePortRemove = (port: DetectedPort) => { + this.broadcastPortChanged({ eventType: "remove", port }); + }; + portManager.on("port:add", handlePortAdd); + portManager.on("port:remove", handlePortRemove); + this.removePortListeners = () => { + portManager.off("port:add", handlePortAdd); + portManager.off("port:remove", handlePortRemove); + }; } close(): void { this.removeGitListener?.(); this.removeGitListener = null; + this.removePortListeners?.(); + this.removePortListeners = null; this.gitWatcher.close(); for (const [socket, state] of this.clients) { this.cleanupClient(socket, state); @@ -148,6 +170,39 @@ export class EventBus { this.broadcast({ type: "terminal:lifecycle", ...message }); } + /** + * Fan out port add/remove events discovered by the host-service scanner. + * Renderer clients use this to patch their host snapshot immediately while + * keeping a slow refetch as a reconnect fallback. + */ + private broadcastPortChanged({ + eventType, + port, + }: { + eventType: "add" | "remove"; + port: DetectedPort; + }): void { + this.broadcast({ + type: "port:changed", + workspaceId: port.workspaceId, + eventType, + port, + label: eventType === "add" ? this.getPortLabel(port) : null, + occurredAt: Date.now(), + }); + } + + private getPortLabel(port: DetectedPort): string | null { + const labels = getLabelsForWorkspace((workspaceId) => { + try { + return this.filesystem.resolveWorkspaceRoot(workspaceId); + } catch { + return null; + } + }, port.workspaceId); + return labels?.get(port.port) ?? null; + } + private startFsWatch( socket: WsSocket, state: ClientState, diff --git a/packages/host-service/src/events/index.ts b/packages/host-service/src/events/index.ts index 983a1d8a2a2..a7e05c678a1 100644 --- a/packages/host-service/src/events/index.ts +++ b/packages/host-service/src/events/index.ts @@ -11,6 +11,7 @@ export type { FsUnwatchCommand, FsWatchCommand, GitChangedMessage, + PortChangedMessage, ServerMessage, TerminalLifecycleMessage, } from "./types"; diff --git a/packages/host-service/src/events/types.ts b/packages/host-service/src/events/types.ts index 190fd0662e8..1ac18bf82d4 100644 --- a/packages/host-service/src/events/types.ts +++ b/packages/host-service/src/events/types.ts @@ -1,3 +1,4 @@ +import type { DetectedPort } from "@superset/port-scanner"; import type { FsWatchEvent } from "@superset/workspace-fs/host"; import type { AgentLifecycleEventType } from "./map-event-type"; @@ -39,6 +40,15 @@ export interface TerminalLifecycleMessage { occurredAt: number; } +export interface PortChangedMessage { + type: "port:changed"; + workspaceId: string; + eventType: "add" | "remove"; + port: DetectedPort; + label: string | null; + occurredAt: number; +} + export interface EventBusErrorMessage { type: "error"; message: string; @@ -49,6 +59,7 @@ export type ServerMessage = | GitChangedMessage | AgentLifecycleMessage | TerminalLifecycleMessage + | PortChangedMessage | EventBusErrorMessage; // ── Client → Server ──────────────────────────────────────────────── diff --git a/packages/host-service/src/ports/port-manager.ts b/packages/host-service/src/ports/port-manager.ts new file mode 100644 index 00000000000..b4faa3da452 --- /dev/null +++ b/packages/host-service/src/ports/port-manager.ts @@ -0,0 +1,6 @@ +import { PortManager } from "@superset/port-scanner"; +import { treeKillWithEscalation } from "./tree-kill"; + +export const portManager = new PortManager({ + killFn: treeKillWithEscalation, +}); diff --git a/packages/host-service/src/ports/static-ports.ts b/packages/host-service/src/ports/static-ports.ts new file mode 100644 index 00000000000..b857b20a1da --- /dev/null +++ b/packages/host-service/src/ports/static-ports.ts @@ -0,0 +1,138 @@ +import { readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { parseStaticPortsConfig } from "@superset/port-scanner"; + +const PROJECT_SUPERSET_DIR_NAME = ".superset"; +const PORTS_FILE_NAME = "ports.json"; + +interface LabelCacheEntry { + labels: Map | null; + portsFileSignature: string | null; + worktreePath: string | null; +} + +function getPortsPath(worktreePath: string): string { + return join(worktreePath, PROJECT_SUPERSET_DIR_NAME, PORTS_FILE_NAME); +} + +function isMissingPathError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === "ENOENT" || code === "ENOTDIR"; +} + +function getPortsFileSignature(worktreePath: string): string | null { + try { + const stat = statSync(getPortsPath(worktreePath)); + return `${stat.mtimeMs}:${stat.size}`; + } catch (error) { + if (isMissingPathError(error)) return null; + throw error; + } +} + +function safeGetPortsFileSignature(worktreePath: string): string | null { + try { + return getPortsFileSignature(worktreePath); + } catch (error) { + console.warn("[ports] Failed to stat static port labels:", { + worktreePath, + error, + }); + return null; + } +} + +function readPortsFile(worktreePath: string): string | null { + try { + return readFileSync(getPortsPath(worktreePath), "utf-8"); + } catch (error) { + if (isMissingPathError(error)) return null; + throw error; + } +} + +function safeLoadLabels(worktreePath: string): Map | null { + try { + return loadLabels(worktreePath); + } catch (error) { + console.warn("[ports] Failed to load static port labels:", { + worktreePath, + error, + }); + return null; + } +} + +/** + * Read `/.superset/ports.json` and return a `port → label` map. + * Returns null if the file is missing or malformed — this endpoint is a + * best-effort label hint, not a validator, so parse errors are silent. + */ +function loadLabels(worktreePath: string): Map | null { + const content = readPortsFile(worktreePath); + if (content === null) return null; + + const parsed = parseStaticPortsConfig(content); + if (parsed.ports === null) return null; + + const labels = new Map(); + for (const port of parsed.ports) { + labels.set(port.port, port.label); + } + return labels; +} + +/** + * Memoize label lookups per workspaceId. Called by host port snapshots and + * add-event enrichment, so the workspace-root + fs reads would otherwise repeat + * needlessly. `labels: null` with a resolved worktree means "no labels file" — + * that negative can stick until the file signature changes. A missing + * worktreePath is not cached because workspace hydration can race first reads. + */ +const labelCache = new Map(); + +function setLabelCache( + workspaceId: string, + worktreePath: string | null, + labels: Map | null, +): Map | null { + const portsFileSignature = worktreePath + ? safeGetPortsFileSignature(worktreePath) + : null; + labelCache.set(workspaceId, { + labels, + portsFileSignature, + worktreePath, + }); + return labels; +} + +export function getLabelsForWorkspace( + resolveWorktreePath: (workspaceId: string) => string | null, + workspaceId: string, +): Map | null { + const cached = labelCache.get(workspaceId); + if (cached) { + if (cached.worktreePath === null) { + labelCache.delete(workspaceId); + } else { + const currentSignature = safeGetPortsFileSignature(cached.worktreePath); + if (currentSignature === cached.portsFileSignature) return cached.labels; + return setLabelCache( + workspaceId, + cached.worktreePath, + safeLoadLabels(cached.worktreePath), + ); + } + } + + const worktreePath = resolveWorktreePath(workspaceId); + if (!worktreePath) return null; + + return setLabelCache(workspaceId, worktreePath, safeLoadLabels(worktreePath)); +} + +export function invalidateLabelCache(workspaceId?: string): void { + if (workspaceId === undefined) labelCache.clear(); + else labelCache.delete(workspaceId); +} diff --git a/packages/host-service/src/ports/tree-kill.ts b/packages/host-service/src/ports/tree-kill.ts new file mode 100644 index 00000000000..e6f9c1264ec --- /dev/null +++ b/packages/host-service/src/ports/tree-kill.ts @@ -0,0 +1,121 @@ +import treeKill from "tree-kill"; + +const DEFAULT_ESCALATION_TIMEOUT_MS = 2000; +const POLL_INTERVAL_MS = 50; + +/** + * Kill a process tree with escalation to SIGKILL if the process survives. + * Sends SIGTERM, polls for exit, escalates to SIGKILL after timeout. + */ +export function treeKillWithEscalation({ + pid, + signal = "SIGTERM", + escalationTimeoutMs = DEFAULT_ESCALATION_TIMEOUT_MS, +}: { + pid: number; + signal?: string; + escalationTimeoutMs?: number; +}): Promise<{ success: boolean; error?: string }> { + return new Promise((resolve) => { + let resolved = false; + let pollTimer: ReturnType | null = null; + let escalationTimer: ReturnType | null = null; + + const clearTimers = () => { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + if (escalationTimer) { + clearTimeout(escalationTimer); + escalationTimer = null; + } + }; + + const doResolve = (result: { success: boolean; error?: string }) => { + if (resolved) return; + resolved = true; + clearTimers(); + resolve(result); + }; + + treeKill(pid, signal, (err) => { + if (resolved) return; + + if (err) { + if (isProcessNotFoundError(err)) { + doResolve({ success: true }); + return; + } + console.error( + `[treeKillWithEscalation] Failed to ${signal} pid ${pid}:`, + err, + ); + } + + if (!isProcessAlive(pid)) { + doResolve({ success: true }); + return; + } + + pollTimer = setInterval(() => { + if (!isProcessAlive(pid)) { + doResolve({ success: true }); + } + }, POLL_INTERVAL_MS); + pollTimer.unref(); + }); + + escalationTimer = setTimeout(() => { + escalationTimer = null; + if (resolved) return; + + if (!isProcessAlive(pid)) { + doResolve({ success: true }); + return; + } + + console.log( + `[treeKillWithEscalation] Process ${pid} still alive after ${signal}, escalating to SIGKILL`, + ); + + treeKill(pid, "SIGKILL", (err) => { + if (resolved) return; + + if (err) { + if (isProcessNotFoundError(err)) { + doResolve({ success: true }); + return; + } + console.error( + `[treeKillWithEscalation] Failed to SIGKILL pid ${pid}:`, + err, + ); + doResolve({ success: false, error: err.message }); + } else { + doResolve({ success: true }); + } + }); + }, escalationTimeoutMs); + escalationTimer.unref(); + }); +} + +/** + * ESRCH = dead, EPERM = alive (process exists but we lack permission) + */ +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code !== "ESRCH"; + } +} + +function isProcessNotFoundError(err: Error): boolean { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ESRCH") return true; + const message = err.message ?? ""; + return message.includes("ESRCH") || message.includes("No such process"); +} diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index c3c8c141855..9d69d4c4fa6 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -13,6 +13,7 @@ import { type IPty, spawn } from "node-pty"; import type { HostDb } from "../db"; import { projects, terminalSessions, workspaces } from "../db/schema"; import type { EventBus } from "../events"; +import { portManager } from "../ports/port-manager"; import { buildV2TerminalEnv, getShellLaunchArgs, @@ -268,6 +269,8 @@ export function disposeSession(terminalId: string, db: HostDb) { sessions.delete(terminalId); } + portManager.unregisterSession(terminalId); + db.update(terminalSessions) .set({ status: "disposed", endedAt: Date.now() }) .where(eq(terminalSessions.id, terminalId)) @@ -437,6 +440,7 @@ export function createTerminalSessionInternal({ scanState: createScanState(), }; sessions.set(terminalId, session); + portManager.upsertSession(terminalId, workspaceId, pty.pid); // If the marker never arrives (broken wrapper, unsupported config), // the timeout unblocks so the session degrades gracefully. @@ -458,6 +462,8 @@ export function createTerminalSessionInternal({ } if (data.length === 0) return; + portManager.checkOutputForHint(data); + if (broadcastMessage(session, { type: "data", data }) === 0) { bufferOutput(session, data); } @@ -468,6 +474,8 @@ export function createTerminalSessionInternal({ session.exitCode = exitCode ?? 0; session.exitSignal = signal ?? 0; + portManager.unregisterSession(terminalId); + db.update(terminalSessions) .set({ status: "exited", endedAt: Date.now() }) .where(eq(terminalSessions.id, terminalId)) diff --git a/packages/host-service/src/trpc/router/ports/index.ts b/packages/host-service/src/trpc/router/ports/index.ts new file mode 100644 index 00000000000..6cf684ee56c --- /dev/null +++ b/packages/host-service/src/trpc/router/ports/index.ts @@ -0,0 +1 @@ +export { portsRouter } from "./ports"; diff --git a/packages/host-service/src/trpc/router/ports/ports.ts b/packages/host-service/src/trpc/router/ports/ports.ts new file mode 100644 index 00000000000..cff912cc1a4 --- /dev/null +++ b/packages/host-service/src/trpc/router/ports/ports.ts @@ -0,0 +1,115 @@ +import type { DetectedPort } from "@superset/port-scanner"; +import { z } from "zod"; +import { portManager } from "../../../ports/port-manager"; +import { getLabelsForWorkspace } from "../../../ports/static-ports"; +import { protectedProcedure, router } from "../../index"; + +export interface EnrichedPort extends DetectedPort { + label: string | null; +} + +export type PortEvent = + | { type: "add"; port: DetectedPort } + | { type: "remove"; port: DetectedPort }; + +const getAllInputSchema = z.object({ + workspaceIds: z.array(z.string()).min(1), +}); + +export const portsRouter = router({ + getAll: protectedProcedure + .input(getAllInputSchema) + .query(({ ctx, input }): EnrichedPort[] => { + const requestedWorkspaceIds = new Set(input.workspaceIds); + const resolve = (workspaceId: string): string | null => { + try { + return ctx.runtime.filesystem.resolveWorkspaceRoot(workspaceId); + } catch { + // Workspace deleted or unknown — no labels for this row. + return null; + } + }; + const labelsByWorkspace = new Map< + string, + ReturnType + >(); + return portManager + .getAllPorts() + .filter((port) => requestedWorkspaceIds.has(port.workspaceId)) + .map((port) => { + let labels = labelsByWorkspace.get(port.workspaceId); + if (!labelsByWorkspace.has(port.workspaceId)) { + labels = getLabelsForWorkspace(resolve, port.workspaceId); + labelsByWorkspace.set(port.workspaceId, labels); + } + return { ...port, label: labels?.get(port.port) ?? null }; + }); + }), + + /** + * Stream port add/remove events. tRPC v11 async iterators: the generator + * runs until the client disconnects (or an abort signal cancels it), at + * which point the `finally` block detaches emitter listeners. + */ + subscribe: protectedProcedure + .input(getAllInputSchema) + .subscription(async function* ({ signal, input }) { + const requestedWorkspaceIds = new Set(input.workspaceIds); + const queue: PortEvent[] = []; + let resolve: (() => void) | null = null; + const wake = () => { + resolve?.(); + resolve = null; + }; + + const onAdd = (port: DetectedPort) => { + if (!requestedWorkspaceIds.has(port.workspaceId)) return; + queue.push({ type: "add", port }); + wake(); + }; + const onRemove = (port: DetectedPort) => { + if (!requestedWorkspaceIds.has(port.workspaceId)) return; + queue.push({ type: "remove", port }); + wake(); + }; + + portManager.on("port:add", onAdd); + portManager.on("port:remove", onRemove); + + signal?.addEventListener("abort", wake); + + try { + while (!signal?.aborted) { + while (queue.length > 0) { + const event = queue.shift(); + if (event) yield event; + } + await new Promise((r) => { + if (signal?.aborted) { + r(); + return; + } + resolve = r; + }); + } + } finally { + portManager.off("port:add", onAdd); + portManager.off("port:remove", onRemove); + signal?.removeEventListener("abort", wake); + } + }), + + kill: protectedProcedure + .input( + z.object({ + workspaceId: z.string(), + terminalId: z.string(), + port: z.number().int().positive(), + }), + ) + .mutation( + async ({ input }): Promise<{ success: boolean; error?: string }> => { + return portManager.killPort(input); + }, + ), +}); diff --git a/packages/host-service/src/trpc/router/router.ts b/packages/host-service/src/trpc/router/router.ts index 964b323775f..a81ada2949e 100644 --- a/packages/host-service/src/trpc/router/router.ts +++ b/packages/host-service/src/trpc/router/router.ts @@ -7,6 +7,7 @@ import { githubRouter } from "./github"; import { healthRouter } from "./health"; import { hostRouter } from "./host"; import { notificationsRouter } from "./notifications"; +import { portsRouter } from "./ports"; import { projectRouter } from "./project"; import { pullRequestsRouter } from "./pull-requests"; import { terminalRouter } from "./terminal"; @@ -25,6 +26,7 @@ export const appRouter = router({ notifications: notificationsRouter, pullRequests: pullRequestsRouter, project: projectRouter, + ports: portsRouter, terminal: terminalRouter, workspace: workspaceRouter, workspaceCleanup: workspaceCleanupRouter, diff --git a/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts b/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts index db72093b3b5..d33f5e35c0e 100644 --- a/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts +++ b/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts @@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; +import { invalidateLabelCache } from "../../../ports/static-ports"; import { runTeardown, type TeardownResult } from "../../../runtime/teardown"; import { disposeSessionsByWorkspaceId } from "../../../terminal/terminal"; import type { TeardownFailureCause } from "../../error-types"; @@ -166,6 +167,7 @@ export const workspaceCleanupRouter = router({ .delete(workspaces) .where(eq(workspaces.id, input.workspaceId)) .run(); + invalidateLabelCache(input.workspaceId); } return { diff --git a/packages/host-service/src/trpc/router/workspace/workspace.ts b/packages/host-service/src/trpc/router/workspace/workspace.ts index 95be2e566c5..1a414d9ed04 100644 --- a/packages/host-service/src/trpc/router/workspace/workspace.ts +++ b/packages/host-service/src/trpc/router/workspace/workspace.ts @@ -5,6 +5,7 @@ import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; +import { invalidateLabelCache } from "../../../ports/static-ports"; import { createSimpleGitWithEnv } from "../../../runtime/git/simple-git"; import { protectedProcedure, router } from "../../index"; @@ -200,6 +201,7 @@ export const workspaceRouter = router({ } ctx.db.delete(workspaces).where(eq(workspaces.id, input.id)).run(); + invalidateLabelCache(input.id); return { success: true }; }), diff --git a/packages/port-scanner/package.json b/packages/port-scanner/package.json new file mode 100644 index 00000000000..506a5d535ce --- /dev/null +++ b/packages/port-scanner/package.json @@ -0,0 +1,25 @@ +{ + "name": "@superset/port-scanner", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "clean": "git clean -xdf .cache .turbo dist node_modules", + "typecheck": "tsc --noEmit --emitDeclarationOnly false", + "test": "bun test" + }, + "dependencies": { + "pidtree": "^0.6.0" + }, + "devDependencies": { + "@superset/typescript": "workspace:*", + "bun-types": "^1.3.1", + "typescript": "^5.9.3" + } +} diff --git a/packages/port-scanner/src/index.ts b/packages/port-scanner/src/index.ts new file mode 100644 index 00000000000..05ef857d91f --- /dev/null +++ b/packages/port-scanner/src/index.ts @@ -0,0 +1,18 @@ +export { + type KillFn, + PortManager, + type PortManagerOptions, +} from "./port-manager"; +export { + getListeningPortsForPids, + getProcessCommand, + getProcessName, + getProcessTree, + type PortInfo, +} from "./scanner"; +export { + parseStaticPortsConfig, + type StaticPortLabel, + type StaticPortsParseResult, +} from "./static-ports"; +export type { DetectedPort } from "./types"; diff --git a/packages/port-scanner/src/port-manager.test.ts b/packages/port-scanner/src/port-manager.test.ts new file mode 100644 index 00000000000..37034a38c79 --- /dev/null +++ b/packages/port-scanner/src/port-manager.test.ts @@ -0,0 +1,377 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import type { DetectedPort } from "./types"; + +/** + * Regression tests for #3372 ("excessive lsof spawning"). + * + * Three behaviors the fix guarantees: + * 1. No scans run when there are no registered sessions (lifecycle). + * 2. At most one scan is in flight at any moment, even under a flood of + * hint-matching output (concurrency / coalescing). + * 3. stopPeriodicScan aborts any in-flight child so it cannot outlive us + * (no orphan lsof). + * + * The hint regexes that previously matched routine log noise ("port 22", + * trailing ":12345") must no longer trigger scans; the three "listening on …" + * patterns still must. + */ + +interface ScannerSpy { + getProcessTree: number; + getListeningPortsForPids: number; + inFlight: number; + maxInFlight: number; + lastSignal: AbortSignal | undefined; + aborted: number; +} + +interface MockPortInfo { + port: number; + pid: number; + address: string; + processName: string; +} + +const spy: ScannerSpy = { + getProcessTree: 0, + getListeningPortsForPids: 0, + inFlight: 0, + maxInFlight: 0, + lastSignal: undefined, + aborted: 0, +}; + +let lsofDelayMs = 0; +let listeningPorts: MockPortInfo[] = []; + +mock.module("./scanner", () => ({ + getProcessTree: async (pid: number) => { + spy.getProcessTree++; + return [pid, pid + 1]; + }, + getListeningPortsForPids: async (_pids: number[], signal?: AbortSignal) => { + spy.getListeningPortsForPids++; + spy.inFlight++; + spy.maxInFlight = Math.max(spy.maxInFlight, spy.inFlight); + spy.lastSignal = signal; + try { + if (lsofDelayMs > 0) { + // Match production: getListeningPortsLsof catches all errors and + // returns []. If we get aborted we just resolve with [] early. + await new Promise((resolve) => { + const timer = setTimeout(resolve, lsofDelayMs); + signal?.addEventListener("abort", () => { + clearTimeout(timer); + spy.aborted++; + resolve(); + }); + }); + } + return listeningPorts; + } finally { + spy.inFlight--; + } + }, +})); + +const { PortManager } = await import("./port-manager"); + +const HINT_DEBOUNCE_MS = 500; +const PAST_DEBOUNCE_MS = HINT_DEBOUNCE_MS + 50; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const noopKill = async () => ({ success: true }); + +let manager: InstanceType; + +const pmInternals = () => + manager as unknown as { + scanInterval: ReturnType | null; + }; + +function resetSpy(): void { + spy.getProcessTree = 0; + spy.getListeningPortsForPids = 0; + spy.inFlight = 0; + spy.maxInFlight = 0; + spy.lastSignal = undefined; + spy.aborted = 0; + lsofDelayMs = 0; + listeningPorts = []; +} + +beforeEach(() => { + resetSpy(); + manager = new PortManager({ killFn: noopKill }); +}); + +afterEach(() => { + manager.stopPeriodicScan(); +}); + +describe("PortManager — #3372 lifecycle (interval runs only with sessions)", () => { + it("forceScan is a no-op when no sessions are registered", async () => { + await manager.forceScan(); + expect(spy.getProcessTree).toBe(0); + expect(spy.getListeningPortsForPids).toBe(0); + }); + + it("first registered session starts the interval; last unregister stops it", () => { + expect(pmInternals().scanInterval).toBeNull(); + + manager.upsertSession("p1", "ws1", 1000); + expect(pmInternals().scanInterval).not.toBeNull(); + + manager.unregisterSession("p1"); + expect(pmInternals().scanInterval).toBeNull(); + }); + + it("sessions with pid=null still control the interval", () => { + manager.upsertSession("pd1", "ws1", null); + expect(pmInternals().scanInterval).not.toBeNull(); + + manager.unregisterSession("pd1"); + expect(pmInternals().scanInterval).toBeNull(); + }); + + it("multiple sessions: interval stops only when all are gone", () => { + manager.upsertSession("p1", "ws1", 1000); + manager.upsertSession("pd1", "ws2", 2000); + + manager.unregisterSession("p1"); + expect(pmInternals().scanInterval).not.toBeNull(); + + manager.unregisterSession("pd1"); + expect(pmInternals().scanInterval).toBeNull(); + }); + + it("re-registering after idle restarts the interval", () => { + manager.upsertSession("p1", "ws1", 1000); + manager.unregisterSession("p1"); + expect(pmInternals().scanInterval).toBeNull(); + + manager.upsertSession("p2", "ws1", 1001); + expect(pmInternals().scanInterval).not.toBeNull(); + }); + + it("session with pid=null is skipped during PID collection", async () => { + manager.upsertSession("p1", "ws1", null); + await manager.forceScan(); + // No PID → no process-tree walk and no lsof batch. + expect(spy.getProcessTree).toBe(0); + expect(spy.getListeningPortsForPids).toBe(0); + }); +}); + +describe("PortManager — #3372 concurrency (at most one lsof in flight)", () => { + it("bulk scan batches every session into a single lsof call", async () => { + for (let i = 0; i < 10; i++) { + manager.upsertSession(`p${i}`, `ws${i}`, 1000 + i); + } + await manager.forceScan(); + + expect(spy.getListeningPortsForPids).toBe(1); + expect(spy.maxInFlight).toBe(1); + }); + + it("a flood of hints coalesces into one follow-up, never concurrent", async () => { + lsofDelayMs = 30; + manager.upsertSession("p1", "ws1", 1000); + + const firstScan = manager.forceScan(); + + // 100 hints while the first scan is running — all on the hot path. + for (let i = 0; i < 100; i++) { + manager.checkOutputForHint("listening on port 3000\n"); + } + + await firstScan; + await sleep(PAST_DEBOUNCE_MS); // let the single debounced follow-up run + + expect(spy.maxInFlight).toBe(1); + // Exact — one initial scan + one coalesced follow-up, never more, never fewer. + expect(spy.getListeningPortsForPids).toBe(2); + }); +}); + +describe("PortManager — port identity updates", () => { + it("emits an update when an existing port rebinds to a new address", async () => { + const added: DetectedPort[] = []; + const removed: DetectedPort[] = []; + manager.on("port:add", (port: DetectedPort) => added.push(port)); + manager.on("port:remove", (port: DetectedPort) => removed.push(port)); + + manager.upsertSession("p1", "ws1", 1000); + + listeningPorts = [ + { port: 3000, pid: 1000, address: "0.0.0.0", processName: "node" }, + ]; + await manager.forceScan(); + + listeningPorts = [ + { port: 3000, pid: 1000, address: "127.0.0.1", processName: "node" }, + ]; + await manager.forceScan(); + + const [port] = manager.getAllPorts(); + expect(port?.address).toBe("127.0.0.1"); + expect(added.map((event) => event.address)).toEqual([ + "0.0.0.0", + "127.0.0.1", + ]); + expect(removed.map((event) => event.address)).toEqual(["0.0.0.0"]); + }); + + it("dedupes dual-address listeners for the same terminal port", async () => { + const added: DetectedPort[] = []; + const removed: DetectedPort[] = []; + manager.on("port:add", (port: DetectedPort) => added.push(port)); + manager.on("port:remove", (port: DetectedPort) => removed.push(port)); + + manager.upsertSession("p1", "ws1", 1000); + + listeningPorts = [ + { port: 3000, pid: 1000, address: "::1", processName: "node" }, + { port: 3000, pid: 1000, address: "127.0.0.1", processName: "node" }, + ]; + await manager.forceScan(); + + expect(manager.getAllPorts()).toHaveLength(1); + expect(manager.getAllPorts()[0]?.address).toBe("127.0.0.1"); + expect(added).toHaveLength(1); + expect(removed).toHaveLength(0); + + listeningPorts = [ + { port: 3000, pid: 1000, address: "127.0.0.1", processName: "node" }, + { port: 3000, pid: 1000, address: "::1", processName: "node" }, + ]; + await manager.forceScan(); + + expect(manager.getAllPorts()).toHaveLength(1); + expect(manager.getAllPorts()[0]?.address).toBe("127.0.0.1"); + expect(added).toHaveLength(1); + expect(removed).toHaveLength(0); + }); + + it("ranks expanded IPv6 loopback the same as ::1 when deduping", async () => { + manager.upsertSession("p1", "ws1", 1000); + + listeningPorts = [ + { + port: 3000, + pid: 1000, + address: "0:0:0:0:0:0:0:1", + processName: "node", + }, + { port: 3000, pid: 1000, address: "0.0.0.0", processName: "node" }, + ]; + await manager.forceScan(); + + expect(manager.getAllPorts()).toHaveLength(1); + expect(manager.getAllPorts()[0]?.address).toBe("0.0.0.0"); + }); +}); + +describe("PortManager — #3372 hint regex narrowing", () => { + beforeEach(() => { + manager.upsertSession("p1", "ws1", 1000); + resetSpy(); + }); + + it("does NOT scan on a bare 'port 22' (old loose pattern)", async () => { + manager.checkOutputForHint("connection reached port 22\n"); + await sleep(PAST_DEBOUNCE_MS); + expect(spy.getListeningPortsForPids).toBe(0); + }); + + it("does NOT scan on a trailing ':12345' (old loose pattern)", async () => { + manager.checkOutputForHint("commit abc123def:12345\n"); + await sleep(PAST_DEBOUNCE_MS); + expect(spy.getListeningPortsForPids).toBe(0); + }); + + it("DOES scan on 'listening on port 3000'", async () => { + manager.checkOutputForHint("listening on port 3000\n"); + await sleep(PAST_DEBOUNCE_MS); + expect(spy.getListeningPortsForPids).toBe(1); + }); + + it("DOES scan on 'server running at http://localhost:3000'", async () => { + manager.checkOutputForHint("server running at http://localhost:3000\n"); + await sleep(PAST_DEBOUNCE_MS); + expect(spy.getListeningPortsForPids).toBe(1); + }); + + it("DOES scan on 'ready on http://localhost:5173' (Vite-style)", async () => { + manager.checkOutputForHint("ready on http://localhost:5173\n"); + await sleep(PAST_DEBOUNCE_MS); + expect(spy.getListeningPortsForPids).toBe(1); + }); + + it("DOES scan on Vite's 'Local: http://localhost:5173/' banner", async () => { + manager.checkOutputForHint(" ➜ Local: http://localhost:5173/\n"); + await sleep(PAST_DEBOUNCE_MS); + expect(spy.getListeningPortsForPids).toBe(1); + }); + + it("DOES scan on Django's 'Starting development server at http://...'", async () => { + manager.checkOutputForHint( + "Starting development server at http://127.0.0.1:8000/\n", + ); + await sleep(PAST_DEBOUNCE_MS); + expect(spy.getListeningPortsForPids).toBe(1); + }); +}); + +describe("PortManager — #3372 teardown (no orphan children)", () => { + it("stopPeriodicScan aborts any in-flight lsof", async () => { + lsofDelayMs = 200; + manager.upsertSession("p1", "ws1", 1000); + + const scanPromise = manager.forceScan(); + // Wait for the lsof stub to start. + await sleep(10); + expect(spy.inFlight).toBe(1); + + manager.stopPeriodicScan(); + + // The promise resolves (port-scanner swallows its own errors). + await scanPromise; + + expect(spy.aborted).toBeGreaterThanOrEqual(1); + expect(spy.inFlight).toBe(0); + }); + + it("in-flight lsof receives the AbortSignal from the manager", async () => { + lsofDelayMs = 50; + manager.upsertSession("p1", "ws1", 1000); + + const scanPromise = manager.forceScan(); + await sleep(10); + + expect(spy.lastSignal).toBeDefined(); + expect(spy.lastSignal?.aborted).toBe(false); + + await scanPromise; + }); + + it("hint timer that fires after stopPeriodicScan does not crash on missing scanAbort", async () => { + // Regression: ensureScanAbort() lazy-allocates so a leftover hintScanTimeout + // firing after an idle stop can still run a scan with a fresh AbortSignal, + // rather than passing `undefined` and losing abortability. + manager.upsertSession("p1", "ws1", 1000); + + manager.checkOutputForHint("listening on port 3000\n"); + // Unregister immediately — this triggers stopPeriodicScanIfIdle which + // clears the hint timer. If any code path regresses and the timer + // survives past abort-nulling, ensureScanAbort must still produce a + // valid signal rather than throwing. + manager.unregisterSession("p1"); + + // Re-register and force a scan; must complete without error. + manager.upsertSession("p2", "ws2", 2000); + await manager.forceScan(); + expect(spy.getListeningPortsForPids).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/port-scanner/src/port-manager.ts b/packages/port-scanner/src/port-manager.ts new file mode 100644 index 00000000000..7ccc634cd1c --- /dev/null +++ b/packages/port-scanner/src/port-manager.ts @@ -0,0 +1,542 @@ +import { EventEmitter } from "node:events"; +import { + getListeningPortsForPids, + getProcessTree, + type PortInfo, +} from "./scanner"; +import type { DetectedPort } from "./types"; + +/** How often to poll for port changes (in ms) */ +const SCAN_INTERVAL_MS = 2500; + +/** Delay before scanning after a port hint is detected (in ms) */ +const HINT_SCAN_DELAY_MS = 500; + +/** 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 PORT_HINT_PATTERNS = [ + /listening\s+on\s+(?:port\s+)?(\d+)/i, + /server\s+(?:started|running)\s+(?:on|at)\s+(?:http:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0)?:?(\d+)/i, + /ready\s+on\s+(?:http:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0)?:?(\d+)/i, + /\bLocal:\s+https?:\/\//i, + /development\s+server\s+at\s+https?:\/\//i, +]; + +/** + * Check if terminal output contains hints that a port may have been opened. + * Restricted to phrases that strongly imply a server just started listening; + * looser patterns like a bare "port 22" or trailing ":12345" are omitted + * because they match routine log output (ssh banners, timestamps, etc.) and + * triggered excessive lsof scans — see issue #3372. + * + * `Local: http://localhost:5173/` and `development server at …` are added so + * Vite, Next.js 14+, and Django get detected on first boot rather than waiting + * for the next periodic scan. + */ +function containsPortHint(data: string): boolean { + return PORT_HINT_PATTERNS.some((pattern) => pattern.test(data)); +} + +function addressRank(address: string): number { + const normalizedAddress = address.toLowerCase(); + if (normalizedAddress === "127.0.0.1" || normalizedAddress === "localhost") { + return 0; + } + if (normalizedAddress === "0.0.0.0" || normalizedAddress === "*") { + return 1; + } + if (!normalizedAddress.includes(":")) { + return 2; + } + if (normalizedAddress === "::1" || normalizedAddress === "0:0:0:0:0:0:0:1") { + return 3; + } + if (normalizedAddress === "::" || normalizedAddress === "0:0:0:0:0:0:0:0") { + return 4; + } + return 5; +} + +function isAbortError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const candidate = error as { name?: unknown; code?: unknown }; + return candidate.name === "AbortError" || candidate.code === "ABORT_ERR"; +} + +function comparePortInfo(a: PortInfo, b: PortInfo): number { + return ( + a.port - b.port || + a.pid - b.pid || + a.processName.localeCompare(b.processName) || + addressRank(a.address) - addressRank(b.address) || + a.address.localeCompare(b.address) + ); +} + +function dedupePortInfosByPort(portInfos: PortInfo[]): PortInfo[] { + const portsByNumber = new Map(); + + for (const info of portInfos) { + const existing = portsByNumber.get(info.port); + if (!existing || comparePortInfo(info, existing) < 0) { + portsByNumber.set(info.port, info); + } + } + + return Array.from(portsByNumber.values()).sort(comparePortInfo); +} + +interface SessionEntry { + workspaceId: string; + /** PTY process ID — null when the terminal isn't yet spawned (or has exited). */ + pid: number | null; +} + +interface ScanState { + terminalPortMap: Map; + pidOwnerMap: Map; + allPids: Set; + emptyTreeTerminals: Set; +} + +/** + * Kills a process tree and escalates to SIGKILL if needed. Callers inject this + * so the shared package doesn't depend on a particular tree-kill implementation + * (desktop has one; host-service needs its own). + */ +export type KillFn = (args: { + pid: number; +}) => Promise<{ success: boolean; error?: string }>; + +export interface PortManagerOptions { + killFn: KillFn; +} + +export class PortManager extends EventEmitter { + private ports = new Map(); + /** terminalId → { workspaceId, pid | null } */ + private sessions = new Map(); + private scanInterval: ReturnType | null = null; + private hintScanTimeout: ReturnType | null = null; + private isScanning = false; + /** Set when a hint arrives during a scan; triggers one follow-up scan. */ + private scanRequested = false; + /** Aborts any in-flight scan children (lsof/netstat) on teardown. */ + private scanAbort: AbortController | null = null; + private readonly killFn: KillFn; + + constructor(options: PortManagerOptions) { + super(); + this.killFn = options.killFn; + } + + /** + * Register or update a terminal session for port scanning. + * Pass `pid = null` when the terminal hasn't spawned yet; call again with + * the real PID once it's known. Safe to call multiple times. + */ + upsertSession( + terminalId: string, + workspaceId: string, + pid: number | null, + ): void { + this.sessions.set(terminalId, { workspaceId, pid }); + this.ensurePeriodicScanRunning(); + } + + /** + * Remove a session and forget any ports it owned. + */ + unregisterSession(terminalId: string): void { + this.sessions.delete(terminalId); + this.removePortsForTerminal(terminalId); + this.stopPeriodicScanIfIdle(); + } + + checkOutputForHint(data: string): void { + if (this.hintScanTimeout || this.scanRequested) return; + if (!containsPortHint(data)) return; + this.scheduleHintScan(); + } + + private hasAnySessions(): boolean { + return this.sessions.size > 0; + } + + private ensurePeriodicScanRunning(): void { + if (this.scanInterval) return; + + this.ensureScanAbort(); + this.scanInterval = setInterval(() => { + this.scanAllSessions().catch((error) => { + console.error("[PortManager] Scan error:", error); + }); + }, SCAN_INTERVAL_MS); + + // Don't prevent Node from exiting + this.scanInterval.unref(); + } + + /** + * Lazily allocate the AbortController. Guards against the case where a + * pending `hintScanTimeout` fires after `stopPeriodicScan` nulled it out — + * without this, the follow-up scan would run with `signal = undefined` and + * lsof children would become un-abortable. + */ + private ensureScanAbort(): AbortController { + if (!this.scanAbort) { + this.scanAbort = new AbortController(); + } + return this.scanAbort; + } + + private stopPeriodicScanIfIdle(): void { + if (!this.hasAnySessions()) this.stopPeriodicScan(); + } + + stopPeriodicScan(): void { + if (this.scanInterval) { + clearInterval(this.scanInterval); + this.scanInterval = null; + } + + if (this.hintScanTimeout) { + clearTimeout(this.hintScanTimeout); + this.hintScanTimeout = null; + } + + // Kill any in-flight lsof/netstat so it can't outlive us. + if (this.scanAbort) { + this.scanAbort.abort(); + this.scanAbort = null; + } + + this.scanRequested = false; + } + + /** + * Debounce hint-triggered scans into a single follow-up bulk scan. + * Hints arrive on every PTY data chunk; we only need one scan per burst. + */ + private scheduleHintScan(): void { + if (this.hintScanTimeout) return; + + this.hintScanTimeout = setTimeout(() => { + this.hintScanTimeout = null; + this.scanAllSessions().catch((error) => { + console.error("[PortManager] Hint-triggered scan error:", error); + }); + }, HINT_SCAN_DELAY_MS); + this.hintScanTimeout.unref(); + } + + private createScanState(): ScanState { + return { + terminalPortMap: new Map< + string, + { workspaceId: string; pids: number[] } + >(), + pidOwnerMap: new Map< + number, + { terminalId: string; workspaceId: string } + >(), + allPids: new Set(), + emptyTreeTerminals: new Set(), + }; + } + + private async collectSessionPids(scanState: ScanState): Promise { + const tasks: Promise[] = []; + for (const [terminalId, { workspaceId, pid }] of this.sessions) { + if (pid === null) continue; + tasks.push( + this.collectPidTree({ + terminalId, + workspaceId, + pid, + scanState, + }), + ); + } + await Promise.all(tasks); + } + + private async collectPidTree({ + terminalId, + workspaceId, + pid, + scanState, + }: { + terminalId: string; + workspaceId: string; + pid: number; + scanState: ScanState; + }): Promise { + try { + const pids = await getProcessTree(pid); + if (pids.length === 0) { + scanState.emptyTreeTerminals.add(terminalId); + return; + } + + scanState.terminalPortMap.set(terminalId, { workspaceId, pids }); + this.addTerminalPids({ terminalId, workspaceId, pids, scanState }); + } catch { + // Session may have exited + } + } + + private addTerminalPids({ + terminalId, + workspaceId, + pids, + scanState, + }: { + terminalId: string; + workspaceId: string; + pids: number[]; + scanState: ScanState; + }): void { + for (const childPid of pids) { + scanState.allPids.add(childPid); + if (!scanState.pidOwnerMap.has(childPid)) { + scanState.pidOwnerMap.set(childPid, { terminalId, workspaceId }); + } + } + } + + private async buildPortsByTerminal({ + allPids, + pidOwnerMap, + }: { + allPids: Set; + pidOwnerMap: ScanState["pidOwnerMap"]; + }): Promise> { + const portsByTerminal = new Map(); + const allPidList = Array.from(allPids); + if (allPidList.length === 0) return portsByTerminal; + + const portInfos = await getListeningPortsForPids( + allPidList, + this.ensureScanAbort().signal, + ); + for (const info of portInfos) { + const owner = pidOwnerMap.get(info.pid); + if (!owner) continue; + const existing = portsByTerminal.get(owner.terminalId); + if (existing) { + existing.push(info); + } else { + portsByTerminal.set(owner.terminalId, [info]); + } + } + + return portsByTerminal; + } + + private updatePortsFromScan({ + terminalPortMap, + portsByTerminal, + }: { + terminalPortMap: ScanState["terminalPortMap"]; + portsByTerminal: Map; + }): void { + for (const [terminalId, { workspaceId }] of terminalPortMap) { + const portInfos = portsByTerminal.get(terminalId) ?? []; + this.updatePortsForTerminal({ terminalId, workspaceId, portInfos }); + } + } + + private clearEmptyTreeTerminals(emptyTreeTerminals: Set): void { + for (const terminalId of emptyTreeTerminals) { + this.removePortsForTerminal(terminalId); + } + } + + private cleanupUnregisteredPorts(): void { + for (const [key, port] of this.ports) { + if (!this.sessions.has(port.terminalId)) { + this.ports.delete(key); + this.emit("port:remove", port); + } + } + } + + private async scanAllSessions(): Promise { + if (this.isScanning) { + // A hint or tick fired mid-scan; queue exactly one follow-up. + this.scanRequested = true; + return; + } + if (!this.hasAnySessions()) return; + this.isScanning = true; + + try { + const scanState = this.createScanState(); + await this.collectSessionPids(scanState); + + const portsByTerminal = await this.buildPortsByTerminal({ + allPids: scanState.allPids, + pidOwnerMap: scanState.pidOwnerMap, + }); + + this.updatePortsFromScan({ + terminalPortMap: scanState.terminalPortMap, + portsByTerminal, + }); + this.clearEmptyTreeTerminals(scanState.emptyTreeTerminals); + this.cleanupUnregisteredPorts(); + } catch (error) { + if (isAbortError(error)) return; + throw error; + } finally { + this.isScanning = false; + } + + if (this.scanRequested && this.hasAnySessions()) { + this.scanRequested = false; + await this.scanAllSessions(); + } + } + + private updatePortsForTerminal({ + terminalId, + workspaceId, + portInfos, + }: { + terminalId: string; + workspaceId: string; + portInfos: PortInfo[]; + }): void { + const now = Date.now(); + + const validPortInfos = portInfos.filter( + (info) => !IGNORED_PORTS.has(info.port), + ); + const dedupedPortInfos = dedupePortInfosByPort(validPortInfos); + + const seenKeys = new Set(); + + for (const info of dedupedPortInfos) { + const key = this.makeKey(terminalId, 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, + terminalId, + 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 || + existing.address !== info.address + ) { + 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); + } + } + + for (const [key, port] of this.ports) { + if (port.terminalId === terminalId && !seenKeys.has(key)) { + this.ports.delete(key); + this.emit("port:remove", port); + } + } + } + + private makeKey(terminalId: string, port: number): string { + return `${terminalId}:${port}`; + } + + removePortsForTerminal(terminalId: string): void { + const portsToRemove: DetectedPort[] = []; + + for (const [key, port] of this.ports) { + if (port.terminalId === terminalId) { + portsToRemove.push(port); + this.ports.delete(key); + } + } + + for (const port of portsToRemove) { + this.emit("port:remove", port); + } + } + + getAllPorts(): DetectedPort[] { + return Array.from(this.ports.values()).sort( + (a, b) => b.detectedAt - a.detectedAt, + ); + } + + getPortsByWorkspace(workspaceId: string): DetectedPort[] { + return this.getAllPorts().filter((p) => p.workspaceId === workspaceId); + } + + async forceScan(): Promise { + await this.scanAllSessions(); + } + + /** + * Kill the process listening on a tracked port. + * Refuses to kill the terminal's own shell — that would close the terminal. + * A dev server is always a descendant (different PID), so `killFn` with the + * port's owning PID correctly tears down the server without touching the shell. + */ + killPort({ + terminalId, + workspaceId, + port, + }: { + terminalId: string; + workspaceId: string; + port: number; + }): Promise<{ + success: boolean; + error?: string; + }> { + const key = this.makeKey(terminalId, port); + const detectedPort = this.ports.get(key); + + if (!detectedPort) { + return Promise.resolve({ + success: false, + error: "Port not found in tracked ports", + }); + } + + if (detectedPort.workspaceId !== workspaceId) { + return Promise.resolve({ + success: false, + error: "Port does not belong to the requested workspace", + }); + } + + const shellPid = this.sessions.get(terminalId)?.pid; + + if (shellPid != null && detectedPort.pid === shellPid) { + return Promise.resolve({ + success: false, + error: "Cannot kill the terminal shell process", + }); + } + + return this.killFn({ pid: detectedPort.pid }); + } +} diff --git a/packages/port-scanner/src/procfs.test.ts b/packages/port-scanner/src/procfs.test.ts new file mode 100644 index 00000000000..b0063549f6f --- /dev/null +++ b/packages/port-scanner/src/procfs.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { parseIPv4Hex, parseIPv6Hex, parseProcNetLine } from "./procfs"; + +describe("parseIPv4Hex", () => { + test("decodes little-endian hex to dotted quad", () => { + expect(parseIPv4Hex("0100007F")).toBe("127.0.0.1"); + expect(parseIPv4Hex("00000000")).toBe("0.0.0.0"); + expect(parseIPv4Hex("0101A8C0")).toBe("192.168.1.1"); + }); + + test("rejects wrong-length input", () => { + expect(parseIPv4Hex("")).toBe(null); + expect(parseIPv4Hex("FF")).toBe(null); + expect(parseIPv4Hex("0100007F00")).toBe(null); + }); +}); + +describe("parseIPv6Hex", () => { + test("decodes all-zeros wildcard", () => { + expect(parseIPv6Hex("00000000000000000000000000000000")).toBe( + "0:0:0:0:0:0:0:0", + ); + }); + + test("decodes loopback :: 1", () => { + // ::1 in /proc/net/tcp6 is four little-endian 32-bit words: + // word0=0, word1=0, word2=0, word3=0x01000000 (LE of 0x00000001) + expect(parseIPv6Hex("00000000000000000000000001000000")).toBe( + "0:0:0:0:0:0:0:1", + ); + }); + + test("rejects wrong-length input", () => { + expect(parseIPv6Hex("")).toBe(null); + expect(parseIPv6Hex("FFFF")).toBe(null); + }); +}); + +describe("parseProcNetLine", () => { + // Columns: sl local_addr remote_addr state tx_q rx_q tr tm_when retrnsmt uid timeout inode ... + const LISTEN_LINE = + " 0: 00000000:0BB8 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 145678 1 0000000000000000 100 0 0 10 0"; + + test("returns port/inode/address for a LISTEN row", () => { + const parsed = parseProcNetLine(LISTEN_LINE, false); + expect(parsed).toEqual({ + // 0x0BB8 = 3000 + port: 3000, + inode: 145678, + address: "0.0.0.0", + }); + }); + + test("drops ESTABLISHED rows (state 01)", () => { + const established = LISTEN_LINE.replace(" 0A ", " 01 "); + expect(parseProcNetLine(established, false)).toBe(null); + }); + + test("drops rows with non-positive inode (connected but ownerless)", () => { + const noInode = LISTEN_LINE.replace(" 145678 ", " 0 "); + expect(parseProcNetLine(noInode, false)).toBe(null); + }); + + test("drops malformed lines", () => { + expect(parseProcNetLine("garbage", false)).toBe(null); + expect(parseProcNetLine(" sl local_address ...", false)).toBe(null); + }); + + test("parses IPv6 addresses when isIPv6=true", () => { + const ipv6Line = + " 0: 00000000000000000000000000000000:0BB8 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 987654 1 0000000000000000 100 0 0 10 0"; + const parsed = parseProcNetLine(ipv6Line, true); + expect(parsed).toEqual({ + port: 3000, + inode: 987654, + address: "0:0:0:0:0:0:0:0", + }); + }); + + test("filters out-of-range ports", () => { + // 0x0000 = 0 (invalid) + const badPort = LISTEN_LINE.replace(":0BB8 ", ":0000 "); + expect(parseProcNetLine(badPort, false)).toBe(null); + }); +}); diff --git a/packages/port-scanner/src/procfs.ts b/packages/port-scanner/src/procfs.ts new file mode 100644 index 00000000000..954c7303aa5 --- /dev/null +++ b/packages/port-scanner/src/procfs.ts @@ -0,0 +1,292 @@ +import { promises as fs } from "node:fs"; +import type { PortInfo } from "./scanner"; + +/** + * Linux-only: resolve listening TCP ports for a set of PIDs by reading + * /proc directly. Replaces spawning `lsof` on each scan. + * + * Why: on a busy host, `lsof -p -iTCP -sTCP:LISTEN` forks a child, + * opens every /proc fd anyway, then writes ~200 KiB of text we re-parse. + * Doing the same work in-process with two file reads and one directory walk + * cuts the per-scan cost by ~10× and eliminates the child-process lifecycle + * (timeouts, aborts, stdout buffering). + * + * Shape of the problem: + * 1. /proc/net/tcp{,6} listener rows → (local_address, state, inode) + * 2. /proc//fd/* symlinks to "socket:[]" → inode → pid + * 3. Join on inode, filter state === "0A" (TCP_LISTEN), drop entries + * whose inode we don't own. + * + * Parallels: this is the same approach VS Code uses for its remote tunnel + * port detection (src/vs/workbench/contrib/remoteTunnel). We keep the + * parsing in TypeScript rather than a binary helper so there's nothing to + * bundle for the host-service runtime. + */ + +/** Linux kernel TCP state code for LISTEN. See include/net/tcp_states.h. */ +const TCP_STATE_LISTEN = "0A"; + +/** Keep procfs fd symlink reads bounded across all scanned PIDs. */ +const FD_READLINK_CONCURRENCY = 64; + +interface ProcNetListener { + port: number; + inode: number; + address: string; +} + +/** + * Parse an IPv4 address from the hex form used in /proc/net/tcp. Each byte + * is little-endian, so "0100007F" decodes to 127.0.0.1, not 1.0.0.127. + */ +export function parseIPv4Hex(hex: string): string | null { + if (hex.length !== 8) return null; + const b0 = Number.parseInt(hex.slice(6, 8), 16); + const b1 = Number.parseInt(hex.slice(4, 6), 16); + const b2 = Number.parseInt(hex.slice(2, 4), 16); + const b3 = Number.parseInt(hex.slice(0, 2), 16); + if ( + !Number.isFinite(b0) || + !Number.isFinite(b1) || + !Number.isFinite(b2) || + !Number.isFinite(b3) + ) { + return null; + } + return `${b0}.${b1}.${b2}.${b3}`; +} + +/** + * Parse an IPv6 address from the hex form in /proc/net/tcp6. The kernel + * writes four 32-bit words, each in little-endian byte order within the + * word — so "0000000000000000FFFF00000100007F" is ::ffff:127.0.0.1, not a + * nonsense string. We render the canonical long form (e.g. "0:0:0:0:0:0:0:1") + * rather than RFC 5952's "::1" because consumers just forward this to URL + * builders that tolerate either. + */ +export function parseIPv6Hex(hex: string): string | null { + if (hex.length !== 32) return null; + const groups: string[] = []; + for (let wordIdx = 0; wordIdx < 4; wordIdx++) { + const word = hex.slice(wordIdx * 8, (wordIdx + 1) * 8); + // Reverse the 4 bytes of this word into network byte order. + const be = + word.slice(6, 8) + word.slice(4, 6) + word.slice(2, 4) + word.slice(0, 2); + groups.push(be.slice(0, 4), be.slice(4, 8)); + } + // Strip leading zeros per group but leave at least one digit. + return groups.map((g) => g.replace(/^0+/, "") || "0").join(":"); +} + +/** + * Parse a single /proc/net/tcp or /proc/net/tcp6 line into a listener record. + * Returns null for header lines, non-LISTEN states, or malformed rows. + * + * Exported for testing — reading real /proc in unit tests is noisy, so we + * feed canned lines here instead. + */ +export function parseProcNetLine( + line: string, + isIPv6: boolean, +): ProcNetListener | null { + const cols = line.trim().split(/\s+/); + // Kernel writes at least 17 columns (sl, local, remote, state, tx_queue, + // rx_queue, tr, tm_when, retrnsmt, uid, timeout, inode, ...). We only + // need up to column 10 (inode). + const localAddr = cols[1]; + const state = cols[3]; + const inodeStr = cols[9]; + if ( + cols.length < 10 || + localAddr === undefined || + state === undefined || + inodeStr === undefined + ) { + return null; + } + if (state !== TCP_STATE_LISTEN) return null; + + const colonIdx = localAddr.lastIndexOf(":"); + if (colonIdx < 0) return null; + const hexIP = localAddr.slice(0, colonIdx); + const hexPort = localAddr.slice(colonIdx + 1); + + const port = Number.parseInt(hexPort, 16); + if (!Number.isFinite(port) || port < 1 || port > 65535) return null; + + const inode = Number.parseInt(inodeStr, 10); + if (!Number.isFinite(inode) || inode <= 0) return null; + + const address = isIPv6 ? parseIPv6Hex(hexIP) : parseIPv4Hex(hexIP); + if (address === null) return null; + + return { port, inode, address }; +} + +async function readProcNetFile( + path: string, + isIPv6: boolean, +): Promise { + let content: string; + try { + content = await fs.readFile(path, "utf-8"); + } catch { + // /proc/net/tcp6 may not exist on IPv6-disabled kernels — silent skip. + return []; + } + + const listeners: ProcNetListener[] = []; + // Skip the header row. + const lines = content.split("\n").slice(1); + for (const line of lines) { + if (!line.trim()) continue; + const parsed = parseProcNetLine(line, isIPv6); + if (parsed) listeners.push(parsed); + } + return listeners; +} + +function createLimiter( + concurrency: number, +): (fn: () => Promise) => Promise { + let active = 0; + const queue: Array<() => void> = []; + + return async (fn: () => Promise): Promise => { + if (active >= concurrency) { + await new Promise((resolve) => { + queue.push(resolve); + }); + } + + active++; + try { + return await fn(); + } finally { + active--; + queue.shift()?.(); + } + }; +} + +/** + * Walk /proc//fd/ for each PID we care about and build an inode → pid + * map. We ignore fds we can't read — they may have been closed between + * readdir and readlink (fd table races), or the process may have exited. + */ +async function buildInodeToPid( + pids: Iterable, + pidRank: Map, + signal?: AbortSignal, +): Promise> { + const inodeToPid = new Map(); + const limitReadlink = createLimiter(FD_READLINK_CONCURRENCY); + + await Promise.all( + Array.from(pids, async (pid) => { + signal?.throwIfAborted(); + let entries: string[]; + try { + entries = await fs.readdir(`/proc/${pid}/fd`); + } catch { + // Process exited, or we lack permission for another user's fds. + return; + } + + await Promise.all( + entries.map((fd) => + limitReadlink(async () => { + signal?.throwIfAborted(); + try { + const link = await fs.readlink(`/proc/${pid}/fd/${fd}`); + const match = link.match(/^socket:\[(\d+)\]$/); + const inodeStr = match?.[1]; + if (inodeStr === undefined) return; + const inode = Number.parseInt(inodeStr, 10); + if (!Number.isFinite(inode) || inode <= 0) return; + // If multiple PIDs share a listening socket (prefork + // servers, inherited fds), choose deterministically from + // the caller's PID order instead of whichever async + // readlink finishes last. This keeps port identity stable + // across scans. + const existingPid = inodeToPid.get(inode); + if ( + existingPid === undefined || + (pidRank.get(pid) ?? Number.POSITIVE_INFINITY) < + (pidRank.get(existingPid) ?? Number.POSITIVE_INFINITY) + ) { + inodeToPid.set(inode, pid); + } + } catch { + // fd closed between readdir and readlink — normal. + } + }), + ), + ); + }), + ); + + return inodeToPid; +} + +/** Read /proc//comm — kernel stores the task name (max 15 chars + NUL). */ +async function readProcessName(pid: number): Promise { + try { + const content = await fs.readFile(`/proc/${pid}/comm`, "utf-8"); + return content.trim() || "unknown"; + } catch { + return "unknown"; + } +} + +/** + * Linux implementation of `getListeningPortsForPids` backed by /proc. + * Returns an empty array if /proc reads fail — caller treats empty as + * "nothing listening" identically to the lsof path, so there's no need + * to distinguish failures from genuine empties. + */ +export async function getListeningPortsLinuxProcfs( + pids: number[], + signal?: AbortSignal, +): Promise { + if (pids.length === 0) return []; + + const pidSet = new Set(pids); + const pidRank = new Map(pids.map((pid, index) => [pid, index])); + + try { + // Walk fds and read /proc/net/tcp{,6} concurrently — they're independent. + const [inodeToPid, ipv4Listeners, ipv6Listeners] = await Promise.all([ + buildInodeToPid(pidSet, pidRank, signal), + readProcNetFile("/proc/net/tcp", false), + readProcNetFile("/proc/net/tcp6", true), + ]); + + if (inodeToPid.size === 0) return []; + + const nameCache = new Map(); + const matches: PortInfo[] = []; + for (const listener of [...ipv4Listeners, ...ipv6Listeners]) { + const pid = inodeToPid.get(listener.inode); + if (pid === undefined) continue; + + let processName = nameCache.get(pid); + if (processName === undefined) { + processName = await readProcessName(pid); + nameCache.set(pid, processName); + } + + matches.push({ + port: listener.port, + pid, + address: listener.address, + processName, + }); + } + + return matches; + } catch (err) { + if (signal?.aborted) throw err; + return []; + } +} diff --git a/apps/desktop/src/main/lib/terminal/port-scanner.test.ts b/packages/port-scanner/src/scanner.test.ts similarity index 88% rename from apps/desktop/src/main/lib/terminal/port-scanner.test.ts rename to packages/port-scanner/src/scanner.test.ts index ce36d111bcc..c8d9eb59204 100644 --- a/apps/desktop/src/main/lib/terminal/port-scanner.test.ts +++ b/packages/port-scanner/src/scanner.test.ts @@ -45,20 +45,30 @@ function parseLsofOutput( if (columns.length < 10) continue; const processName = columns[0]; - const pid = Number.parseInt(columns[1], 10); + const pidStr = columns[1]; + const name = columns[columns.length - 2]; // NAME column (e.g., *:3000), before (LISTEN) + if ( + processName === undefined || + pidStr === undefined || + name === undefined + ) { + continue; + } + + const pid = Number.parseInt(pidStr, 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); + const portStr = match[3]; + if (portStr === undefined) continue; + const port = Number.parseInt(portStr, 10); // Skip invalid ports if (port < 1 || port > 65535) continue; @@ -187,8 +197,8 @@ 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); + expect(ports[0]?.port).toBe(3000); + expect(ports[1]?.port).toBe(4000); }); it("should skip invalid port numbers", () => { @@ -200,11 +210,10 @@ node 12345 user 25u IPv4 0x1234567890ad 0t0 TCP *:3000 (LISTEN)` const ports = parseLsofOutput(output); expect(ports).toHaveLength(1); - expect(ports[0].port).toBe(3000); + 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) @@ -234,17 +243,14 @@ postgres 3457 kietho 8u IPv4 0xb4db4c0cd4dfeb63 0t0 T }); 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"); + expect(ports[0]?.port).toBe(3000); + expect(ports[0]?.address).toBe("0.0.0.0"); }); it("should handle process names with different lengths", () => { @@ -255,10 +261,10 @@ verylongprocessname 67890 user 24u IPv4 0x1234567890ac 0t0 TCP *:4000 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); + expect(ports[0]?.processName).toBe("n"); + expect(ports[0]?.port).toBe(3000); + expect(ports[1]?.processName).toBe("verylongprocessname"); + expect(ports[1]?.port).toBe(4000); }); }); @@ -269,15 +275,14 @@ 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); + 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", () => { @@ -285,7 +290,6 @@ ruby 99999 user 6u IPv4 0x1234567890ae 0t0 TCP *:9000 (LISTEN)` 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); @@ -297,30 +301,24 @@ python 67890 user 5u IPv4 0x1234567890ad 0t0 TCP *:8000 (LISTEN)` 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); + 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/packages/port-scanner/src/scanner.ts similarity index 82% rename from apps/desktop/src/main/lib/terminal/port-scanner.ts rename to packages/port-scanner/src/scanner.ts index 1b6089de6ad..876d52bf63c 100644 --- a/apps/desktop/src/main/lib/terminal/port-scanner.ts +++ b/packages/port-scanner/src/scanner.ts @@ -2,6 +2,7 @@ import { execFile } from "node:child_process"; import os from "node:os"; import { promisify } from "node:util"; import pidtree from "pidtree"; +import { getListeningPortsLinuxProcfs } from "./procfs"; const execFileAsync = promisify(execFile); @@ -79,7 +80,10 @@ export async function getListeningPortsForPids( const platform = os.platform(); - if (platform === "darwin" || platform === "linux") { + if (platform === "linux") { + return getListeningPortsLinuxProcfs(pids, signal); + } + if (platform === "darwin") { return getListeningPortsLsof(pids, signal); } if (platform === "win32") { @@ -123,33 +127,43 @@ async function getListeningPortsLsof( // 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); + const pidStr = columns[1]; + const name = columns[columns.length - 2]; // before (LISTEN) + if ( + columns.length < 10 || + processName === undefined || + pidStr === undefined || + name === undefined + ) { + continue; + } + + const pid = Number.parseInt(pidStr, 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, - }); - } + if (!match) continue; + + // match[3] is the mandatory port group; one of match[1]/[2] is the host. + const portStr = match[3]; + if (portStr === undefined) continue; + const address = match[1] || match[2] || "*"; + const port = Number.parseInt(portStr, 10); + + if (port < 1 || port > 65535) continue; + + ports.push({ + port, + pid, + address: address === "*" ? "0.0.0.0" : address, + processName, + }); } return ports; @@ -184,9 +198,10 @@ async function getListeningPortsWindows( // 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 pidStr = columns[columns.length - 1]; + if (columns.length < 5 || pidStr === undefined) continue; - const pid = Number.parseInt(columns[columns.length - 1], 10); + const pid = Number.parseInt(pidStr, 10); if (!pidSet.has(pid)) continue; if (!processNames.has(pid) && !pidsToLookup.includes(pid)) { @@ -210,29 +225,32 @@ async function getListeningPortsWindows( if (!line.includes("LISTENING")) continue; const columns = line.trim().split(/\s+/); - if (columns.length < 5) continue; + const pidStr = columns[columns.length - 1]; + const localAddr = columns[1]; + if (columns.length < 5 || pidStr === undefined || localAddr === undefined) + continue; - const pid = Number.parseInt(columns[columns.length - 1], 10); + const pid = Number.parseInt(pidStr, 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 + // 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; - - ports.push({ - port, - pid, - address, - processName: processNames.get(pid) || "unknown", - }); - } + if (!match) continue; + + const portStr = match[3]; + if (portStr === undefined) continue; + const address = match[1] || match[2] || "0.0.0.0"; + const port = Number.parseInt(portStr, 10); + + if (port < 1 || port > 65535) continue; + + ports.push({ + port, + pid, + address, + processName: processNames.get(pid) || "unknown", + }); } return ports; @@ -255,8 +273,9 @@ async function getProcessNameWindows( { timeout: EXEC_TIMEOUT_MS, signal }, ); const lines = output.trim().split("\n"); - if (lines.length >= 2) { - const name = lines[1].trim(); + const secondLine = lines[1]; + if (secondLine) { + const name = secondLine.trim(); return name.replace(/\.exe$/i, "") || "unknown"; } } catch { diff --git a/packages/port-scanner/src/static-ports.ts b/packages/port-scanner/src/static-ports.ts new file mode 100644 index 00000000000..59bc4162d74 --- /dev/null +++ b/packages/port-scanner/src/static-ports.ts @@ -0,0 +1,103 @@ +export interface StaticPortLabel { + port: number; + label: string; +} + +export type StaticPortsParseResult = + | { ports: StaticPortLabel[]; error: null } + | { ports: null; error: string }; + +function validatePortEntry( + entry: unknown, + index: number, +): + | { valid: true; port: number; label: string } + | { valid: false; error: string } { + if (typeof entry !== "object" || entry === null) { + return { valid: false, error: `ports[${index}] must be an object` }; + } + + if (!("port" in entry)) { + return { + valid: false, + error: `ports[${index}] is missing required field 'port'`, + }; + } + + if (!("label" in entry)) { + return { + valid: false, + error: `ports[${index}] is missing required field 'label'`, + }; + } + + const { port, label } = entry as { port: unknown; label: unknown }; + + if (typeof port !== "number" || !Number.isInteger(port)) { + return { valid: false, error: `ports[${index}].port must be an integer` }; + } + + if (port < 1 || port > 65535) { + return { + valid: false, + error: `ports[${index}].port must be between 1 and 65535`, + }; + } + + if (typeof label !== "string") { + return { valid: false, error: `ports[${index}].label must be a string` }; + } + + if (label.trim() === "") { + return { valid: false, error: `ports[${index}].label cannot be empty` }; + } + + return { valid: true, port, label: label.trim() }; +} + +export function parseStaticPortsConfig( + content: string, +): StaticPortsParseResult { + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ports: null, error: `Invalid JSON in ports.json: ${message}` }; + } + + if (typeof parsed !== "object" || parsed === null) { + return { ports: null, error: "ports.json must contain a JSON object" }; + } + + if (!("ports" in parsed)) { + return { + ports: null, + error: "ports.json is missing required field 'ports'", + }; + } + + const portsField = (parsed as { ports: unknown }).ports; + if (!Array.isArray(portsField)) { + return { ports: null, error: "'ports' field must be an array" }; + } + + const ports: StaticPortLabel[] = []; + const seenPorts = new Set(); + for (let index = 0; index < portsField.length; index++) { + const result = validatePortEntry(portsField[index], index); + if (!result.valid) { + return { ports: null, error: result.error }; + } + if (seenPorts.has(result.port)) { + return { + ports: null, + error: `ports[${index}].port duplicates an earlier entry`, + }; + } + seenPorts.add(result.port); + ports.push({ port: result.port, label: result.label }); + } + + return { ports, error: null }; +} diff --git a/packages/port-scanner/src/types.ts b/packages/port-scanner/src/types.ts new file mode 100644 index 00000000000..29fce181ee3 --- /dev/null +++ b/packages/port-scanner/src/types.ts @@ -0,0 +1,9 @@ +export interface DetectedPort { + port: number; + pid: number; + processName: string; + terminalId: string; + workspaceId: string; + detectedAt: number; + address: string; +} diff --git a/packages/port-scanner/tsconfig.json b/packages/port-scanner/tsconfig.json new file mode 100644 index 00000000000..9025172a9ba --- /dev/null +++ b/packages/port-scanner/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@superset/typescript/internal-package.json", + "compilerOptions": { + "types": ["bun-types"], + "noUncheckedIndexedAccess": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/workspace-client/src/index.ts b/packages/workspace-client/src/index.ts index 3509a7b8af8..53ff748d691 100644 --- a/packages/workspace-client/src/index.ts +++ b/packages/workspace-client/src/index.ts @@ -5,6 +5,7 @@ export { type EventBusHandle, type GitChangedPayload, getEventBus, + type PortChangedPayload, type TerminalLifecyclePayload, } from "./lib/eventBus"; export { diff --git a/packages/workspace-client/src/lib/eventBus.ts b/packages/workspace-client/src/lib/eventBus.ts index 24cef2ee840..88ad858a640 100644 --- a/packages/workspace-client/src/lib/eventBus.ts +++ b/packages/workspace-client/src/lib/eventBus.ts @@ -9,7 +9,8 @@ type EventType = | "fs:events" | "git:changed" | "agent:lifecycle" - | "terminal:lifecycle"; + | "terminal:lifecycle" + | "port:changed"; interface FsEventsPayload { events: FsWatchEvent[]; @@ -37,6 +38,15 @@ export interface TerminalLifecyclePayload { occurredAt: number; } +type PortChangedMessage = Extract; + +export interface PortChangedPayload { + eventType: PortChangedMessage["eventType"]; + port: PortChangedMessage["port"]; + label: PortChangedMessage["label"]; + occurredAt: number; +} + type EventListener = T extends "fs:events" ? (workspaceId: string, payload: FsEventsPayload) => void : T extends "git:changed" @@ -45,7 +55,9 @@ type EventListener = T extends "fs:events" ? (workspaceId: string, payload: AgentLifecyclePayload) => void : T extends "terminal:lifecycle" ? (workspaceId: string, payload: TerminalLifecyclePayload) => void - : never; + : T extends "port:changed" + ? (workspaceId: string, payload: PortChangedPayload) => void + : never; interface ListenerEntry { type: EventType; @@ -101,7 +113,8 @@ function handleMessage(state: ConnectionState, data: unknown): void { message.type === "fs:events" || message.type === "git:changed" || message.type === "agent:lifecycle" || - message.type === "terminal:lifecycle" + message.type === "terminal:lifecycle" || + message.type === "port:changed" ? message.workspaceId : null; @@ -141,6 +154,13 @@ function handleMessage(state: ConnectionState, data: unknown): void { occurredAt: message.occurredAt, }, ); + } else if (message.type === "port:changed") { + (entry.callback as EventListener<"port:changed">)(message.workspaceId, { + eventType: message.eventType, + port: message.port, + label: message.label, + occurredAt: message.occurredAt, + }); } } } diff --git a/plans/20260422-v2-remote-ports.md b/plans/20260422-v2-remote-ports.md new file mode 100644 index 00000000000..552a2ccd9cc --- /dev/null +++ b/plans/20260422-v2-remote-ports.md @@ -0,0 +1,215 @@ +# v2 Port Surfacing Across Local + Remote Host Services + +**Date:** 2026-04-22 +**Status:** In review on PR #3676 + +## Goal + +Show listening ports in the v2 sidebar for workspaces whose terminals run locally (desktop) *and* for workspaces whose terminals run in a remote `host-service`. The v1 perf lessons (issue #3372) must be preserved: strict hint patterns, debounced scans, one-in-flight scan per host, abortable children. + +## Guiding principle + +**Scan where the PID lives, and key ports by terminal session.** PIDs are only meaningful on the host that owns the process. A listening port belongs to a process tree rooted at a terminal session, not to a renderer pane. Don't ship PIDs across the wire to scan elsewhere; ship fully-resolved port records keyed by `terminalId` instead. The sidebar consumes per-host port snapshots and groups them by workspace without caring which host detected each port. + +This matches the recent v2 notification model: notification attention is keyed +by durable sources (`terminalId` for terminal panes, chat session id for chat +panes), and panes/tabs are only views over those sources. Ports should follow +the same boundary. A port row can open the workspace and route kill/open actions +through the host-service that owns the terminal, but it should not carry or +depend on renderer pane focus identity. + +## Current state + +- Local detection: `apps/desktop/src/main/lib/terminal/port-manager.ts` (singleton, 2.5s poll + hint-debounce) + `port-scanner.ts` (lsof/netstat). +- Exposure to UI: `apps/desktop/src/lib/trpc/routers/ports/ports.ts` — `getAll`, `subscribe` (observable), `kill`. +- Consumers: + - v2: `apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/` + - v1/legacy: `apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/` remains local-only. +- Types: `apps/desktop/src/shared/types/ports.ts` — `DetectedPort`, `EnrichedPort`. +- Host-service terminals: `packages/host-service/src/terminal/terminal.ts`, session rows in `packages/host-service/src/db/schema.ts` (`terminalSessions`). No port detection today. + +## Target architecture + +```text + ┌────────────────────────┐ ┌────────────────────────────┐ + │ desktop main process │ │ host-service (remote) │ + │ │ │ │ + │ port-manager (local) │ │ port-manager (remote) │ + │ └── port-scanner │ │ └── port-scanner │ + │ (shared pkg) │ │ (shared pkg) │ + │ │ │ │ + │ emits add/remove ─────┐│ │ emits add/remove ─────────┐│ + │ ▼│ │ ▼│ + │ ports router + events │ │ ports router + event bus │ + └────────────┬───────────┘ └──────────────┬─────────────┘ + │ │ + │ │ (tunnel tRPC + WS) + ▼ ▼ + ┌──────────────────────────────────────────────┐ + │ desktop renderer: DashboardSidebarPortsList │ + │ patches per-host snapshots from events │ + │ refetches getAll as reconnect fallback │ + │ groups by workspaceId │ + └──────────────────────────────────────────────┘ +``` + +Both hosts emit the same `DetectedPort` shape. The renderer is the only place that knows "some workspaces are remote." + +## Work breakdown + +### 1. Extract shared scanner → `packages/port-scanner` + +New package. Zero dependencies beyond `pidtree` and node built-ins so it runs in both desktop main and host-service. + +- Move `apps/desktop/src/main/lib/terminal/port-scanner.ts` → `packages/port-scanner/src/scanner.ts`. +- Move the `PortManager` class → `packages/port-scanner/src/port-manager.ts`, but **remove the singleton export**. Callers instantiate their own. The singleton pattern bleeds state in tests and blocks running two managers in one host-service process. +- Keep `DetectedPort` in `apps/desktop/src/shared/types/ports.ts` for now (UI owns the wire shape); import it from the shared package via a peer type, or duplicate it — v1/v2 duplication is acceptable (per project convention). +- Update desktop imports: `main/lib/terminal/port-manager` → `@superset/port-scanner`. + +No behavior change in this step. Land it alone to de-risk. + +### 2. Host-service port manager + +- `packages/host-service/src/ports/port-manager.ts`: thin wrapper that instantiates `PortManager` from the shared package and wires it to the host-service terminal registry. +- Terminal lifecycle hooks in `packages/host-service/src/terminal/terminal.ts`: call `portManager.upsertSession(terminalId, workspaceId, pid)` on spawn and `unregisterSession(terminalId)` on exit. The existing terminal session lifecycle fits the shared manager model. +- Pipe PTY output through `portManager.checkOutputForHint(data)` at the same site that already streams to the renderer. + +### 3. Host-service tRPC `ports` router + +- `packages/host-service/src/trpc/router/ports/ports.ts`: mirror of `apps/desktop/src/lib/trpc/routers/ports/ports.ts`. + - `getAll({ workspaceIds })` → enriched detected ports for the requested workspaces. `.superset/ports.json` is supplemental label metadata only: it names ports that were already detected as listening; it does not create static rows and does not replace dynamic discovery. Host-service can load labels from its own filesystem since it owns the worktree. + - `subscribe({ workspaceIds })` → observable of `{ type: 'add' | 'remove', port }` scoped to the requested workspaces. **Note:** this is a useful router-level API, but the dashboard sidebar should prefer the unified host-service event bus so one WebSocket carries git, terminal, notification, filesystem, and port events for a host. + - `kill({ workspaceId, terminalId, port })` → forwards to host-service port manager after verifying the tracked port belongs to that workspace and terminal session. +- Register under the existing host-service router. + +### 4. Desktop: merge local + remote host-service results + +Use a v2-specific sidebar component instead of wiring remote host-service +polling into the legacy `WorkspaceSidebar` path. + +`DashboardSidebarPortsList` reads v2 hosts/workspaces from the renderer DB +collections, queries each relevant host-service `ports.getAll`, and groups the +result by workspace. Local v2 workspaces query the local host-service through +`activeHostUrl`; remote v2 workspaces query the relay URL for their host. +The sidebar then subscribes to each queried host's unified event bus and patches +the corresponding React Query snapshot from `port:changed` events. Events are +batched briefly before cache writes, and `ports.getAll` still runs on a slow +fallback interval so reconnects, dropped WebSocket messages, and version skew +converge without making the normal UI path wait for the next poll. + +Pros: no proxy code in desktop main; remote failures are local to the sidebar +query and don't affect other hosts. The renderer owns one query and one shared +event-bus connection per relevant host, matching the v2 terminal model while +keeping PID-local scanning on the owning host. + +Cons: renderer still fans out per host. Fine for small N; revisit with a +sidebar aggregate endpoint if many simultaneous host-services become common. + +Rejected alternative: a desktop-main proxy that subscribes to each host-service +and re-emits through a singleton. It duplicates buffering, partitions errors +awkwardly, and leaks host-service identity into desktop-main state for no real +benefit. + +### 5. Sidebar display + +`DashboardSidebarPortsList` is mounted in the v2 `DashboardSidebar`. It groups +ports by workspace and shows an origin badge (local / remote) because v2 can +mix host-service owners in one sidebar. + +The kill button routes through the same host-service client that produced the +port row using `workspaceId + terminalId + port`. Browser-open is only enabled +for local-device ports, where `localhost:` is meaningful, and its click +intent uses the same current-tab/new-tab/external split as file/tree open +actions. Clicking a port opens the workspace with `terminalId` plus a fresh +focus request; the client resolves that to a pane/tab. Ports still do not carry +renderer pane identity. + +### 6. Schema + +**No schema changes.** The `terminalSessions` table (`packages/host-service/src/db/schema.ts:9`) already has everything the manager needs (`terminalId`, `workspaceId`, `pid`). Ports are runtime state — persisting them adds no value and costs writes on every 2.5s scan. + +## Perf safeguards (carry over from v1) + +Already baked into the shared `PortManager`, but call them out explicitly so they don't regress during the extract: + +- `containsPortHint` patterns stay strict (listening on / server started|running on / ready on). +- `isScanning` guard + `scanRequested` follow-up queue. +- `scanAbort` aborts in-flight `lsof`/`netstat` on teardown. +- `IGNORED_PORTS` filter. +- `SCAN_INTERVAL_MS = 2500`, `HINT_SCAN_DELAY_MS = 500` unchanged. + +## Rollout + +1. Ship step 1 (extract). Pure refactor, green CI proves equivalence. +2. Ship steps 2+3 behind host-service feature flag (if one exists) or just default-on — host-service is new enough that there's no back-compat to preserve. Per project memory, host-service/cloud deploys before desktop. +3. Ship step 4 in the desktop client. Per project memory, new cloud endpoints are safe to call from new desktop builds since cloud deploys first. + +## Pre-extract fixes (from v1 audit) + +Land these in step 1 so the shared package starts clean. + +**Blockers:** +- `port-manager.ts:124,151` — `scanAbort` can be `undefined` when a lingering `hintScanTimeout` fires after `stopPeriodicScan`. Lazy-allocate at the top of `scanAllSessions`. +- `ports.ts:36-45` + `usePortsData.ts:28` — DB `SELECT workspace` per unique `workspaceId` per `getAll`, and `getAll` is re-run on every `port:add`/`port:remove`. With a dev server churning ports this is a cascade of sync `better-sqlite3` reads on the main thread. Cache `workspaceId → labels` for supplemental `.superset/ports.json` names (invalidate on workspace CRUD), or coalesce `invalidate()` in the renderer with a 50ms debounce. + +**Worth-fixing:** +- Delete `registerSession`/`unregisterSession` — no production callers (only tests). Only `upsertDaemonSession` is wired from `daemon-manager.ts`. Simplifies the extracted class. +- `port-manager.ts:317-350` — replace tail-recursion on `scanRequested` with `while (this.scanRequested) { … }`. +- `port-manager.ts:402-407` — O(ports × terminals) sweep per tick. Partition `this.ports` into `Map>`. +- `port-scanner.ts:128-152` — lsof parser is fragile on `COMMAND` names with spaces (e.g. `"Google Chrome Helper"`). Switch to `lsof -F pcPn` field output — trivially parseable, no column-index arithmetic. +- Hint regex adds: Vite/Next.js print `Local: http://localhost:5173/` with no "listening/ready". Add `/\bLocal:\s+https?:\/\//i` and `/development server at/i`. Steal VS Code's three regexes verbatim (see below) — they're the de-facto reference. +- `IGNORED_PORTS` filters 5432/3306/6379/27017 globally. Devs often *do* want to see a dockerized Postgres spun up by their dev shell. Narrow to 22/80/443 or make the filter opt-in per workspace. +- Windows: `wmic` is removed in 11 24H2 / Server 2025. The code falls through to PowerShell-per-PID which is slow. Replace with one `Get-CimInstance Win32_Process -Filter "ProcessId IN (…)"` call, or skip netstat entirely and rely on URL-regex scraping (what VS Code does on Windows). + +**Nits:** clear `scanRequested` in `stopPeriodicScan`; log (don't swallow) `EACCES` in `getListeningPortsLsof`; cap `ports` Map at ~500 entries as a belt-and-braces leak guard. + +**Preserve during extract (do not regress):** +- Two-level abort (`scanAbort` + `runTolerant` rethrowing on abort). +- `pidSet.has(pid)` recheck on lsof output — lsof returns *everything* if `-p` resolves to zero matches. The "CRITICAL" comment is right. +- `unref()` on timers — required for clean Electron exit. +- Hint-scan debounce via `hintScanTimeout` guard — protects against the #3372 regression. +- Host-service `port:changed` events patch the dashboard cache immediately; + the 30s `ports.getAll` interval is a fallback, not the responsiveness path. + +## Prior art — steal from VS Code & Gitpod + +Big finding: **VS Code and Gitpod both read `/proc/net/tcp{,6}` directly on Linux** — no `lsof` subprocess at all. For the host-service scanner (which will almost always run on Linux), this is a meaningful win. + +- [VS Code `extHostTunnelService.ts`](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/node/extHostTunnelService.ts) — `loadListeningPorts` reads procfs, filters state `0A`, parses big-endian hex IPs; correlates socket inodes → PIDs via `/proc//fd/*`. Uses a `MovingAverage` of scan cost and polls at `max(avg * 20, 2000ms)` — adaptive backoff. We should steal this. +- [VS Code `urlFinder.ts`](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/remote/browser/urlFinder.ts) — canonical hint regexes: + ```text + localUrlRegex: /\b\w{0,20}(?::\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0|:\d{2,5})[\w\-\.\~:\/\?\#[\]\@!\$&\(\)\*\+\,\;\=]*/gim + extractPortRegex: /(localhost|127\.0\.0\.1|0\.0\.0\.0):(\d{1,5})/ + localPythonServerRegex: /HTTP\son\s(127\.0\.0\.1|0\.0\.0\.0)\sport\s(\d+)/ + ``` +- [Gitpod `served-ports.go`](https://github.com/gitpod-io/gitpod/blob/main/components/supervisor/pkg/ports/served-ports.go) — same procfs strategy in Go. Runs inside the workspace container, never on the forwarded socket. Confirms our "scan on the host that owns the PID" principle. +- [Coder `ports_supported.go`](https://github.com/coder/coder/blob/main/agent/ports_supported.go) — uses `cakturk/go-netstat` (procfs on Linux, `GetExtendedTcpTable` on Windows). Also remote-host-local detection. +- VS Code on Windows: **does not spawn netstat.** Falls back entirely to terminal-output URL scraping. Worth considering for our Windows tier given `wmic` deprecation pain. + +### Revised scanner tier plan + +Three-tier, matching VS Code's split: + +1. **Linux** — read `/proc/net/tcp` + `/proc/net/tcp6`, filter state `0A`, map inodes → PIDs via `/proc//fd`. No subprocess. Cheapest path and the one that matters most (host-service runs on Linux). +2. **macOS** — keep `lsof` (no procfs). Switch to `-F pcPn` field output. This is the only tier that pays a subprocess cost, so apply VS Code's adaptive backoff here specifically. +3. **Windows** — `netstat -ano` once + single batched `Get-CimInstance` for names; OR skip net-enumeration entirely and rely on URL-regex scraping. Decide based on how many desktop users actually run on Windows. + +Polling cadence: replace fixed `SCAN_INTERVAL_MS = 2500` with `max(movingAvg * 20, 2000ms)` capped at e.g. 10s. Hint-triggered scans still fire immediately (debounced). + +## Open questions + +- **Resolved: `ports.json` semantics.** `.superset/ports.json` is supplemental + label metadata. It gives friendly names to ports that dynamic scanning already + detects. It must not create port rows, hide unlabelled detected ports, replace + dynamic detection, or make malformed label config suppress detected ports. +- **Resolved: port labels for remote workspaces.** Host-service reads + `.superset/ports.json` from its own worktree and returns enriched rows from + `ports.getAll`; both desktop and host-service refresh cached labels when the + file mtime/size changes. +- **Resolved: port identity.** Shared port records are terminal-owned + (`terminalId`), workspace-grouped (`workspaceId`), and host-scoped by the + client/router path. They do not include renderer `paneId` or any UI focus key. +- **Multi-host fan-out.** If a user connects to several host-services, the + renderer holds one polling query per relevant host. Fine for small N; revisit + if it grows. +- **Security.** Port kill across tRPC needs the same auth boundary as terminal kill — confirm host-service already gates this before exposing `ports.kill`.