Skip to content

Add getOpenPorts() — cross-platform port discovery for a terminal's process tree#114

Merged
nedtwigg merged 7 commits into
mainfrom
port-discovery
May 29, 2026
Merged

Add getOpenPorts() — cross-platform port discovery for a terminal's process tree#114
nedtwigg merged 7 commits into
mainfrom
port-discovery

Conversation

@nedtwigg
Copy link
Copy Markdown
Member

What

Adds a getOpenPorts(id) query to the PlatformAdapter API. Given a terminal pane, it returns every TCP listening port opened by that pane's shell process and all of its descendant subprocesses — so you can answer "what server is this terminal running" (e.g. a dev server on :3000).

const ports = await adapter.getOpenPorts(paneId);
// → [{ protocol: 'tcp', family: 'IPv4', address: '0.0.0.0',
//      port: 3000, pid: 67248, processName: 'node' }, ...]

Each record also carries the bind address, so callers learn which interface(s) a socket is on (0.0.0.0 / :: = all interfaces, 127.0.0.1 / ::1 = loopback-only).

How

Works out of the box for any Dormouse pane on any platform — Dormouse always holds the root shell PID of the PTYs it spawns, so no terminal-side cooperation is needed. Discovery is two native steps per OS, with zero new dependencies:

Process tree Listening sockets
Linux scan /proc/<pid>/stat /proc/<pid>/fd inodes → /proc/net/tcp{,6} LISTEN rows
macOS ps -axo pid=,ppid= lsof -iTCP -sTCP:LISTEN
Windows Get-CimInstance Win32_Process Get-NetTCPConnection (→ netstat -ano fallback)

The core (getOpenPortsForPid) lives in the shared standalone/sidecar/pty-core.js (the VS Code extension loads it via the lib/pty-core.cjs shim) and is fail-soft — any platform error yields [], never an exception. It is wired end-to-end through every backend exactly the way the sibling getCwd query is: PlatformAdapter → VS Code (pty-host / pty-manager / message-router) and Tauri (sidecar/main.js / pty_get_open_ports Rust command / tauri-adapter), plus the fake adapter for tests/playground.

Scope

  • Listening TCP only. Established/outbound connections and UDP are intentionally excluded (the "what server is running" signal, not connection churn). The core is structured so adding UDP or other states later is localized.
  • This PR adds the API; there is no UI surfacing it yet (e.g. a port badge in the pane header). Happy to follow up.

Tests

  • Unit tests for every parser and process-tree path (pty-core.test.js), with injected fs / execFileSync fixtures. Also added a test script to the dormouse-sidecar package so these (and the previously un-run sidecar tests) now execute in CI.
  • Live end-to-end verified on Windows: confirmed both 127.0.0.1 and 0.0.0.0 listeners are found, and that querying a parent PID surfaces a child process's port (the subprocess requirement).
  • Lib tsc -b clean, vscode-ext bundles, cargo check clean, full lib + fake-adapter suites pass.

Note: the macOS lsof and Linux /proc live paths are covered by unit tests with fixtures (this was developed on Windows); a real run on a Mac/Linux box is worth a sanity check before release.

🤖 Generated with Claude Code

nedtwigg and others added 5 commits May 28, 2026 15:23
Discovers TCP listening ports opened by a shell process and all of its
descendant subprocesses, mapped back to the owning PID. Three native
backends, no new dependencies:

- Linux: scan /proc/<pid>/stat for the process tree, map /proc/<pid>/fd
  socket inodes to LISTEN rows in /proc/net/tcp{,6}.
- macOS: ps for the tree, lsof -iTCP -sTCP:LISTEN for the ports.
- Windows: Get-CimInstance Win32_Process for the tree, Get-NetTCPConnection
  (with a netstat -ano fallback) for the ports.

Returns { protocol, family, address, port, pid, processName } records,
de-duplicated by (family, address, port) and sorted by port, so callers
also learn which interface each socket is bound to.

Wires up a getOpenPorts(id, requestId) manager method and adds a node --test
script to the sidecar package so these (and the previously un-run sidecar)
tests execute in CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Exposes the pty-core port discovery as PlatformAdapter.getOpenPorts(id),
threaded end-to-end through every backend the same way getCwd is:

- types.ts: OpenPort type + getOpenPorts() on PlatformAdapter.
- VS Code: pty:getOpenPorts/pty:openPorts message types, message-router
  case, pty-manager.getOpenPorts() (4s timeout), pty-host case.
- Tauri: sidecar main.js case, pty_get_open_ports Rust command (8s
  timeout) + handler registration, tauri-adapter method.
- Fake adapter: getOpenPorts() backed by a settable map for playground/tests.

Documents the mechanism and invariants in docs/specs/transport.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- pty-manager.ts: drop the OpenPortEntry interface and import the shared
  OpenPort type from lib (matches how message-types.ts sources it).
- pty-core.js: add a runPowerShellJson() helper to collapse the repeated
  runPowerShell + JSON.parse + normalizeJsonArray dance at the two
  Win32_Process call sites.
- pty-core.js: memoize /proc/<pid>/comm reads so a pid with multiple
  listening ports is only read once.
- pty-core.js: drop the redundant try/catch in the getOpenPorts manager
  method — getOpenPortsForPid is already fail-soft, and getCwd doesn't wrap
  its helper either.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Member Author

@nedtwigg nedtwigg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we centralize the timeout to be OPEN_PORT_TIMEOUT somewhere in lib? Let's set it to 3 seconds

Comment thread docs/specs/transport.md Outdated
Comment on lines +95 to +103
1. **Process tree** (`getDescendantPids`) — Linux scans `/proc/<pid>/stat` for the pid→ppid table; macOS uses `ps -axo pid=,ppid=`; Windows uses `Get-CimInstance Win32_Process`. A shared BFS (`buildDescendantSet`) collects the root pid plus all transitive children.
2. **Listening sockets** (`getListeningPortsForPids`) — Linux maps each pid's `/proc/<pid>/fd` socket inodes to `0A` (LISTEN) rows in `/proc/net/tcp{,6}`; macOS runs `lsof -nP -iTCP -sTCP:LISTEN -p <csv>`; Windows runs `Get-NetTCPConnection -State Listen` with a `netstat -ano` fallback for hosts lacking the cmdlet.

Invariants:

- **Listening TCP only.** Established/outbound connections and UDP are intentionally excluded — the signal is "what server is this terminal running," not raw connection churn.
- **Fail soft, never throw.** Any platform-specific failure (missing `/proc`, `lsof`/PowerShell error, timeout) yields `[]`, never an exception. The Tauri command uses an 8s timeout and the VS Code path a 4s timeout, both wider than the 1s cwd query because enumeration shells out on macOS/Windows.
- **`address` is the bind interface.** `0.0.0.0`/`::` mean all interfaces; `127.0.0.1`/`::1` mean loopback-only. Results are de-duplicated by `(family, address, port)` and sorted by port.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part of the spec is unnecessary, redundant with the implementation.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — dropped the platform-step breakdown and invariants list in 65304c9. Kept a source-of-truth pointer to getOpenPortsForPid() in the message-table row instead of a separate section.

@nedtwigg
Copy link
Copy Markdown
Member Author

@dormouse-bot please address the comments I made above, and also merge origin/main, resolving any conflicts. Push to this branch.

dormouse-bot and others added 2 commits May 29, 2026 02:56
Define a single OPEN_PORT_TIMEOUT_MS=3000 constant in lib and use it
at every transport boundary (vscode-adapter, pty-manager, Tauri
pty_get_open_ports) and for the per-subprocess execs inside pty-core
(ps, lsof, PowerShell, netstat). Rust and pty-core.js mirror the
value with cross-reference comments since lib's TS source isn't
importable from those runtimes.

Drop the "Open-port discovery" section in transport.md — its
platform-step breakdown and invariants list duplicated the
implementation. The message-protocol row keeps a Source-of-truth
pointer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
# Conflicts:
#	vscode-ext/src/message-types.ts
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 29, 2026

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 549d63a
Status: ✅  Deploy successful!
Preview URL: https://d879d388.mouseterm.pages.dev
Branch Preview URL: https://port-discovery.mouseterm.pages.dev

View logs

@dormouse-bot
Copy link
Copy Markdown
Collaborator

Done — pushed 549d63a.

  • Defined OPEN_PORT_TIMEOUT_MS = 3000 in lib/src/lib/platform/types.ts and used it at every boundary: vscode-adapter webview→host, pty-manager host→pty-host, and the Tauri pty_get_open_ports command. Also threaded it through the per-subprocess execs inside pty-core.js (ps, lsof, PowerShell, netstat) so the 3s budget is enforced top-to-bottom.
  • standalone/src-tauri/src/lib.rs and standalone/sidecar/pty-core.js mirror the constant with cross-reference comments since lib's TS source isn't importable from those runtimes.
  • Trimmed the redundant "Open-port discovery" section from docs/specs/transport.md per the inline review.
  • Merged origin/main into port-discovery. Only conflict was vscode-ext/src/message-types.ts (both sides added imports — kept both OpenPort and VSCodeWorkbenchCommand).

Local: tsc -b clean, 530/530 lib tests pass, 38/38 sidecar tests pass, vscode-ext bundles.

@nedtwigg nedtwigg merged commit 625f4b6 into main May 29, 2026
8 of 9 checks passed
@nedtwigg nedtwigg deleted the port-discovery branch May 29, 2026 03:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants