From f276eeae102f9e7ac6f794639367eed5cafa7406 Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:12:19 -0700 Subject: [PATCH 1/2] feat(ports): surface remote host-service ports in the sidebar (#3676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ports): extract port scanner to shared package for v2 remote hosts Move port detection and PortManager into @superset/port-scanner so the host-service can surface remote workspace ports over tRPC subscriptions without duplicating scan logic. Desktop and host-service each wire their own kill strategy via a constructor-injected killFn. Also fixes two blockers found during extraction: - PortManager.scanAbort was nulled on stop() and never re-allocated, leaving subsequent scans without an AbortSignal. - The desktop ports router hit SQLite on every enrichment; now cached per workspace and invalidatable. * feat(ports): merge remote host-service ports into the sidebar list usePortsData now polls ports.getAll on every online remote v2 host every 5s (via tanstack useQueries) and merges the results with the local Electron port stream. Each EnrichedPort carries a hostUrl so useKillPort can route kills to the owning host-service instead of always hitting Electron. Polling rather than SSE-subscribing keeps the existing httpBatchLink client intact — the host-service already debounces scans behind hints, so ports.getAll is a cheap in-memory read. * feat(ports): resolve ports.json labels on remote host-services Host-service's ports.getAll now returns EnrichedPort[] by reading each workspace's .superset/ports.json and attaching a per-port label. The lookup is memoized per workspaceId so the 5s renderer poll doesn't retrigger fs+sqlite reads. Parse errors are silent here (unlike desktop's loader, which surfaces them): this endpoint is a best-effort hint feeding a sidebar list, not a config validator. * fix(ports): drop label cache entries when workspaces are deleted Previously the label cache held a workspaceId → labels map for the life of the process. After a workspace was deleted, the next lookup would still return cached labels (or the cached "null" sentinel), both of which are stale. Desktop: split the cache out of the ports router so workspace-delete paths can invalidate without creating a ports ↔ workspaces import cycle, then call it from `deleteWorkspace` and the project-cascade delete. Host-service: call `invalidateLabelCache` from `workspace.delete` and `workspace-cleanup` after the sqlite row is removed. Rollback-on-create paths are intentionally skipped — the workspace never had a cache entry to begin with. * perf(port-scanner): read /proc directly on Linux instead of spawning lsof On Linux we now resolve listening sockets by reading /proc/net/tcp, /proc/net/tcp6, and /proc//fd/* in process. This is the same approach VS Code uses for its remote tunnel port detection (see src/vs/workbench/contrib/remoteTunnel). macOS keeps the lsof path; Windows keeps netstat. The switch matters for remote v2 host-services running on Linux: the old lsof fork happened on every scan tick (2.5s periodic + hint-driven bursts) and dominated cost on hosts with many terminal daemons. Two file reads + one directory walk is roughly 10× cheaper and avoids child-process lifecycle (timeouts, aborts, partial stdout). Parsers are pure and exported so we test them on canned /proc lines rather than requiring a real /proc mount in CI. * fix(ports): wire v2 sidebar port list * fix(ports): address review cleanups * Fix v2 port sidebar interactions * Add host port events for v2 sidebar * Patch v2 port cache from host events * Fix port sidebar event and kill contracts * Refactor dashboard port data transforms --- apps/desktop/package.json | 1 + .../plans/20260108-2251-static-ports-json.md | 7 + .../src/lib/trpc/routers/ports/label-cache.ts | 153 +++++ .../src/lib/trpc/routers/ports/ports.ts | 11 +- .../src/lib/trpc/routers/projects/projects.ts | 2 + .../routers/workspaces/utils/db-helpers.ts | 2 + .../src/main/lib/static-ports/loader.test.ts | 17 + .../src/main/lib/static-ports/loader.ts | 117 +--- .../terminal/daemon/daemon-manager.test.ts | 4 +- .../lib/terminal/daemon/daemon-manager.ts | 12 +- .../main/lib/terminal/port-manager.test.ts | 261 --------- .../src/main/lib/terminal/port-manager.ts | 478 +-------------- apps/desktop/src/renderer/globals.css | 14 + .../useWorkspaceEvent/useWorkspaceEvent.ts | 26 +- .../hooks/ports/usePortKillActions/index.ts | 7 + .../usePortKillActions/killPortTarget.test.ts | 74 +++ .../usePortKillActions/killPortTarget.ts | 51 ++ .../usePortKillActions/usePortKillActions.ts | 90 +++ .../DashboardSidebar/DashboardSidebar.tsx | 2 + .../DashboardSidebarPortsList.tsx | 80 +++ .../DashboardSidebarPortBadge.tsx | 146 +++++ .../DashboardSidebarPortBadge/index.ts | 1 + .../DashboardSidebarPortGroup.tsx | 80 +++ .../DashboardSidebarPortGroup/index.ts | 1 + .../useDashboardSidebarPortKill/index.ts | 1 + .../useDashboardSidebarPortKill.ts | 10 + .../useDashboardSidebarPortsData/index.ts | 5 + .../useDashboardSidebarPortsData.test.ts | 330 +++++++++++ .../useDashboardSidebarPortsData.ts | 193 +++++++ .../useDashboardSidebarPortsData.utils.ts | 225 ++++++++ .../DashboardSidebarPortsList/index.ts | 1 + .../_dashboard/utils/workspace-navigation.ts | 15 + .../hooks/useConsumeOpenUrlRequest/index.ts | 4 + .../useConsumeOpenUrlRequest.test.ts | 31 + .../useConsumeOpenUrlRequest.ts | 51 ++ .../components/TerminalPane/TerminalPane.tsx | 11 +- .../v2-workspace/$workspaceId/page.tsx | 51 +- .../getSidebarClickIntent.test.ts | 41 ++ .../getSidebarClickIntent.ts | 28 +- .../utils/getSidebarClickIntent/index.ts | 3 + .../utils/openUrlInV2Workspace/index.ts | 4 + .../openUrlInV2Workspace.ts | 30 + .../WorkspaceSidebar/PortsList/PortsList.tsx | 2 +- .../MergedPortBadge/MergedPortBadge.tsx | 82 +-- .../WorkspacePortGroup/WorkspacePortGroup.tsx | 31 +- .../PortsList/hooks/useKillPort.ts | 14 +- .../PortsList/hooks/usePortsData.ts | 17 +- apps/desktop/src/shared/types/ports.ts | 19 +- apps/docs/content/docs/ports.mdx | 30 +- bun.lock | 17 + packages/host-service/package.json | 2 + .../host-service/src/events/event-bus.test.ts | 68 +++ packages/host-service/src/events/event-bus.ts | 55 ++ packages/host-service/src/events/index.ts | 1 + packages/host-service/src/events/types.ts | 11 + .../host-service/src/ports/port-manager.ts | 6 + .../host-service/src/ports/static-ports.ts | 138 +++++ packages/host-service/src/ports/tree-kill.ts | 121 ++++ .../host-service/src/terminal/terminal.ts | 8 + .../src/trpc/router/ports/index.ts | 1 + .../src/trpc/router/ports/ports.ts | 115 ++++ .../host-service/src/trpc/router/router.ts | 2 + .../workspace-cleanup/workspace-cleanup.ts | 2 + .../src/trpc/router/workspace/workspace.ts | 2 + packages/port-scanner/package.json | 25 + packages/port-scanner/src/index.ts | 16 + .../port-scanner/src/port-manager.test.ts | 377 ++++++++++++ packages/port-scanner/src/port-manager.ts | 542 ++++++++++++++++++ packages/port-scanner/src/procfs.test.ts | 85 +++ packages/port-scanner/src/procfs.ts | 292 ++++++++++ .../port-scanner/src/scanner.test.ts | 60 +- .../port-scanner/src/scanner.ts | 101 ++-- packages/port-scanner/src/static-ports.ts | 103 ++++ packages/port-scanner/src/types.ts | 9 + packages/port-scanner/tsconfig.json | 9 + packages/workspace-client/src/index.ts | 1 + packages/workspace-client/src/lib/eventBus.ts | 26 +- plans/20260422-v2-remote-ports.md | 215 +++++++ 78 files changed, 4245 insertions(+), 1031 deletions(-) create mode 100644 apps/desktop/src/lib/trpc/routers/ports/label-cache.ts delete mode 100644 apps/desktop/src/main/lib/terminal/port-manager.test.ts create mode 100644 apps/desktop/src/renderer/hooks/ports/usePortKillActions/index.ts create mode 100644 apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.test.ts create mode 100644 apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.ts create mode 100644 apps/desktop/src/renderer/hooks/ports/usePortKillActions/usePortKillActions.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/DashboardSidebarPortsList.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortBadge/DashboardSidebarPortBadge.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortBadge/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortGroup/DashboardSidebarPortGroup.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortGroup/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortKill/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortKill/useDashboardSidebarPortKill.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.utils.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/useConsumeOpenUrlRequest.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/useConsumeOpenUrlRequest.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/getSidebarClickIntent.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace/openUrlInV2Workspace.ts create mode 100644 packages/host-service/src/events/event-bus.test.ts create mode 100644 packages/host-service/src/ports/port-manager.ts create mode 100644 packages/host-service/src/ports/static-ports.ts create mode 100644 packages/host-service/src/ports/tree-kill.ts create mode 100644 packages/host-service/src/trpc/router/ports/index.ts create mode 100644 packages/host-service/src/trpc/router/ports/ports.ts create mode 100644 packages/port-scanner/package.json create mode 100644 packages/port-scanner/src/index.ts create mode 100644 packages/port-scanner/src/port-manager.test.ts create mode 100644 packages/port-scanner/src/port-manager.ts create mode 100644 packages/port-scanner/src/procfs.test.ts create mode 100644 packages/port-scanner/src/procfs.ts rename apps/desktop/src/main/lib/terminal/port-scanner.test.ts => packages/port-scanner/src/scanner.test.ts (88%) rename apps/desktop/src/main/lib/terminal/port-scanner.ts => packages/port-scanner/src/scanner.ts (82%) create mode 100644 packages/port-scanner/src/static-ports.ts create mode 100644 packages/port-scanner/src/types.ts create mode 100644 packages/port-scanner/tsconfig.json create mode 100644 plans/20260422-v2-remote-ports.md 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/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/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..1f8d1623140 100644 --- a/apps/desktop/src/shared/types/ports.ts +++ b/apps/desktop/src/shared/types/ports.ts @@ -1,12 +1,6 @@ -export interface DetectedPort { - port: number; - pid: number; - processName: string; - paneId: string; - workspaceId: string; - detectedAt: number; - address: string; -} +export type { DetectedPort } from "@superset/port-scanner"; + +import type { DetectedPort } from "@superset/port-scanner"; export interface StaticPort { port: number; @@ -29,7 +23,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..dc5bd82a156 --- /dev/null +++ b/packages/port-scanner/src/index.ts @@ -0,0 +1,16 @@ +export { + type KillFn, + PortManager, + type PortManagerOptions, +} from "./port-manager"; +export { + getListeningPortsForPids, + 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`. From 19af7282c4837d065b3c767379c3ed0bef891a0e Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sun, 26 Apr 2026 09:03:12 +0900 Subject: [PATCH 2/2] fix(desktop): integrate fork extensions with upstream port-scanner extraction - Re-export getProcessCommand/getProcessName from @superset/port-scanner index (used by fork-only browser-automation router and browser-mcp-bridge). - Update browser-automation/index.ts and pane-resolver.ts imports from the removed main/lib/terminal/port-scanner to @superset/port-scanner. - Biome formatter polish on shared/types/ports.ts and browser-automation imports after the rename. --- .../src/lib/trpc/routers/browser-automation/index.ts | 10 +++++----- .../src/main/lib/browser-mcp-bridge/pane-resolver.ts | 2 +- apps/desktop/src/shared/types/ports.ts | 2 -- packages/port-scanner/src/index.ts | 2 ++ 4 files changed, 8 insertions(+), 8 deletions(-) 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/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/shared/types/ports.ts b/apps/desktop/src/shared/types/ports.ts index 1f8d1623140..dee910e8337 100644 --- a/apps/desktop/src/shared/types/ports.ts +++ b/apps/desktop/src/shared/types/ports.ts @@ -1,7 +1,5 @@ export type { DetectedPort } from "@superset/port-scanner"; -import type { DetectedPort } from "@superset/port-scanner"; - export interface StaticPort { port: number; label: string; diff --git a/packages/port-scanner/src/index.ts b/packages/port-scanner/src/index.ts index dc5bd82a156..05ef857d91f 100644 --- a/packages/port-scanner/src/index.ts +++ b/packages/port-scanner/src/index.ts @@ -5,6 +5,8 @@ export { } from "./port-manager"; export { getListeningPortsForPids, + getProcessCommand, + getProcessName, getProcessTree, type PortInfo, } from "./scanner";