Skip to content

Commit 625f4b6

Browse files
authored
Add getOpenPorts() — cross-platform port discovery for a terminal's process tree (#114)
2 parents 1f391e0 + 549d63a commit 625f4b6

15 files changed

Lines changed: 885 additions & 5 deletions

File tree

docs/specs/transport.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ VS Code-only workbench chord mirroring uses `dormouse:runWorkbenchCommand` from
8080
| Direction | Message | Source type | Contract |
8181
| --- | --- | --- | --- |
8282
| Webview → host | `dormouse:openExternal` | `WebviewMessage` | Request the host to open a user-confirmed external URI from an OSC 8 hyperlink. Hosts must revalidate and reject malformed, control-character-bearing, or blocked pseudo-scheme targets (`javascript:`, `data:`, `blob:`, `about:`). |
83+
| Webview → host | `pty:getOpenPorts` | `WebviewMessage` | Request the TCP listening ports opened by a PTY's shell process **and all of its descendant subprocesses**. The host resolves them from the PTY's root pid and replies with `pty:openPorts`. Source of truth: `getOpenPortsForPid()` in `standalone/sidecar/pty-core.js` (the VS Code extension loads it through the `lib/pty-core.cjs` shim). |
84+
| Host → webview | `pty:openPorts` | `ExtensionMessage` | Reply to `pty:getOpenPorts`: `ports: OpenPort[]` (`{ protocol, family, address, port, pid, processName }`), de-duplicated by `(family, address, port)` and sorted by port. Empty array when the PTY is gone or enumeration fails. |
8385
| Host → webview | `pty:data` | `ExtensionMessage` | PTY output after state-driving supported OSC sequences have been parsed/stripped; `OSC 8` hyperlinks are preserved for xterm.js and routed only to the owning router. |
8486
| Host → webview | `pty:replay` | `ExtensionMessage` | Buffered raw output since spawn; the webview parses semantic OSCs during replay reconstruction without triggering alerts. |
8587
| Host → webview | `dormouse:newTerminal` | `ExtensionMessage` | Payload may include `shell`, `args`, display `name`, `replaceUntouched`, and `announce`; the webview replaces the selected untouched terminal in-place only when `replaceUntouched` is true, otherwise it spawns a new pane. |

lib/src/lib/platform/fake-adapter.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,30 @@ describe('FakePtyAdapter', () => {
239239
expect(dataEvents).toEqual([{ id: 't1', data: 'after' }]);
240240
});
241241

242+
it('clears configured open ports on kill', async () => {
243+
const { adapter } = createAdapter();
244+
const ports = [
245+
{
246+
protocol: 'tcp' as const,
247+
family: 'IPv4' as const,
248+
address: '127.0.0.1',
249+
port: 5173,
250+
pid: 1234,
251+
processName: 'vite',
252+
},
253+
];
254+
adapter.spawnPty('t1');
255+
adapter.setOpenPorts('t1', ports);
256+
257+
await expect(adapter.getOpenPorts('t1')).resolves.toEqual(ports);
258+
259+
adapter.killPty('t1');
260+
await expect(adapter.getOpenPorts('t1')).resolves.toEqual([]);
261+
262+
adapter.spawnPty('t1');
263+
await expect(adapter.getOpenPorts('t1')).resolves.toEqual([]);
264+
});
265+
242266
it('clears input handlers on reset', () => {
243267
const { adapter, dataEvents } = createAdapter();
244268
const handled: string[] = [];

lib/src/lib/platform/fake-adapter.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AlertStateDetail, PlatformAdapter, PtyInfo } from './types';
1+
import type { AlertStateDetail, OpenPort, PlatformAdapter, PtyInfo } from './types';
22
import { AlertManager, type SessionStatus } from '../alert-manager';
33
import { normalizeExternalUri } from '../external-links';
44
import {
@@ -45,6 +45,7 @@ export class FakePtyAdapter implements PlatformAdapter {
4545
private scenarioMap = new Map<string, FakeScenario>();
4646
private inputHandlers = new Map<string, (data: string) => void>();
4747
private protocolParsers = new Map<string, TerminalProtocolParser>();
48+
private openPortsMap = new Map<string, OpenPort[]>();
4849
private alertManager = new AlertManager();
4950

5051
constructor() {
@@ -91,6 +92,7 @@ export class FakePtyAdapter implements PlatformAdapter {
9192
this.spawnHandlers.clear();
9293
this.inputHandlers.clear();
9394
this.protocolParsers.clear();
95+
this.openPortsMap.clear();
9496
this.alertManager.dispose();
9597
this.alertManager = new AlertManager();
9698
this.alertManager.onStateChange((id, state) => {
@@ -158,6 +160,7 @@ export class FakePtyAdapter implements PlatformAdapter {
158160
this.terminalSizes.delete(id);
159161
this.inputHandlers.delete(id);
160162
this.protocolParsers.delete(id);
163+
this.openPortsMap.delete(id);
161164
this.alertManager.onExit(id, 0);
162165
for (const handler of this.exitHandlers) {
163166
handler({ id, exitCode: 0 });
@@ -183,6 +186,16 @@ export class FakePtyAdapter implements PlatformAdapter {
183186
async getCwd(_id: string): Promise<string | null> { return null; }
184187
async getScrollback(_id: string): Promise<string | null> { return null; }
185188

189+
/** Ports the playground/tests want a given terminal to report. */
190+
setOpenPorts(id: string, ports: OpenPort[]): void {
191+
this.openPortsMap.set(id, ports);
192+
}
193+
194+
async getOpenPorts(id: string): Promise<OpenPort[]> {
195+
if (!this.terminals.has(id)) return [];
196+
return this.openPortsMap.get(id) ?? [];
197+
}
198+
186199
getPtySize(id: string): FakePtySize {
187200
return this.terminalSizes.get(id) ?? DEFAULT_PTY_SIZE;
188201
}

lib/src/lib/platform/types.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,31 @@ export interface PtyInfo {
77
exitCode?: number;
88
}
99

10+
/**
11+
* A TCP socket in the LISTEN state opened by a terminal's shell process or any
12+
* of its descendant subprocesses. `address` is the bind interface — `0.0.0.0`
13+
* / `::` mean all interfaces, `127.0.0.1` / `::1` mean loopback-only.
14+
*/
15+
export interface OpenPort {
16+
protocol: 'tcp';
17+
family: 'IPv4' | 'IPv6';
18+
address: string;
19+
port: number;
20+
pid: number;
21+
processName?: string;
22+
}
23+
24+
/**
25+
* End-to-end budget for `getOpenPorts()` at every transport boundary
26+
* (webview → host adapter, host → pty-host child, Tauri command → sidecar) and
27+
* for the per-subprocess execs inside `getOpenPortsForPid()` (lsof, PowerShell,
28+
* `Get-NetTCPConnection`, `netstat`). Wider than the 1 s cwd query because
29+
* enumeration shells out on macOS/Windows; tight enough to fail visibly rather
30+
* than hang a pane header. Mirrored as `OPEN_PORT_TIMEOUT_MS` in
31+
* `standalone/sidecar/pty-core.js` and `standalone/src-tauri/src/lib.rs`.
32+
*/
33+
export const OPEN_PORT_TIMEOUT_MS = 3000;
34+
1035
export type AlertStateDetail = { id: string } & AlertState;
1136

1237
export interface PlatformAdapter {
@@ -26,6 +51,8 @@ export interface PlatformAdapter {
2651
// PTY queries
2752
getCwd(id: string): Promise<string | null>;
2853
getScrollback(id: string): Promise<string | null>;
54+
/** TCP listening ports opened by this terminal's process tree (shell + descendants). */
55+
getOpenPorts(id: string): Promise<OpenPort[]>;
2956

3057
// Clipboard support for file references and raw images.
3158
readClipboardFilePaths(): Promise<string[] | null>;

lib/src/lib/platform/vscode-adapter.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { AlertStateDetail, PlatformAdapter, PtyInfo } from './types';
1+
import type { AlertStateDetail, OpenPort, PlatformAdapter, PtyInfo } from './types';
2+
import { OPEN_PORT_TIMEOUT_MS } from './types';
23
import { setDefaultShellOpts } from '../shell-defaults';
34
import {
45
collectTerminalSemanticEvents,
@@ -162,6 +163,15 @@ export class VSCodeAdapter implements PlatformAdapter {
162163
return this.requestResponse('pty:getScrollback', 'pty:scrollback', { id }, (msg) => msg.data);
163164
}
164165

166+
async getOpenPorts(id: string): Promise<OpenPort[]> {
167+
const result = await this.requestResponse<OpenPort[]>(
168+
'pty:getOpenPorts', 'pty:openPorts', { id },
169+
(msg) => msg.ports as OpenPort[],
170+
OPEN_PORT_TIMEOUT_MS,
171+
);
172+
return result ?? [];
173+
}
174+
165175
readClipboardFilePaths(): Promise<string[] | null> {
166176
return this.requestResponse<string[] | null>(
167177
'clipboard:readFiles', 'clipboard:files', {},

standalone/sidecar/main.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ rl.on('line', (line) => {
4040
case 'pty:kill': mgr.kill(data.id); break;
4141
case 'pty:requestInit': mgr.list(); break;
4242
case 'pty:getCwd': mgr.getCwd(data.id, data.requestId); break;
43+
case 'pty:getOpenPorts': mgr.getOpenPorts(data.id, data.requestId); break;
4344
case 'pty:getScrollback': mgr.getScrollback(data.id, data.requestId); break;
4445
case 'pty:getShells': mgr.getShells(data.requestId); break;
4546
case 'pty:gracefulKillAll': mgr.gracefulKillAll(data.timeout); break;

standalone/sidecar/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
"private": true,
44
"version": "0.1.0",
55
"main": "main.js",
6+
"scripts": {
7+
"test": "node --test"
8+
},
69
"dependencies": {
710
"node-pty": "1.2.0-beta.13"
811
}

0 commit comments

Comments
 (0)