diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 8a6bf87d4699562e57b58531be71876867f233e6..8aae21d8cf71bb2fb561c397ad446bb60f201f51 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -7,7 +7,6 @@ import { type DiscoveredLocalServer, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Net from "@t3tools/shared/Net"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; @@ -44,17 +43,7 @@ const layerTestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { run: processProbeFailure, }); -let integrationListeningPort: number | null = null; - -const layerTestIntegrationNet = Layer.succeed(Net.NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: (port) => Effect.sync(() => port !== integrationListeningPort), - hasListenerOnHost: (port) => Effect.sync(() => port === integrationListeningPort), - reserveLoopbackPort: () => Effect.succeed(40_000), - findAvailablePort: (preferred) => Effect.succeed(preferred), -}); - -/** A host without `/proc`, so a missing `lsof` falls back to common ports. */ +/** A host without `/proc`, so a missing `lsof` leaves only configured URLs. */ const layerNoProc = FileSystem.layerNoop({}); const layerProbeFailure = ( @@ -67,13 +56,6 @@ const layerProbeFailure = ( Layer.mergeAll( fileSystem, Layer.succeed(ProcessRunner.ProcessRunner, { run }), - Layer.succeed(Net.NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: () => Effect.succeed(true), - hasListenerOnHost: () => Effect.succeed(false), - reserveLoopbackPort: () => Effect.succeed(40_000), - findAvailablePort: (preferred) => Effect.succeed(preferred), - }), Layer.succeed(HostProcess.Platform, "linux"), FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch))), ), @@ -85,7 +67,6 @@ const layerTestPortDiscovery = PortScanner.layer.pipe( Layer.mergeAll( layerNoProc, layerTestProcessRunner, - layerTestIntegrationNet, Layer.succeed(HostProcess.Platform, "win32"), FetchHttpClient.layer, ), @@ -94,9 +75,20 @@ const layerTestPortDiscovery = PortScanner.layer.pipe( const LSOF_TEST_PORT = 43_123; +/** The scanner with `processIds` registered to a T3 terminal, so their listeners get probes. */ +const ownedScanner = (processIds: ReadonlyArray = [1234]) => + Effect.tap(PortScanner.PortDiscovery, (scanner) => + scanner.registerTerminalProcesses({ + threadId: "scanner-thread", + terminalId: "scanner-terminal", + processIds, + }), + ); + const layerLsofScanner = (input: { readonly pid: () => number; readonly fetch: typeof globalThis.fetch; + readonly stdout?: () => string; }) => PortScanner.layer.pipe( Layer.provide( @@ -105,7 +97,7 @@ const layerLsofScanner = (input: { Layer.succeed(ProcessRunner.ProcessRunner, { run: () => Effect.succeed({ - stdout: `p${input.pid()}\ncnode\nn*:${LSOF_TEST_PORT}\n`, + stdout: input.stdout?.() ?? `p${input.pid()}\ncnode\nn*:${LSOF_TEST_PORT}\n`, stderr: "", code: null, timedOut: false, @@ -115,13 +107,6 @@ const layerLsofScanner = (input: { stderrInvalidUtf8: false, }), }), - Layer.succeed(Net.NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: () => Effect.succeed(true), - hasListenerOnHost: () => Effect.succeed(false), - reserveLoopbackPort: () => Effect.succeed(40_000), - findAvailablePort: (preferred) => Effect.succeed(preferred), - }), Layer.succeed(HostProcess.Platform, "linux"), FetchHttpClient.layer.pipe( Layer.provide(Layer.succeed(FetchHttpClient.Fetch, input.fetch)), @@ -153,73 +138,45 @@ const closeServer = (server: NodeNet.Server): Effect.Effect => }); const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( - ports: ReadonlyArray, onConnection: (socket: NodeNet.Socket) => void, ) { - for (const port of ports) { - const server = yield* openServer(port, onConnection); - if (server !== null) return { port, server }; + const server = yield* openServer(0, onConnection); + const address = server?.address(); + if (!server || !address || typeof address === "string") { + return yield* Effect.die(new Error("Could not open the preview scanner test listener")); } - return yield* Effect.die( - new Error("No common development port was available for the preview scanner test"), - ); + return { port: address.port, server }; }); const commonDevServer = Effect.acquireRelease( - openCommonDevServer(PortScanner.COMMON_DEV_PORTS, (socket) => { + openCommonDevServer((socket) => { socket.once("data", () => { socket.end("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello"); }); - }).pipe( - Effect.tap(({ port }) => - Effect.sync(() => { - integrationListeningPort = port; - }), - ), - ), - ({ server }) => - closeServer(server).pipe( - Effect.ensuring( - Effect.sync(() => { - integrationListeningPort = null; - }), - ), - ), + }), + ({ server }) => closeServer(server), ); const commonNonHttpServer = Effect.acquireRelease( - openCommonDevServer(PortScanner.COMMON_DEV_PORTS.toReversed(), (socket) => { + openCommonDevServer((socket) => { socket.on("error", () => undefined); socket.once("data", () => socket.end("MYSQL\r\n\r\n")); - }).pipe( - Effect.tap(({ port }) => - Effect.sync(() => { - integrationListeningPort = port; - }), - ), - ), - ({ server }) => - closeServer(server).pipe( - Effect.ensuring( - Effect.sync(() => { - integrationListeningPort = null; - }), - ), - ), + }), + ({ server }) => closeServer(server), ); /** * Integration tests against a real TCP listener. We provide the Windows host - * platform so the tests exercise the TCP-probe fallback without depending on - * `lsof` being installed. + * platform with a failing listener probe so the tests exercise configured URLs + * without depending on `lsof` being installed. */ -effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fallback)", (it) => { +effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (configured URLs)", (it) => { it.effect( - "scan() returns an HTTP server we just opened on a curated dev port", + "scan() returns a configured HTTP server we just opened", Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () { const { port } = yield* commonDevServer; const scanner = yield* PortScanner.PortDiscovery; - const result = yield* scanner.scan(); + const result = yield* scanner.scan([`http://localhost:${port}`]); const found = result.find((server) => server.port === port); expect(found).toBeDefined(); expect(found?.host).toBe("localhost"); @@ -231,7 +188,7 @@ effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fal Effect.fn("PortScannerTest.scanExcludesNonHttpServer")(function* () { const { port } = yield* commonNonHttpServer; const scanner = yield* PortScanner.PortDiscovery; - const result = yield* scanner.scan(); + const result = yield* scanner.scan([`http://localhost:${port}`]); expect(result.some((server) => server.port === port)).toBe(false); }), ); @@ -242,10 +199,12 @@ effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fal const { port } = yield* commonDevServer; const received: number[] = []; const scanner = yield* PortScanner.PortDiscovery; - yield* scanner.subscribe({ configuredUrls: [], initialSnapshot: [] }, (servers) => - Effect.sync(() => { - for (const server of servers) received.push(server.port); - }), + yield* scanner.subscribe( + { configuredUrls: [`http://localhost:${port}`], initialSnapshot: [] }, + (servers) => + Effect.sync(() => { + for (const server of servers) received.push(server.port); + }), ); yield* scanner.retain; expect(received).toContain(port); @@ -265,7 +224,7 @@ effectIt.effect("revalidates a successful HTML probe after its cache entry expir const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan()).toHaveLength(1); expect(yield* scanner.scan()).toHaveLength(1); expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); @@ -499,7 +458,7 @@ effectIt.effect("shares a configured root probe with discovered-root classificat const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan([rootUrl])).toHaveLength(1); expect(requests).toEqual([rootUrl]); @@ -528,7 +487,7 @@ effectIt.effect("starts fresh cache entries after the probing batch completes", const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); yield* Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan()).toHaveLength(1); expect(yield* scanner.scan()).toHaveLength(1); expect(requests).toHaveLength(1); @@ -548,7 +507,7 @@ effectIt.effect("caches a failed web probe until its bounded cache entry expires const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan()).toHaveLength(0); expect(yield* scanner.scan()).toHaveLength(0); expect(requests).toHaveLength(2); @@ -573,7 +532,7 @@ effectIt.effect("falls back to HTTPS and does not follow redirects while probing const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); const servers = yield* scanner.scan(); expect(servers).toHaveLength(1); 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 }).pipe(Effect.provide(layer)); }); +effectIt.effect("probes a port only while T3 terminals own every listener on it", () => { + const owned = `p1234\ncnode\nn[::1]:${LSOF_TEST_PORT}\n`; + const unowned = `p5678\ncrpc\nn127.0.0.1:${LSOF_TEST_PORT}\n`; + let stdout = unowned; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("app", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; + const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn, stdout: () => stdout }); + + return Effect.gen(function* () { + const scanner = yield* ownedScanner(); + expect(yield* scanner.scan()).toHaveLength(0); + stdout = `${owned}${unowned}`; + expect(yield* scanner.scan()).toHaveLength(0); + expect(requests).toEqual([]); + + stdout = owned; + expect(yield* scanner.scan()).toHaveLength(1); + expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); + + yield* scanner.unregisterTerminal({ + threadId: "scanner-thread", + terminalId: "scanner-terminal", + }); + expect(yield* scanner.scan()).toHaveLength(0); + expect(requests).toHaveLength(1); + }).pipe(Effect.provide(layer)); +}); + effectIt.effect( "excludes HTTP errors, non-navigation responses, and successful non-documents", () => { @@ -592,7 +582,7 @@ effectIt.effect( const layer = layerLsofScanner({ pid: () => pid, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner([1, 2, 3, 4, 5, 6, 7]); expect(yield* scanner.scan()).toHaveLength(0); pid += 1; @@ -650,7 +640,7 @@ effectIt.effect("aborts HTTP and HTTPS probes when they time out", () => { const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); const scanFiber = yield* Effect.forkChild(scanner.scan()); yield* TestClock.adjust(Duration.seconds(2)); expect(yield* Fiber.join(scanFiber)).toHaveLength(0); @@ -739,11 +729,10 @@ effectIt.effect( ); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner([4242]); const first = yield* scanner.scan(); + // Ports 22 and 3001 have no known owner, so they never receive probes. expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([ - { port: 22, pid: null, processName: null }, - { port: 3001, pid: null, processName: null }, { port: 8765, pid: 4242, processName: "python3" }, ]); yield* scanner.scan(); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 9b1d9e9663335d29e50fb15be7cb151460975ec8..7a59079ea9f4787979773b83a3731c9b0bf63e46 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -8,8 +8,8 @@ * Linux without lsof: reads listening sockets from `/proc/net/tcp{,6}` and * finds their processes through `/proc//fd`. * - * Windows listener failures, or neither source: checks a curated list of - * common dev ports through the shared Net service. + * Only listeners owned by T3 terminal processes and explicitly configured + * URLs receive probes. Without listener ownership, only configured URLs do. * * Listening ports are published only after a bounded HTTP(S) probe finds a * successful HTML document or a redirect to one. @@ -26,7 +26,6 @@ import { type DiscoveredLocalServer, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Net from "@t3tools/shared/Net"; import { isLoopbackHost, LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -71,10 +70,6 @@ export class PortDiscovery extends Context.Service< } >()("t3/preview/PortScanner/PortDiscovery") {} -export const COMMON_DEV_PORTS: ReadonlyArray = Object.freeze([ - 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000, -]); - const POLL_INTERVAL = Duration.seconds(3); const LSOF_TIMEOUT_MS = 5_000; /** File descriptors one `/proc` walk may read before it stops looking for socket owners. */ @@ -218,14 +213,20 @@ const parseLsofOutput = ( if (portMatch == null) continue; const url = `http://localhost:${portMatch}`; const key = `localhost:${portMatch}`; - if (seen.has(key)) continue; + const terminal = pid === null ? null : (terminalByProcessId.get(pid) ?? null); + const existing = seen.get(key); + // A port receives probes only when T3 terminals own all of its listeners. + if (existing) { + if (terminal === null) seen.set(key, { ...existing, terminal: null }); + continue; + } seen.set(key, { host: "localhost", port: portMatch, url, processName, pid, - terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null), + terminal, }); } } @@ -261,14 +262,20 @@ const parseWindowsListenerOutput = ( const pid = Number(pidRaw); if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue; const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null; - if (seen.has(port)) continue; + const terminal = + normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null); + const existing = seen.get(port); + if (existing) { + if (terminal === null) seen.set(port, { ...existing, terminal: null }); + continue; + } seen.set(port, { host: "localhost", port, url: `http://localhost:${port}`, processName: processNameRaw?.trim() || null, pid: normalizedPid, - terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null), + terminal, }); } return [...seen.values()].toSorted((left, right) => left.port - right.port); @@ -339,7 +346,6 @@ const isCommandNotFound = (error: ProcessRunner.ProcessSpawnError): boolean => /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* PortDiscoveryMake() { - const net = yield* Net.NetService; const processRunner = yield* ProcessRunner.ProcessRunner; const fileSystem = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcess.Platform; @@ -398,7 +404,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { /** * Linux listeners from `/proc/net/tcp{,6}`, for hosts without `lsof`. Null - * when neither file is readable, so discovery falls back to common ports. + * when neither file is readable, so only configured URLs are probed. */ const scanProcListeners = Effect.fn("PortDiscovery.scanProcListeners")(function* ( terminalByProcessId: ReadonlyMap, @@ -433,44 +439,25 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const seen = new Map(); for (const { port, inode } of listeners) { const owner = owners.get(inode) ?? null; + const terminal = owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null); const existing = seen.get(port); - if (existing && (existing.pid !== null || owner === null)) continue; + if (existing && (existing.pid !== null || owner === null)) { + if (terminal === null) seen.set(port, { ...existing, terminal: null }); + continue; + } seen.set(port, { host: "localhost", port, url: `http://localhost:${port}`, processName: owner?.processName ?? null, pid: owner?.pid ?? null, - terminal: owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null), + // A replaced listener had no known owner, so the port stays unowned. + terminal: existing ? null : terminal, }); } return [...seen.values()].toSorted((left, right) => left.port - right.port); }); - const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { - const results = yield* Effect.forEach( - COMMON_DEV_PORTS, - (port) => - net.isPortAvailableOnLoopback(port).pipe( - Effect.map((available) => ({ - port, - listening: !available, - })), - ), - { concurrency: "unbounded" }, - ); - return results - .filter((result) => result.listening) - .map((result) => ({ - host: "localhost", - port: result.port, - url: `http://localhost:${result.port}`, - processName: null, - pid: null, - terminal: null, - })); - }); - const probeWebUrl = Effect.fn("PortDiscovery.probeWebUrl")((url: string) => httpClient.get(url).pipe( Effect.map((response) => { @@ -524,6 +511,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() { } for (const server of servers) { + // A listener alone does not identify its protocol. Sending HTTP or TLS to + // another app's binary RPC listener can crash it before classification. + if (server.terminal === null) continue; groups.push({ server, urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`], @@ -616,7 +606,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const recoverProcessProbeFailure = (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) => - Effect.logDebug("preview port process probe failed; falling back to common-port probes", { + Effect.logDebug("preview port process probe failed; probing configured URLs only", { cause: error, probe, platform: hostPlatform, @@ -642,7 +632,6 @@ export const make = Effect.gen(function* PortDiscoveryMake() { args: ["-NoProfile", "-NonInteractive", "-Command", command], timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), maxOutputBytes: 1024 * 1024, - outputMode: "truncate", }) .pipe( Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), @@ -655,20 +644,20 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }), ); if (listeners !== null) return yield* probeWebServers(listeners, configuredUrls); - return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); + return yield* probeWebServers([], configuredUrls); } const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); if (yield* Ref.get(lsofMissingRef)) { const fromProc = yield* scanProcListeners(terminalByProcessId); - return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls); + return yield* probeWebServers(fromProc ?? [], configuredUrls); } const lsofResult = yield* processRunner .run({ command: "lsof", args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], timeout: Duration.millis(LSOF_TIMEOUT_MS), + // Partial output could hide another app's listener on an owned port. maxOutputBytes: 1024 * 1024, - outputMode: "truncate", }) .pipe( Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), @@ -686,7 +675,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ); if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls); const fromProc = yield* scanProcListeners(terminalByProcessId); - return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls); + return yield* probeWebServers(fromProc ?? [], configuredUrls); }); const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")(