diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 3dee90313a2b3..db13a996255ea 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -436,17 +436,26 @@ const PROC_NET_TCP6 = ` sl local_address remote_addres `; effectIt.effect( - "reads Linux listeners from /proc when lsof is missing, and stops spawning it", + "prefers Linux /proc over installed lsof, and bounds repeated socket owner lookups", () => { let lsofSpawns = 0; const fdWalks: string[] = []; const procFiles: Record = { - "/proc/net/tcp": PROC_NET_TCP, + "/proc/net/tcp": + PROC_NET_TCP + + PROC_NET_TCP.split("\n")[1]! + .replace("0100007F:223D", "0101007F:1388") + .replace("5001", "5007"), "/proc/net/tcp6": PROC_NET_TCP6, "/proc/4242/comm": "python3\n", }; const procFds: Record> = { - "4242": { "0": "/dev/null", "3": "socket:[5001]" }, + "4242": { + "0": "/dev/null", + "3": "socket:[5001]", + "4": "socket:[5004]", + "5": "socket:[5007]", + }, "77": { "5": "socket:[9999]" }, }; const missing = FileSystem.makeNoop({}); @@ -475,9 +484,9 @@ effectIt.effect( return Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } })); }) as typeof globalThis.fetch; const layer = layerProbeFailure( - (input) => { + () => { lsofSpawns += 1; - return processProbeFailure(input); + return Effect.succeed(processOutput("p4242\ncpython3\nn*:3001\nn*:8765\n")); }, fetchFn, fileSystem, @@ -488,18 +497,137 @@ effectIt.effect( const first = yield* scanner.scan(); expect( first.map(({ port, pid, processName, url }) => ({ port, pid, processName, url })), - ).toEqual([{ port: 8765, pid: 4242, processName: "python3", url: "http://localhost:8765" }]); + ).toEqual([ + { port: 3001, pid: 4242, processName: "python3", url: "http://localhost:3001" }, + { port: 8765, pid: 4242, processName: "python3", url: "http://localhost:8765" }, + ]); yield* scanner.scan(); - expect(lsofSpawns).toBe(1); + expect(lsofSpawns).toBe(0); expect(fdWalks).toEqual(["77", "4242", "77", "4242"]); yield* scanner.scan(); yield* scanner.scan(); expect(fdWalks).toEqual(["77", "4242", "77", "4242", "77", "4242"]); + procFds["77"]!["6"] = "socket:[5006]"; + procFiles["/proc/net/tcp6"] = + PROC_NET_TCP6 + PROC_NET_TCP.split("\n")[1]!.replace("5001", "5006"); + expect((yield* scanner.scan()).map(({ port }) => port)).toEqual([3001]); + const walksAfterCollision = fdWalks.length; + procFiles["/proc/net/tcp"] = PROC_NET_TCP.split("\n")[0]!; + procFiles["/proc/net/tcp6"] = PROC_NET_TCP6.split("\n")[0]!; + expect(yield* scanner.scan()).toEqual([]); + expect(lsofSpawns).toBe(0); + expect(fdWalks).toHaveLength(walksAfterCollision); expect(requests).toEqual([]); + + procFiles["/proc/net/tcp"] = PROC_NET_TCP; + procFiles["/proc/net/tcp6"] = PROC_NET_TCP6; + expect((yield* scanner.scan()).map(({ pid }) => pid)).toEqual([4242, 4242]); + yield* scanner.retain.pipe(Effect.scoped); + procFds["77"] = procFds["4242"]!; + delete procFds["4242"]; + procFiles["/proc/77/comm"] = "python3\n"; + yield* scanner.registerTerminalProcesses({ + threadId: "scanner-thread", + terminalId: "scanner-terminal", + processIds: [77], + }); + expect((yield* scanner.scan()).map(({ port, pid }) => ({ port, pid }))).toEqual([ + { port: 3001, pid: 77 }, + { port: 8765, pid: 77 }, + ]); }).pipe(Effect.provide(layer)); }, ); +effectIt.effect.each([ + { mode: "missing", fallback: true }, + { mode: "denied", fallback: true }, + { mode: "invalid", fallback: true }, + { mode: "fd-unavailable", fallback: true }, + { mode: "fd-budget", fallback: true }, + { mode: "fd-budget-no-lsof", fallback: true }, + { mode: "ipv6-denied", fallback: true }, + { mode: "ipv6-absent", fallback: false }, + { mode: "empty", fallback: false }, + { mode: "darwin", fallback: true }, +] as const)("uses lsof only if Linux /proc is unusable: $mode", ({ mode, fallback }) => { + let lsofSpawns = 0; + let procWalks = 0; + const missing = FileSystem.makeNoop({}); + const layer = PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + readFileString: (path) => { + if (!path.startsWith("/proc/net/tcp") || mode === "missing") { + return missing.readFileString(path); + } + if (mode === "denied" || (mode === "ipv6-denied" && path.endsWith("tcp6"))) { + return Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + }), + ); + } + if (mode === "ipv6-absent" && path.endsWith("tcp6")) { + return missing.readFileString(path); + } + if (mode === "invalid") return Effect.succeed("unusable table"); + return Effect.succeed( + mode === "fd-unavailable" || mode.startsWith("fd-budget") + ? PROC_NET_TCP + : PROC_NET_TCP.split("\n")[0]!, + ); + }, + readDirectory: (path) => { + if (!mode.startsWith("fd-budget")) return missing.readDirectory(path); + if (path === "/proc") procWalks += 1; + if (mode === "fd-budget-no-lsof") { + if (path === "/proc") return Effect.succeed(["1234", "9999"]); + if (path === "/proc/1234/fd") return Effect.succeed(["3"]); + } + return Effect.succeed( + path === "/proc" + ? ["1", "1234"] + : Array.from({ length: 50_000 }, (_, i) => String(i)), + ); + }, + readLink: (path) => + mode === "fd-budget-no-lsof" && path === "/proc/1234/fd/3" + ? Effect.succeed("socket:[5001]") + : mode.startsWith("fd-budget") + ? Effect.succeed("/dev/null") + : missing.readLink(path), + }), + Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => { + lsofSpawns += 1; + if (mode === "fd-budget-no-lsof") return processProbeFailure(input); + return Effect.succeed(processOutput(`p1234\ncnode\nn*:${LSOF_TEST_PORT}\n`)); + }, + }), + Layer.succeed(HostProcess.Platform, mode === "darwin" ? "darwin" : "linux"), + FetchHttpClient.layer, + ), + ), + ); + return Effect.gen(function* () { + const scanner = yield* ownedScanner(); + expect((yield* scanner.scan()).map(({ port }) => port)).toEqual( + mode === "fd-budget-no-lsof" ? [8765] : fallback ? [LSOF_TEST_PORT] : [], + ); + if (mode === "fd-budget-no-lsof") { + for (let i = 0; i < 3; i += 1) { + expect((yield* scanner.scan()).map(({ port }) => port)).toEqual([8765]); + } + expect(procWalks).toBe(4); + } + expect(lsofSpawns).toBe(fallback ? 1 : 0); + }).pipe(Effect.provide(layer)); +}); + effectIt.effect("ignores a partial Windows netstat snapshot when one query exits nonzero", () => { let ipv6Fails = true; const requests: string[] = []; diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index a75603ec02cee..78f134fc1b813 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -311,7 +311,7 @@ const PROC_TCP_LISTEN = "0A"; /** Each 32-bit word of the hex address is little-endian. */ const procLocalAddress = (hex: string): boolean => { if (hex.length === 8) { - return hex === "00000000" || hex.endsWith("7F"); + return hex === "00000000" || hex === "0100007F"; } if (hex.length !== 32) return false; if (hex === "0".repeat(32) || hex === `${"0".repeat(24)}01000000`) return true; @@ -543,11 +543,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() { inodes: ReadonlySet, ) { const owners = new Map(); - const pids = (yield* fileSystem - .readDirectory("/proc") - .pipe(Effect.orElseSucceed((): ReadonlyArray => []))).filter((entry) => - /^\d+$/.test(entry), - ); + const entries = yield* fileSystem.readDirectory("/proc").pipe(Effect.option); + if (Option.isNone(entries)) return null; + const pids = entries.value.filter((entry) => /^\d+$/.test(entry)); let budget = PROC_FD_WALK_LIMIT; for (const pidText of pids) { if (owners.size === inodes.size || budget <= 0) break; @@ -571,7 +569,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ); for (const inode of matched) owners.set(inode, { pid: Number(pidText), processName }); } - return owners; + const lsofMissing = yield* Ref.get(lsofMissingRef); + return budget <= 0 && owners.size < inodes.size && !lsofMissing ? null : owners; }); const scanProcListeners = Effect.fn("PortDiscovery.scanProcListeners")(function* ( @@ -579,12 +578,27 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ) { if (hostPlatform !== "linux") return null; const tables = yield* Effect.forEach(["/proc/net/tcp", "/proc/net/tcp6"], (path) => - fileSystem.readFileString(path).pipe(Effect.option), + fileSystem.readFileString(path).pipe( + // Kernels without IPv6 omit tcp6; a denied table can hide listeners on a shared port. + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed(null), + ), + Effect.option, + ), ); - if (tables.every(Option.isNone)) return null; - const listeners = tables.flatMap((table) => - Option.isSome(table) ? parseProcNetTcp(table.value) : [], + if (tables.some(Option.isNone)) return null; + const readable = tables.flatMap((table) => + Option.isSome(table) && table.value !== null ? [table.value] : [], ); + if ( + readable.length === 0 || + readable.some( + (table) => !/^\s*sl\s+local_address\s+(?:rem|remote)_address\s+st\s/.test(table), + ) + ) + return null; + const listeners = readable.flatMap(parseProcNetTcp); const known = yield* Ref.get(socketOwnersRef); const knownMisses = yield* Ref.get(socketOwnerMissesRef); const inodes = new Set(listeners.map((listener) => listener.inode)); @@ -595,6 +609,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ), ); const found = unseen.size === 0 ? new Map() : yield* findSocketOwners(unseen); + if (found === null) return null; const owners = new Map([...known, ...found].filter(([inode]) => inodes.has(inode))); const misses = new Map([...knownMisses].filter(([inode]) => inodes.has(inode))); for (const inode of unseen) { @@ -825,11 +840,10 @@ export const make = Effect.gen(function* PortDiscoveryMake() { yield* probeWebServers(listeners ?? [], configuredUrls), ); } + const fromProc = yield* scanProcListeners(resolveOwners); + if (fromProc !== null) return yield* probeWebServers(fromProc, configuredUrls); const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); - if (yield* Ref.get(lsofMissingRef)) { - const fromProc = yield* scanProcListeners(resolveOwners); - return yield* probeWebServers(fromProc ?? [], configuredUrls); - } + if (yield* Ref.get(lsofMissingRef)) return yield* probeWebServers([], configuredUrls); const lsofResult = yield* processRunner .run({ command: "lsof", @@ -856,8 +870,11 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }), ); if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls); - const fromProc = yield* scanProcListeners(resolveOwners); - return yield* probeWebServers(fromProc ?? [], configuredUrls); + // A missing lsof makes a bounded, partial /proc owner walk the only available discovery. + const withoutLsof = (yield* Ref.get(lsofMissingRef)) + ? yield* scanProcListeners(resolveOwners) + : null; + return yield* probeWebServers(withoutLsof ?? [], configuredUrls); }); const scanSnapshot = (configuredUrls: ReadonlyArray) => @@ -922,8 +939,14 @@ export const make = Effect.gen(function* PortDiscoveryMake() { return [retainCount === 0, { ...state, retainCount }]; }); if (!idle) return; - const threads = yield* Ref.getAndSet(agentThreadsRef, new Map()); - if ([...threads.values()].some((threadId) => threadId !== null)) agentScanPending = true; + yield* scanSemaphore.withPermits(1)( + Effect.gen(function* () { + yield* Ref.set(socketOwnersRef, new Map()); + yield* Ref.set(socketOwnerMissesRef, new Map()); + const threads = yield* Ref.getAndSet(agentThreadsRef, new Map()); + if ([...threads.values()].some((threadId) => threadId !== null)) agentScanPending = true; + }), + ); }); const retain: PortDiscovery["Service"]["retain"] = Effect.acquireRelease(