authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-09 23:31:29-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 23:31:29-03:00
log318201caa520525517ef2bee8f4bbeedb2fec1d7
tree6133f676884e71730df4a2c38eb3436b96d6741c
parentf34adad34f21510e867b47fe018c7975779a0ffc
signature Signed by PGP key B5690EEEBB952194

fix(server): probe only owned preview listeners (#16687)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

2 files changed, 110 insertions(+), 132 deletions(-)

apps/server/src/preview/PortScanner.test.ts+76-87
...@@ -7,7 +7,6 @@ import {...@@ -7,7 +7,6 @@ import {
7 type DiscoveredLocalServer,7 type DiscoveredLocalServer,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import * as HostProcess from "@t3tools/shared/HostProcess";9import * as HostProcess from "@t3tools/shared/HostProcess";
10import * as Net from "@t3tools/shared/Net";
11import * as Cause from "effect/Cause";10import * as Cause from "effect/Cause";
12import * as Clock from "effect/Clock";11import * as Clock from "effect/Clock";
13import * as Duration from "effect/Duration";12import * as Duration from "effect/Duration";
...@@ -44,17 +43,7 @@ const layerTestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, {...@@ -44,17 +43,7 @@ const layerTestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
44 run: processProbeFailure,43 run: processProbeFailure,
45});44});
4645
47let integrationListeningPort: number | null = null;46/** A host without `/proc`, so a missing `lsof` leaves only configured URLs. */
48
49const layerTestIntegrationNet = Layer.succeed(Net.NetService, {
50 canListenOnHost: () => Effect.succeed(true),
51 isPortAvailableOnLoopback: (port) => Effect.sync(() => port !== integrationListeningPort),
52 hasListenerOnHost: (port) => Effect.sync(() => port === integrationListeningPort),
53 reserveLoopbackPort: () => Effect.succeed(40_000),
54 findAvailablePort: (preferred) => Effect.succeed(preferred),
55});
56
57/** A host without `/proc`, so a missing `lsof` falls back to common ports. */
58const layerNoProc = FileSystem.layerNoop({});47const layerNoProc = FileSystem.layerNoop({});
5948
60const layerProbeFailure = (49const layerProbeFailure = (
...@@ -67,13 +56,6 @@ const layerProbeFailure = (...@@ -67,13 +56,6 @@ const layerProbeFailure = (
67 Layer.mergeAll(56 Layer.mergeAll(
68 fileSystem,57 fileSystem,
69 Layer.succeed(ProcessRunner.ProcessRunner, { run }),58 Layer.succeed(ProcessRunner.ProcessRunner, { run }),
70 Layer.succeed(Net.NetService, {
71 canListenOnHost: () => Effect.succeed(true),
72 isPortAvailableOnLoopback: () => Effect.succeed(true),
73 hasListenerOnHost: () => Effect.succeed(false),
74 reserveLoopbackPort: () => Effect.succeed(40_000),
75 findAvailablePort: (preferred) => Effect.succeed(preferred),
76 }),
77 Layer.succeed(HostProcess.Platform, "linux"),59 Layer.succeed(HostProcess.Platform, "linux"),
78 FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch))),60 FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch))),
79 ),61 ),
...@@ -85,7 +67,6 @@ const layerTestPortDiscovery = PortScanner.layer.pipe(...@@ -85,7 +67,6 @@ const layerTestPortDiscovery = PortScanner.layer.pipe(
85 Layer.mergeAll(67 Layer.mergeAll(
86 layerNoProc,68 layerNoProc,
87 layerTestProcessRunner,69 layerTestProcessRunner,
88 layerTestIntegrationNet,
89 Layer.succeed(HostProcess.Platform, "win32"),70 Layer.succeed(HostProcess.Platform, "win32"),
90 FetchHttpClient.layer,71 FetchHttpClient.layer,
91 ),72 ),
...@@ -94,9 +75,20 @@ const layerTestPortDiscovery = PortScanner.layer.pipe(...@@ -94,9 +75,20 @@ const layerTestPortDiscovery = PortScanner.layer.pipe(
9475
95const LSOF_TEST_PORT = 43_123;76const LSOF_TEST_PORT = 43_123;
9677
78/** The scanner with `processIds` registered to a T3 terminal, so their listeners get probes. */
79const ownedScanner = (processIds: ReadonlyArray<number> = [1234]) =>
80 Effect.tap(PortScanner.PortDiscovery, (scanner) =>
81 scanner.registerTerminalProcesses({
82 threadId: "scanner-thread",
83 terminalId: "scanner-terminal",
84 processIds,
85 }),
86 );
87
97const layerLsofScanner = (input: {88const layerLsofScanner = (input: {
98 readonly pid: () => number;89 readonly pid: () => number;
99 readonly fetch: typeof globalThis.fetch;90 readonly fetch: typeof globalThis.fetch;
91 readonly stdout?: () => string;
100}) =>92}) =>
101 PortScanner.layer.pipe(93 PortScanner.layer.pipe(
102 Layer.provide(94 Layer.provide(
...@@ -105,7 +97,7 @@ const layerLsofScanner = (input: {...@@ -105,7 +97,7 @@ const layerLsofScanner = (input: {
105 Layer.succeed(ProcessRunner.ProcessRunner, {97 Layer.succeed(ProcessRunner.ProcessRunner, {
106 run: () =>98 run: () =>
107 Effect.succeed({99 Effect.succeed({
108 stdout: `p${input.pid()}\ncnode\nn*:${LSOF_TEST_PORT}\n`,100 stdout: input.stdout?.() ?? `p${input.pid()}\ncnode\nn*:${LSOF_TEST_PORT}\n`,
109 stderr: "",101 stderr: "",
110 code: null,102 code: null,
111 timedOut: false,103 timedOut: false,
...@@ -115,13 +107,6 @@ const layerLsofScanner = (input: {...@@ -115,13 +107,6 @@ const layerLsofScanner = (input: {
115 stderrInvalidUtf8: false,107 stderrInvalidUtf8: false,
116 }),108 }),
117 }),109 }),
118 Layer.succeed(Net.NetService, {
119 canListenOnHost: () => Effect.succeed(true),
120 isPortAvailableOnLoopback: () => Effect.succeed(true),
121 hasListenerOnHost: () => Effect.succeed(false),
122 reserveLoopbackPort: () => Effect.succeed(40_000),
123 findAvailablePort: (preferred) => Effect.succeed(preferred),
124 }),
125 Layer.succeed(HostProcess.Platform, "linux"),110 Layer.succeed(HostProcess.Platform, "linux"),
126 FetchHttpClient.layer.pipe(111 FetchHttpClient.layer.pipe(
127 Layer.provide(Layer.succeed(FetchHttpClient.Fetch, input.fetch)),112 Layer.provide(Layer.succeed(FetchHttpClient.Fetch, input.fetch)),
...@@ -153,73 +138,45 @@ const closeServer = (server: NodeNet.Server): Effect.Effect<void> =>...@@ -153,73 +138,45 @@ const closeServer = (server: NodeNet.Server): Effect.Effect<void> =>
153 });138 });
154139
155const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* (140const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* (
156 ports: ReadonlyArray<number>,
157 onConnection: (socket: NodeNet.Socket) => void,141 onConnection: (socket: NodeNet.Socket) => void,
158) {142) {
159 for (const port of ports) {143 const server = yield* openServer(0, onConnection);
160 const server = yield* openServer(port, onConnection);144 const address = server?.address();
161 if (server !== null) return { port, server };145 if (!server || !address || typeof address === "string") {
146 return yield* Effect.die(new Error("Could not open the preview scanner test listener"));
162 }147 }
163 return yield* Effect.die(148 return { port: address.port, server };
164 new Error("No common development port was available for the preview scanner test"),
165 );
166});149});
167150
168const commonDevServer = Effect.acquireRelease(151const commonDevServer = Effect.acquireRelease(
169 openCommonDevServer(PortScanner.COMMON_DEV_PORTS, (socket) => {152 openCommonDevServer((socket) => {
170 socket.once("data", () => {153 socket.once("data", () => {
171 socket.end("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello");154 socket.end("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello");
172 });155 });
173 }).pipe(156 }),
174 Effect.tap(({ port }) =>157 ({ server }) => closeServer(server),
175 Effect.sync(() => {
176 integrationListeningPort = port;
177 }),
178 ),
179 ),
180 ({ server }) =>
181 closeServer(server).pipe(
182 Effect.ensuring(
183 Effect.sync(() => {
184 integrationListeningPort = null;
185 }),
186 ),
187 ),
188);158);
189159
190const commonNonHttpServer = Effect.acquireRelease(160const commonNonHttpServer = Effect.acquireRelease(
191 openCommonDevServer(PortScanner.COMMON_DEV_PORTS.toReversed(), (socket) => {161 openCommonDevServer((socket) => {
192 socket.on("error", () => undefined);162 socket.on("error", () => undefined);
193 socket.once("data", () => socket.end("MYSQL\r\n\r\n"));163 socket.once("data", () => socket.end("MYSQL\r\n\r\n"));
194 }).pipe(164 }),
195 Effect.tap(({ port }) =>165 ({ server }) => closeServer(server),
196 Effect.sync(() => {
197 integrationListeningPort = port;
198 }),
199 ),
200 ),
201 ({ server }) =>
202 closeServer(server).pipe(
203 Effect.ensuring(
204 Effect.sync(() => {
205 integrationListeningPort = null;
206 }),
207 ),
208 ),
209);166);
210167
211/**168/**
212 * Integration tests against a real TCP listener. We provide the Windows host169 * Integration tests against a real TCP listener. We provide the Windows host
213 * platform so the tests exercise the TCP-probe fallback without depending on170 * platform with a failing listener probe so the tests exercise configured URLs
214 * `lsof` being installed.171 * without depending on `lsof` being installed.
215 */172 */
216effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fallback)", (it) => {173effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (configured URLs)", (it) => {
217 it.effect(174 it.effect(
218 "scan() returns an HTTP server we just opened on a curated dev port",175 "scan() returns a configured HTTP server we just opened",
219 Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () {176 Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () {
220 const { port } = yield* commonDevServer;177 const { port } = yield* commonDevServer;
221 const scanner = yield* PortScanner.PortDiscovery;178 const scanner = yield* PortScanner.PortDiscovery;
222 const result = yield* scanner.scan();179 const result = yield* scanner.scan([`http://localhost:${port}`]);
223 const found = result.find((server) => server.port === port);180 const found = result.find((server) => server.port === port);
224 expect(found).toBeDefined();181 expect(found).toBeDefined();
225 expect(found?.host).toBe("localhost");182 expect(found?.host).toBe("localhost");
...@@ -231,7 +188,7 @@ effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fal...@@ -231,7 +188,7 @@ effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fal
231 Effect.fn("PortScannerTest.scanExcludesNonHttpServer")(function* () {188 Effect.fn("PortScannerTest.scanExcludesNonHttpServer")(function* () {
232 const { port } = yield* commonNonHttpServer;189 const { port } = yield* commonNonHttpServer;
233 const scanner = yield* PortScanner.PortDiscovery;190 const scanner = yield* PortScanner.PortDiscovery;
234 const result = yield* scanner.scan();191 const result = yield* scanner.scan([`http://localhost:${port}`]);
235 expect(result.some((server) => server.port === port)).toBe(false);192 expect(result.some((server) => server.port === port)).toBe(false);
236 }),193 }),
237 );194 );
...@@ -242,10 +199,12 @@ effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fal...@@ -242,10 +199,12 @@ effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fal
242 const { port } = yield* commonDevServer;199 const { port } = yield* commonDevServer;
243 const received: number[] = [];200 const received: number[] = [];
244 const scanner = yield* PortScanner.PortDiscovery;201 const scanner = yield* PortScanner.PortDiscovery;
245 yield* scanner.subscribe({ configuredUrls: [], initialSnapshot: [] }, (servers) =>202 yield* scanner.subscribe(
246 Effect.sync(() => {203 { configuredUrls: [`http://localhost:${port}`], initialSnapshot: [] },
247 for (const server of servers) received.push(server.port);204 (servers) =>
248 }),205 Effect.sync(() => {
206 for (const server of servers) received.push(server.port);
207 }),
249 );208 );
250 yield* scanner.retain;209 yield* scanner.retain;
251 expect(received).toContain(port);210 expect(received).toContain(port);
...@@ -265,7 +224,7 @@ effectIt.effect("revalidates a successful HTML probe after its cache entry expir...@@ -265,7 +224,7 @@ effectIt.effect("revalidates a successful HTML probe after its cache entry expir
265 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });224 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });
266225
267 return Effect.gen(function* () {226 return Effect.gen(function* () {
268 const scanner = yield* PortScanner.PortDiscovery;227 const scanner = yield* ownedScanner();
269 expect(yield* scanner.scan()).toHaveLength(1);228 expect(yield* scanner.scan()).toHaveLength(1);
270 expect(yield* scanner.scan()).toHaveLength(1);229 expect(yield* scanner.scan()).toHaveLength(1);
271 expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]);230 expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]);
...@@ -499,7 +458,7 @@ effectIt.effect("shares a configured root probe with discovered-root classificat...@@ -499,7 +458,7 @@ effectIt.effect("shares a configured root probe with discovered-root classificat
499 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });458 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });
500459
501 return Effect.gen(function* () {460 return Effect.gen(function* () {
502 const scanner = yield* PortScanner.PortDiscovery;461 const scanner = yield* ownedScanner();
503 expect(yield* scanner.scan([rootUrl])).toHaveLength(1);462 expect(yield* scanner.scan([rootUrl])).toHaveLength(1);
504 expect(requests).toEqual([rootUrl]);463 expect(requests).toEqual([rootUrl]);
505464
...@@ -528,7 +487,7 @@ effectIt.effect("starts fresh cache entries after the probing batch completes",...@@ -528,7 +487,7 @@ effectIt.effect("starts fresh cache entries after the probing batch completes",
528 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });487 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });
529488
530 yield* Effect.gen(function* () {489 yield* Effect.gen(function* () {
531 const scanner = yield* PortScanner.PortDiscovery;490 const scanner = yield* ownedScanner();
532 expect(yield* scanner.scan()).toHaveLength(1);491 expect(yield* scanner.scan()).toHaveLength(1);
533 expect(yield* scanner.scan()).toHaveLength(1);492 expect(yield* scanner.scan()).toHaveLength(1);
534 expect(requests).toHaveLength(1);493 expect(requests).toHaveLength(1);
...@@ -548,7 +507,7 @@ effectIt.effect("caches a failed web probe until its bounded cache entry expires...@@ -548,7 +507,7 @@ effectIt.effect("caches a failed web probe until its bounded cache entry expires
548 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });507 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });
549508
550 return Effect.gen(function* () {509 return Effect.gen(function* () {
551 const scanner = yield* PortScanner.PortDiscovery;510 const scanner = yield* ownedScanner();
552 expect(yield* scanner.scan()).toHaveLength(0);511 expect(yield* scanner.scan()).toHaveLength(0);
553 expect(yield* scanner.scan()).toHaveLength(0);512 expect(yield* scanner.scan()).toHaveLength(0);
554 expect(requests).toHaveLength(2);513 expect(requests).toHaveLength(2);
...@@ -573,7 +532,7 @@ effectIt.effect("falls back to HTTPS and does not follow redirects while probing...@@ -573,7 +532,7 @@ effectIt.effect("falls back to HTTPS and does not follow redirects while probing
573 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });532 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });
574533
575 return Effect.gen(function* () {534 return Effect.gen(function* () {
576 const scanner = yield* PortScanner.PortDiscovery;535 const scanner = yield* ownedScanner();
577 const servers = yield* scanner.scan();536 const servers = yield* scanner.scan();
578 expect(servers).toHaveLength(1);537 expect(servers).toHaveLength(1);
579 expect(servers[0]?.url).toBe(`https://localhost:${LSOF_TEST_PORT}`);538 expect(servers[0]?.url).toBe(`https://localhost:${LSOF_TEST_PORT}`);
...@@ -581,6 +540,37 @@ effectIt.effect("falls back to HTTPS and does not follow redirects while probing...@@ -581,6 +540,37 @@ effectIt.effect("falls back to HTTPS and does not follow redirects while probing
581 }).pipe(Effect.provide(layer));540 }).pipe(Effect.provide(layer));
582});541});
583542
543effectIt.effect("probes a port only while T3 terminals own every listener on it", () => {
544 const owned = `p1234\ncnode\nn[::1]:${LSOF_TEST_PORT}\n`;
545 const unowned = `p5678\ncrpc\nn127.0.0.1:${LSOF_TEST_PORT}\n`;
546 let stdout = unowned;
547 const requests: string[] = [];
548 const fetchFn = ((input: Parameters<typeof globalThis.fetch>[0]) => {
549 requests.push(String(input));
550 return Promise.resolve(new Response("app", { headers: { "content-type": "text/html" } }));
551 }) as typeof globalThis.fetch;
552 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn, stdout: () => stdout });
553
554 return Effect.gen(function* () {
555 const scanner = yield* ownedScanner();
556 expect(yield* scanner.scan()).toHaveLength(0);
557 stdout = `${owned}${unowned}`;
558 expect(yield* scanner.scan()).toHaveLength(0);
559 expect(requests).toEqual([]);
560
561 stdout = owned;
562 expect(yield* scanner.scan()).toHaveLength(1);
563 expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]);
564
565 yield* scanner.unregisterTerminal({
566 threadId: "scanner-thread",
567 terminalId: "scanner-terminal",
568 });
569 expect(yield* scanner.scan()).toHaveLength(0);
570 expect(requests).toHaveLength(1);
571 }).pipe(Effect.provide(layer));
572});
573
584effectIt.effect(574effectIt.effect(
585 "excludes HTTP errors, non-navigation responses, and successful non-documents",575 "excludes HTTP errors, non-navigation responses, and successful non-documents",
586 () => {576 () => {
...@@ -592,7 +582,7 @@ effectIt.effect(...@@ -592,7 +582,7 @@ effectIt.effect(
592 const layer = layerLsofScanner({ pid: () => pid, fetch: fetchFn });582 const layer = layerLsofScanner({ pid: () => pid, fetch: fetchFn });
593583
594 return Effect.gen(function* () {584 return Effect.gen(function* () {
595 const scanner = yield* PortScanner.PortDiscovery;585 const scanner = yield* ownedScanner([1, 2, 3, 4, 5, 6, 7]);
596 expect(yield* scanner.scan()).toHaveLength(0);586 expect(yield* scanner.scan()).toHaveLength(0);
597587
598 pid += 1;588 pid += 1;
...@@ -650,7 +640,7 @@ effectIt.effect("aborts HTTP and HTTPS probes when they time out", () => {...@@ -650,7 +640,7 @@ effectIt.effect("aborts HTTP and HTTPS probes when they time out", () => {
650 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });640 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });
651641
652 return Effect.gen(function* () {642 return Effect.gen(function* () {
653 const scanner = yield* PortScanner.PortDiscovery;643 const scanner = yield* ownedScanner();
654 const scanFiber = yield* Effect.forkChild(scanner.scan());644 const scanFiber = yield* Effect.forkChild(scanner.scan());
655 yield* TestClock.adjust(Duration.seconds(2));645 yield* TestClock.adjust(Duration.seconds(2));
656 expect(yield* Fiber.join(scanFiber)).toHaveLength(0);646 expect(yield* Fiber.join(scanFiber)).toHaveLength(0);
...@@ -739,11 +729,10 @@ effectIt.effect(...@@ -739,11 +729,10 @@ effectIt.effect(
739 );729 );
740730
741 return Effect.gen(function* () {731 return Effect.gen(function* () {
742 const scanner = yield* PortScanner.PortDiscovery;732 const scanner = yield* ownedScanner([4242]);
743 const first = yield* scanner.scan();733 const first = yield* scanner.scan();
734 // Ports 22 and 3001 have no known owner, so they never receive probes.
744 expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([735 expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([
745 { port: 22, pid: null, processName: null },
746 { port: 3001, pid: null, processName: null },
747 { port: 8765, pid: 4242, processName: "python3" },736 { port: 8765, pid: 4242, processName: "python3" },
748 ]);737 ]);
749 yield* scanner.scan();738 yield* scanner.scan();
apps/server/src/preview/PortScanner.ts+34-45
...@@ -8,8 +8,8 @@...@@ -8,8 +8,8 @@
8 * Linux without lsof: reads listening sockets from `/proc/net/tcp{,6}` and8 * Linux without lsof: reads listening sockets from `/proc/net/tcp{,6}` and
9 * finds their processes through `/proc/<pid>/fd`.9 * finds their processes through `/proc/<pid>/fd`.
10 *10 *
11 * Windows listener failures, or neither source: checks a curated list of11 * Only listeners owned by T3 terminal processes and explicitly configured
12 * common dev ports through the shared Net service.12 * URLs receive probes. Without listener ownership, only configured URLs do.
13 *13 *
14 * Listening ports are published only after a bounded HTTP(S) probe finds a14 * Listening ports are published only after a bounded HTTP(S) probe finds a
15 * successful HTML document or a redirect to one.15 * successful HTML document or a redirect to one.
...@@ -26,7 +26,6 @@ import {...@@ -26,7 +26,6 @@ import {
26 type DiscoveredLocalServer,26 type DiscoveredLocalServer,
27} from "@t3tools/contracts";27} from "@t3tools/contracts";
28import * as HostProcess from "@t3tools/shared/HostProcess";28import * as HostProcess from "@t3tools/shared/HostProcess";
29import * as Net from "@t3tools/shared/Net";
30import { isLoopbackHost, LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview";29import { isLoopbackHost, LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview";
31import * as Cause from "effect/Cause";30import * as Cause from "effect/Cause";
32import * as Clock from "effect/Clock";31import * as Clock from "effect/Clock";
...@@ -71,10 +70,6 @@ export class PortDiscovery extends Context.Service<...@@ -71,10 +70,6 @@ export class PortDiscovery extends Context.Service<
71 }70 }
72>()("t3/preview/PortScanner/PortDiscovery") {}71>()("t3/preview/PortScanner/PortDiscovery") {}
7372
74export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([
75 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000,
76]);
77
78const POLL_INTERVAL = Duration.seconds(3);73const POLL_INTERVAL = Duration.seconds(3);
79const LSOF_TIMEOUT_MS = 5_000;74const LSOF_TIMEOUT_MS = 5_000;
80/** File descriptors one `/proc` walk may read before it stops looking for socket owners. */75/** File descriptors one `/proc` walk may read before it stops looking for socket owners. */
...@@ -218,14 +213,20 @@ const parseLsofOutput = (...@@ -218,14 +213,20 @@ const parseLsofOutput = (
218 if (portMatch == null) continue;213 if (portMatch == null) continue;
219 const url = `http://localhost:${portMatch}`;214 const url = `http://localhost:${portMatch}`;
220 const key = `localhost:${portMatch}`;215 const key = `localhost:${portMatch}`;
221 if (seen.has(key)) continue;216 const terminal = pid === null ? null : (terminalByProcessId.get(pid) ?? null);
217 const existing = seen.get(key);
218 // A port receives probes only when T3 terminals own all of its listeners.
219 if (existing) {
220 if (terminal === null) seen.set(key, { ...existing, terminal: null });
221 continue;
222 }
222 seen.set(key, {223 seen.set(key, {
223 host: "localhost",224 host: "localhost",
224 port: portMatch,225 port: portMatch,
225 url,226 url,
226 processName,227 processName,
227 pid,228 pid,
228 terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null),229 terminal,
229 });230 });
230 }231 }
231 }232 }
...@@ -261,14 +262,20 @@ const parseWindowsListenerOutput = (...@@ -261,14 +262,20 @@ const parseWindowsListenerOutput = (
261 const pid = Number(pidRaw);262 const pid = Number(pidRaw);
262 if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue;263 if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue;
263 const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null;264 const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null;
264 if (seen.has(port)) continue;265 const terminal =
266 normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null);
267 const existing = seen.get(port);
268 if (existing) {
269 if (terminal === null) seen.set(port, { ...existing, terminal: null });
270 continue;
271 }
265 seen.set(port, {272 seen.set(port, {
266 host: "localhost",273 host: "localhost",
267 port,274 port,
268 url: `http://localhost:${port}`,275 url: `http://localhost:${port}`,
269 processName: processNameRaw?.trim() || null,276 processName: processNameRaw?.trim() || null,
270 pid: normalizedPid,277 pid: normalizedPid,
271 terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null),278 terminal,
272 });279 });
273 }280 }
274 return [...seen.values()].toSorted((left, right) => left.port - right.port);281 return [...seen.values()].toSorted((left, right) => left.port - right.port);
...@@ -339,7 +346,6 @@ const isCommandNotFound = (error: ProcessRunner.ProcessSpawnError): boolean =>...@@ -339,7 +346,6 @@ const isCommandNotFound = (error: ProcessRunner.ProcessSpawnError): boolean =>
339346
340/** @public Service construction is part of the canonical Effect module API. */347/** @public Service construction is part of the canonical Effect module API. */
341export const make = Effect.gen(function* PortDiscoveryMake() {348export const make = Effect.gen(function* PortDiscoveryMake() {
342 const net = yield* Net.NetService;
343 const processRunner = yield* ProcessRunner.ProcessRunner;349 const processRunner = yield* ProcessRunner.ProcessRunner;
344 const fileSystem = yield* FileSystem.FileSystem;350 const fileSystem = yield* FileSystem.FileSystem;
345 const hostPlatform = yield* HostProcess.Platform;351 const hostPlatform = yield* HostProcess.Platform;
...@@ -398,7 +404,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -398,7 +404,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
398404
399 /**405 /**
400 * Linux listeners from `/proc/net/tcp{,6}`, for hosts without `lsof`. Null406 * Linux listeners from `/proc/net/tcp{,6}`, for hosts without `lsof`. Null
401 * when neither file is readable, so discovery falls back to common ports.407 * when neither file is readable, so only configured URLs are probed.
402 */408 */
403 const scanProcListeners = Effect.fn("PortDiscovery.scanProcListeners")(function* (409 const scanProcListeners = Effect.fn("PortDiscovery.scanProcListeners")(function* (
404 terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner>,410 terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner>,
...@@ -433,44 +439,25 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -433,44 +439,25 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
433 const seen = new Map<number, DiscoveredLocalServer>();439 const seen = new Map<number, DiscoveredLocalServer>();
434 for (const { port, inode } of listeners) {440 for (const { port, inode } of listeners) {
435 const owner = owners.get(inode) ?? null;441 const owner = owners.get(inode) ?? null;
442 const terminal = owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null);
436 const existing = seen.get(port);443 const existing = seen.get(port);
437 if (existing && (existing.pid !== null || owner === null)) continue;444 if (existing && (existing.pid !== null || owner === null)) {
445 if (terminal === null) seen.set(port, { ...existing, terminal: null });
446 continue;
447 }
438 seen.set(port, {448 seen.set(port, {
439 host: "localhost",449 host: "localhost",
440 port,450 port,
441 url: `http://localhost:${port}`,451 url: `http://localhost:${port}`,
442 processName: owner?.processName ?? null,452 processName: owner?.processName ?? null,
443 pid: owner?.pid ?? null,453 pid: owner?.pid ?? null,
444 terminal: owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null),454 // A replaced listener had no known owner, so the port stays unowned.
455 terminal: existing ? null : terminal,
445 });456 });
446 }457 }
447 return [...seen.values()].toSorted((left, right) => left.port - right.port);458 return [...seen.values()].toSorted((left, right) => left.port - right.port);
448 });459 });
449460
450 const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () {
451 const results = yield* Effect.forEach(
452 COMMON_DEV_PORTS,
453 (port) =>
454 net.isPortAvailableOnLoopback(port).pipe(
455 Effect.map((available) => ({
456 port,
457 listening: !available,
458 })),
459 ),
460 { concurrency: "unbounded" },
461 );
462 return results
463 .filter((result) => result.listening)
464 .map<DiscoveredLocalServer>((result) => ({
465 host: "localhost",
466 port: result.port,
467 url: `http://localhost:${result.port}`,
468 processName: null,
469 pid: null,
470 terminal: null,
471 }));
472 });
473
474 const probeWebUrl = Effect.fn("PortDiscovery.probeWebUrl")((url: string) =>461 const probeWebUrl = Effect.fn("PortDiscovery.probeWebUrl")((url: string) =>
475 httpClient.get(url).pipe(462 httpClient.get(url).pipe(
476 Effect.map((response) => {463 Effect.map((response) => {
...@@ -524,6 +511,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -524,6 +511,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
524 }511 }
525512
526 for (const server of servers) {513 for (const server of servers) {
514 // A listener alone does not identify its protocol. Sending HTTP or TLS to
515 // another app's binary RPC listener can crash it before classification.
516 if (server.terminal === null) continue;
527 groups.push({517 groups.push({
528 server,518 server,
529 urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`],519 urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`],
...@@ -616,7 +606,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -616,7 +606,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
616606
617 const recoverProcessProbeFailure =607 const recoverProcessProbeFailure =
618 (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) =>608 (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) =>
619 Effect.logDebug("preview port process probe failed; falling back to common-port probes", {609 Effect.logDebug("preview port process probe failed; probing configured URLs only", {
620 cause: error,610 cause: error,
621 probe,611 probe,
622 platform: hostPlatform,612 platform: hostPlatform,
...@@ -642,7 +632,6 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -642,7 +632,6 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
642 args: ["-NoProfile", "-NonInteractive", "-Command", command],632 args: ["-NoProfile", "-NonInteractive", "-Command", command],
643 timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS),633 timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS),
644 maxOutputBytes: 1024 * 1024,634 maxOutputBytes: 1024 * 1024,
645 outputMode: "truncate",
646 })635 })
647 .pipe(636 .pipe(
648 Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)),637 Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)),
...@@ -655,20 +644,20 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -655,20 +644,20 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
655 }),644 }),
656 );645 );
657 if (listeners !== null) return yield* probeWebServers(listeners, configuredUrls);646 if (listeners !== null) return yield* probeWebServers(listeners, configuredUrls);
658 return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls);647 return yield* probeWebServers([], configuredUrls);
659 }648 }
660 const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof");649 const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof");
661 if (yield* Ref.get(lsofMissingRef)) {650 if (yield* Ref.get(lsofMissingRef)) {
662 const fromProc = yield* scanProcListeners(terminalByProcessId);651 const fromProc = yield* scanProcListeners(terminalByProcessId);
663 return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls);652 return yield* probeWebServers(fromProc ?? [], configuredUrls);
664 }653 }
665 const lsofResult = yield* processRunner654 const lsofResult = yield* processRunner
666 .run({655 .run({
667 command: "lsof",656 command: "lsof",
668 args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"],657 args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"],
669 timeout: Duration.millis(LSOF_TIMEOUT_MS),658 timeout: Duration.millis(LSOF_TIMEOUT_MS),
659 // Partial output could hide another app's listener on an owned port.
670 maxOutputBytes: 1024 * 1024,660 maxOutputBytes: 1024 * 1024,
671 outputMode: "truncate",
672 })661 })
673 .pipe(662 .pipe(
674 Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)),663 Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)),
...@@ -686,7 +675,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -686,7 +675,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
686 );675 );
687 if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls);676 if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls);
688 const fromProc = yield* scanProcListeners(terminalByProcessId);677 const fromProc = yield* scanProcListeners(terminalByProcessId);
689 return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls);678 return yield* probeWebServers(fromProc ?? [], configuredUrls);
690 });679 });
691680
692 const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")(681 const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")(