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 {
77 type DiscoveredLocalServer,
88} from "@t3tools/contracts";
99import * as HostProcess from "@t3tools/shared/HostProcess";
10import * as Net from "@t3tools/shared/Net";
1110import * as Cause from "effect/Cause";
1211import * as Clock from "effect/Clock";
1312import * as Duration from "effect/Duration";
......@@ -44,17 +43,7 @@ const layerTestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
4443 run: processProbeFailure,
4544});
4645
47let integrationListeningPort: number | null = null;
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. */
46/** A host without `/proc`, so a missing `lsof` leaves only configured URLs. */
5847const layerNoProc = FileSystem.layerNoop({});
5948
6049const layerProbeFailure = (
......@@ -67,13 +56,6 @@ const layerProbeFailure = (
6756 Layer.mergeAll(
6857 fileSystem,
6958 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 }),
7759 Layer.succeed(HostProcess.Platform, "linux"),
7860 FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch))),
7961 ),
......@@ -85,7 +67,6 @@ const layerTestPortDiscovery = PortScanner.layer.pipe(
8567 Layer.mergeAll(
8668 layerNoProc,
8769 layerTestProcessRunner,
88 layerTestIntegrationNet,
8970 Layer.succeed(HostProcess.Platform, "win32"),
9071 FetchHttpClient.layer,
9172 ),
......@@ -94,9 +75,20 @@ const layerTestPortDiscovery = PortScanner.layer.pipe(
9475
9576const 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
9788const layerLsofScanner = (input: {
9889 readonly pid: () => number;
9990 readonly fetch: typeof globalThis.fetch;
91 readonly stdout?: () => string;
10092}) =>
10193 PortScanner.layer.pipe(
10294 Layer.provide(
......@@ -105,7 +97,7 @@ const layerLsofScanner = (input: {
10597 Layer.succeed(ProcessRunner.ProcessRunner, {
10698 run: () =>
10799 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`,
109101 stderr: "",
110102 code: null,
111103 timedOut: false,
......@@ -115,13 +107,6 @@ const layerLsofScanner = (input: {
115107 stderrInvalidUtf8: false,
116108 }),
117109 }),
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 }),
125110 Layer.succeed(HostProcess.Platform, "linux"),
126111 FetchHttpClient.layer.pipe(
127112 Layer.provide(Layer.succeed(FetchHttpClient.Fetch, input.fetch)),
......@@ -153,73 +138,45 @@ const closeServer = (server: NodeNet.Server): Effect.Effect<void> =>
153138 });
154139
155140const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* (
156 ports: ReadonlyArray<number>,
157141 onConnection: (socket: NodeNet.Socket) => void,
158142) {
159 for (const port of ports) {
160 const server = yield* openServer(port, onConnection);
161 if (server !== null) return { port, server };
143 const server = yield* openServer(0, onConnection);
144 const address = server?.address();
145 if (!server || !address || typeof address === "string") {
146 return yield* Effect.die(new Error("Could not open the preview scanner test listener"));
162147 }
163 return yield* Effect.die(
164 new Error("No common development port was available for the preview scanner test"),
165 );
148 return { port: address.port, server };
166149});
167150
168151const commonDevServer = Effect.acquireRelease(
169 openCommonDevServer(PortScanner.COMMON_DEV_PORTS, (socket) => {
152 openCommonDevServer((socket) => {
170153 socket.once("data", () => {
171154 socket.end("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello");
172155 });
173 }).pipe(
174 Effect.tap(({ port }) =>
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 ),
156 }),
157 ({ server }) => closeServer(server),
188158);
189159
190160const commonNonHttpServer = Effect.acquireRelease(
191 openCommonDevServer(PortScanner.COMMON_DEV_PORTS.toReversed(), (socket) => {
161 openCommonDevServer((socket) => {
192162 socket.on("error", () => undefined);
193163 socket.once("data", () => socket.end("MYSQL\r\n\r\n"));
194 }).pipe(
195 Effect.tap(({ port }) =>
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 ),
164 }),
165 ({ server }) => closeServer(server),
209166);
210167
211168/**
212169 * Integration tests against a real TCP listener. We provide the Windows host
213 * platform so the tests exercise the TCP-probe fallback without depending on
214 * `lsof` being installed.
170 * platform with a failing listener probe so the tests exercise configured URLs
171 * without depending on `lsof` being installed.
215172 */
216effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fallback)", (it) => {
173effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (configured URLs)", (it) => {
217174 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",
219176 Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () {
220177 const { port } = yield* commonDevServer;
221178 const scanner = yield* PortScanner.PortDiscovery;
222 const result = yield* scanner.scan();
179 const result = yield* scanner.scan([`http://localhost:${port}`]);
223180 const found = result.find((server) => server.port === port);
224181 expect(found).toBeDefined();
225182 expect(found?.host).toBe("localhost");
......@@ -231,7 +188,7 @@ effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fal
231188 Effect.fn("PortScannerTest.scanExcludesNonHttpServer")(function* () {
232189 const { port } = yield* commonNonHttpServer;
233190 const scanner = yield* PortScanner.PortDiscovery;
234 const result = yield* scanner.scan();
191 const result = yield* scanner.scan([`http://localhost:${port}`]);
235192 expect(result.some((server) => server.port === port)).toBe(false);
236193 }),
237194 );
......@@ -242,10 +199,12 @@ effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fal
242199 const { port } = yield* commonDevServer;
243200 const received: number[] = [];
244201 const scanner = yield* PortScanner.PortDiscovery;
245 yield* scanner.subscribe({ configuredUrls: [], initialSnapshot: [] }, (servers) =>
246 Effect.sync(() => {
247 for (const server of servers) received.push(server.port);
248 }),
202 yield* scanner.subscribe(
203 { configuredUrls: [`http://localhost:${port}`], initialSnapshot: [] },
204 (servers) =>
205 Effect.sync(() => {
206 for (const server of servers) received.push(server.port);
207 }),
249208 );
250209 yield* scanner.retain;
251210 expect(received).toContain(port);
......@@ -265,7 +224,7 @@ effectIt.effect("revalidates a successful HTML probe after its cache entry expir
265224 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });
266225
267226 return Effect.gen(function* () {
268 const scanner = yield* PortScanner.PortDiscovery;
227 const scanner = yield* ownedScanner();
269228 expect(yield* scanner.scan()).toHaveLength(1);
270229 expect(yield* scanner.scan()).toHaveLength(1);
271230 expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]);
......@@ -499,7 +458,7 @@ effectIt.effect("shares a configured root probe with discovered-root classificat
499458 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });
500459
501460 return Effect.gen(function* () {
502 const scanner = yield* PortScanner.PortDiscovery;
461 const scanner = yield* ownedScanner();
503462 expect(yield* scanner.scan([rootUrl])).toHaveLength(1);
504463 expect(requests).toEqual([rootUrl]);
505464
......@@ -528,7 +487,7 @@ effectIt.effect("starts fresh cache entries after the probing batch completes",
528487 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });
529488
530489 yield* Effect.gen(function* () {
531 const scanner = yield* PortScanner.PortDiscovery;
490 const scanner = yield* ownedScanner();
532491 expect(yield* scanner.scan()).toHaveLength(1);
533492 expect(yield* scanner.scan()).toHaveLength(1);
534493 expect(requests).toHaveLength(1);
......@@ -548,7 +507,7 @@ effectIt.effect("caches a failed web probe until its bounded cache entry expires
548507 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });
549508
550509 return Effect.gen(function* () {
551 const scanner = yield* PortScanner.PortDiscovery;
510 const scanner = yield* ownedScanner();
552511 expect(yield* scanner.scan()).toHaveLength(0);
553512 expect(yield* scanner.scan()).toHaveLength(0);
554513 expect(requests).toHaveLength(2);
......@@ -573,7 +532,7 @@ effectIt.effect("falls back to HTTPS and does not follow redirects while probing
573532 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });
574533
575534 return Effect.gen(function* () {
576 const scanner = yield* PortScanner.PortDiscovery;
535 const scanner = yield* ownedScanner();
577536 const servers = yield* scanner.scan();
578537 expect(servers).toHaveLength(1);
579538 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
581540 }).pipe(Effect.provide(layer));
582541});
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
584574effectIt.effect(
585575 "excludes HTTP errors, non-navigation responses, and successful non-documents",
586576 () => {
......@@ -592,7 +582,7 @@ effectIt.effect(
592582 const layer = layerLsofScanner({ pid: () => pid, fetch: fetchFn });
593583
594584 return Effect.gen(function* () {
595 const scanner = yield* PortScanner.PortDiscovery;
585 const scanner = yield* ownedScanner([1, 2, 3, 4, 5, 6, 7]);
596586 expect(yield* scanner.scan()).toHaveLength(0);
597587
598588 pid += 1;
......@@ -650,7 +640,7 @@ effectIt.effect("aborts HTTP and HTTPS probes when they time out", () => {
650640 const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn });
651641
652642 return Effect.gen(function* () {
653 const scanner = yield* PortScanner.PortDiscovery;
643 const scanner = yield* ownedScanner();
654644 const scanFiber = yield* Effect.forkChild(scanner.scan());
655645 yield* TestClock.adjust(Duration.seconds(2));
656646 expect(yield* Fiber.join(scanFiber)).toHaveLength(0);
......@@ -739,11 +729,10 @@ effectIt.effect(
739729 );
740730
741731 return Effect.gen(function* () {
742 const scanner = yield* PortScanner.PortDiscovery;
732 const scanner = yield* ownedScanner([4242]);
743733 const first = yield* scanner.scan();
734 // Ports 22 and 3001 have no known owner, so they never receive probes.
744735 expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([
745 { port: 22, pid: null, processName: null },
746 { port: 3001, pid: null, processName: null },
747736 { port: 8765, pid: 4242, processName: "python3" },
748737 ]);
749738 yield* scanner.scan();
apps/server/src/preview/PortScanner.ts+34-45
......@@ -8,8 +8,8 @@
88 * Linux without lsof: reads listening sockets from `/proc/net/tcp{,6}` and
99 * finds their processes through `/proc/<pid>/fd`.
1010 *
11 * Windows listener failures, or neither source: checks a curated list of
12 * common dev ports through the shared Net service.
11 * Only listeners owned by T3 terminal processes and explicitly configured
12 * URLs receive probes. Without listener ownership, only configured URLs do.
1313 *
1414 * Listening ports are published only after a bounded HTTP(S) probe finds a
1515 * successful HTML document or a redirect to one.
......@@ -26,7 +26,6 @@ import {
2626 type DiscoveredLocalServer,
2727} from "@t3tools/contracts";
2828import * as HostProcess from "@t3tools/shared/HostProcess";
29import * as Net from "@t3tools/shared/Net";
3029import { isLoopbackHost, LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview";
3130import * as Cause from "effect/Cause";
3231import * as Clock from "effect/Clock";
......@@ -71,10 +70,6 @@ export class PortDiscovery extends Context.Service<
7170 }
7271>()("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
7873const POLL_INTERVAL = Duration.seconds(3);
7974const LSOF_TIMEOUT_MS = 5_000;
8075/** File descriptors one `/proc` walk may read before it stops looking for socket owners. */
......@@ -218,14 +213,20 @@ const parseLsofOutput = (
218213 if (portMatch == null) continue;
219214 const url = `http://localhost:${portMatch}`;
220215 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 }
222223 seen.set(key, {
223224 host: "localhost",
224225 port: portMatch,
225226 url,
226227 processName,
227228 pid,
228 terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null),
229 terminal,
229230 });
230231 }
231232 }
......@@ -261,14 +262,20 @@ const parseWindowsListenerOutput = (
261262 const pid = Number(pidRaw);
262263 if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue;
263264 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 }
265272 seen.set(port, {
266273 host: "localhost",
267274 port,
268275 url: `http://localhost:${port}`,
269276 processName: processNameRaw?.trim() || null,
270277 pid: normalizedPid,
271 terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null),
278 terminal,
272279 });
273280 }
274281 return [...seen.values()].toSorted((left, right) => left.port - right.port);
......@@ -339,7 +346,6 @@ const isCommandNotFound = (error: ProcessRunner.ProcessSpawnError): boolean =>
339346
340347/** @public Service construction is part of the canonical Effect module API. */
341348export const make = Effect.gen(function* PortDiscoveryMake() {
342 const net = yield* Net.NetService;
343349 const processRunner = yield* ProcessRunner.ProcessRunner;
344350 const fileSystem = yield* FileSystem.FileSystem;
345351 const hostPlatform = yield* HostProcess.Platform;
......@@ -398,7 +404,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
398404
399405 /**
400406 * 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.
402408 */
403409 const scanProcListeners = Effect.fn("PortDiscovery.scanProcListeners")(function* (
404410 terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner>,
......@@ -433,44 +439,25 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
433439 const seen = new Map<number, DiscoveredLocalServer>();
434440 for (const { port, inode } of listeners) {
435441 const owner = owners.get(inode) ?? null;
442 const terminal = owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null);
436443 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 }
438448 seen.set(port, {
439449 host: "localhost",
440450 port,
441451 url: `http://localhost:${port}`,
442452 processName: owner?.processName ?? null,
443453 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,
445456 });
446457 }
447458 return [...seen.values()].toSorted((left, right) => left.port - right.port);
448459 });
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
474461 const probeWebUrl = Effect.fn("PortDiscovery.probeWebUrl")((url: string) =>
475462 httpClient.get(url).pipe(
476463 Effect.map((response) => {
......@@ -524,6 +511,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
524511 }
525512
526513 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;
527517 groups.push({
528518 server,
529519 urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`],
......@@ -616,7 +606,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
616606
617607 const recoverProcessProbeFailure =
618608 (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", {
620610 cause: error,
621611 probe,
622612 platform: hostPlatform,
......@@ -642,7 +632,6 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
642632 args: ["-NoProfile", "-NonInteractive", "-Command", command],
643633 timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS),
644634 maxOutputBytes: 1024 * 1024,
645 outputMode: "truncate",
646635 })
647636 .pipe(
648637 Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)),
......@@ -655,20 +644,20 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
655644 }),
656645 );
657646 if (listeners !== null) return yield* probeWebServers(listeners, configuredUrls);
658 return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls);
647 return yield* probeWebServers([], configuredUrls);
659648 }
660649 const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof");
661650 if (yield* Ref.get(lsofMissingRef)) {
662651 const fromProc = yield* scanProcListeners(terminalByProcessId);
663 return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls);
652 return yield* probeWebServers(fromProc ?? [], configuredUrls);
664653 }
665654 const lsofResult = yield* processRunner
666655 .run({
667656 command: "lsof",
668657 args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"],
669658 timeout: Duration.millis(LSOF_TIMEOUT_MS),
659 // Partial output could hide another app's listener on an owned port.
670660 maxOutputBytes: 1024 * 1024,
671 outputMode: "truncate",
672661 })
673662 .pipe(
674663 Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)),
......@@ -686,7 +675,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
686675 );
687676 if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls);
688677 const fromProc = yield* scanProcListeners(terminalByProcessId);
689 return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls);
678 return yield* probeWebServers(fromProc ?? [], configuredUrls);
690679 });
691680
692681 const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")(