From 04afa362bce284d6b0b8b7effabc18496b6ff8c7 Mon Sep 17 00:00:00 2001 From: Max Weinbach Date: Mon, 12 Oct 2026 00:38:59 -0400 Subject: [PATCH 1/7] perf(server): replace PowerShell port polling with netstat and idle skip --- apps/server/src/preview/PortScanner.test.ts | 59 ++++++++++++ apps/server/src/preview/PortScanner.ts | 101 +++++++++++++++----- 2 files changed, 135 insertions(+), 25 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 8aae21d8cf71..50aee010fc4a 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -789,3 +789,62 @@ effectIt.effect("does not swallow process probe interruption", () => } }), ); + +effectIt.effect( + "parses Windows netstat TCP and TCPv6 listeners and skips process spawns when idle", + () => { + const spawnedArgs: Array> = []; + const fetchFn = (() => + Promise.resolve( + new Response("hello", { headers: { "content-type": "text/html" } }), + )) as typeof globalThis.fetch; + const layer = PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerNoProc, + Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => { + spawnedArgs.push(input.args); + const isIpv6 = input.args.includes("tcpv6"); + return Effect.succeed({ + stdout: isIpv6 + ? " TCP [::1]:43123 [::]:0 ABHÖREN 1234\r\n TCP [::1]:43123 [::1]:52000 HERGESTELLT 1234\r\n" + : " TCP 127.0.0.1:5173 0.0.0.0:0 LISTENING 1234\r\n TCP 127.0.0.1:5173 127.0.0.1:60123 ESTABLISHED 9999\r\n", + stderr: "", + code: null, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }); + }, + }), + Layer.succeed(HostProcess.Platform, "win32"), + FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))), + ), + ), + ); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + expect(yield* scanner.scan()).toEqual([]); + expect(spawnedArgs).toHaveLength(0); + + yield* scanner.registerTerminalProcesses({ + threadId: "scanner-thread", + terminalId: "scanner-terminal", + processIds: [1234], + }); + const discovered = yield* scanner.scan(); + expect(discovered.map((entry) => ({ port: entry.port, pid: entry.pid }))).toEqual([ + { port: 5173, pid: 1234 }, + { port: 43123, pid: 1234 }, + ]); + expect(spawnedArgs).toEqual([ + ["-ano", "-p", "tcp"], + ["-ano", "-p", "tcpv6"], + ]); + }).pipe(Effect.provide(layer)); + }, +); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 7a59079ea9f4..d16239adfb17 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -249,19 +249,65 @@ const parsePortFromLsofName = (name: string): number | null => { return port; }; +const parseWindowsListenerLine = ( + line: string, +): { + readonly host: string; + readonly port: number; + readonly pid: number | null; + readonly processName: string | null; +} | null => { + const trimmed = line.trim(); + if (trimmed.length === 0) return null; + + if (trimmed.includes("|")) { + const [hostRaw, portRaw, pidRaw, processNameRaw] = trimmed.split("|", 4); + const host = hostRaw?.trim() ?? ""; + if (!LSOF_LOCAL_HOST_TOKENS.has(host) && host !== "::") return null; + const port = Number(portRaw); + const pid = Number(pidRaw); + if (!Number.isInteger(port) || port <= 0 || port >= 65536) return null; + return { + host, + port, + pid: Number.isInteger(pid) && pid > 0 ? pid : null, + processName: processNameRaw?.trim() || null, + }; + } + + // `netstat.exe -ano -p tcp` / `tcpv6`: + // TCP 127.0.0.1:3773 0.0.0.0:0 LISTENING 34280 + // TCP [::1]:35783 [::]:0 LISTENING 22876 + // Foreign port `:0` identifies a listening TCP socket regardless of Windows locale. + const fields = trimmed.split(/\s+/); + if (fields.length < 4 || fields[0]?.toUpperCase() !== "TCP") return null; + const localEndpoint = fields[1] ?? ""; + const foreignEndpoint = fields[2] ?? ""; + if (!foreignEndpoint.endsWith(":0")) return null; + const lastColon = localEndpoint.lastIndexOf(":"); + if (lastColon <= 0) return null; + const host = localEndpoint.slice(0, lastColon); + if (!LSOF_LOCAL_HOST_TOKENS.has(host) && host !== "::") return null; + const port = Number(localEndpoint.slice(lastColon + 1)); + if (!Number.isInteger(port) || port <= 0 || port >= 65536) return null; + const pid = Number(fields.at(-1)); + return { + host, + port, + pid: Number.isInteger(pid) && pid > 0 ? pid : null, + processName: null, + }; +}; + const parseWindowsListenerOutput = ( raw: string, terminalByProcessId: ReadonlyMap = new Map(), ): ReadonlyArray => { const seen = new Map(); for (const line of raw.split(/\r?\n/g)) { - const [hostRaw, portRaw, pidRaw, processNameRaw] = line.trim().split("|", 4); - const host = hostRaw?.trim() ?? ""; - if (!LSOF_LOCAL_HOST_TOKENS.has(host) && host !== "::") continue; - const port = Number(portRaw); - const pid = Number(pidRaw); - if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue; - const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null; + const parsed = parseWindowsListenerLine(line); + if (!parsed) continue; + const { port, pid: normalizedPid, processName } = parsed; const terminal = normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null); const existing = seen.get(port); @@ -273,7 +319,7 @@ const parseWindowsListenerOutput = ( host: "localhost", port, url: `http://localhost:${port}`, - processName: processNameRaw?.trim() || null, + processName, pid: normalizedPid, terminal, }); @@ -623,26 +669,31 @@ export const make = Effect.gen(function* PortDiscoveryMake() { } } if (hostPlatform === "win32") { + if (terminalByProcessId.size === 0 && configuredUrls.length === 0) { + return { discovered: [], configured: new Map() } satisfies WebProbeSnapshot; + } const recoverWindowsProbeFailure = recoverProcessProbeFailure("windows-listeners"); - const command = - 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; - const listeners = yield* processRunner - .run({ - command: "powershell.exe", - args: ["-NoProfile", "-NonInteractive", "-Command", command], + const runNetstat = (protocol: "tcp" | "tcpv6") => + processRunner.run({ + command: "netstat.exe", + args: ["-ano", "-p", protocol], timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), maxOutputBytes: 1024 * 1024, - }) - .pipe( - Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), - Effect.catchTags({ - ProcessSpawnError: recoverWindowsProbeFailure, - ProcessStdinError: recoverWindowsProbeFailure, - ProcessOutputLimitError: recoverWindowsProbeFailure, - ProcessReadError: recoverWindowsProbeFailure, - ProcessTimeoutError: recoverWindowsProbeFailure, - }), - ); + }); + const listeners = yield* Effect.all([runNetstat("tcp"), runNetstat("tcpv6")], { + concurrency: 2, + }).pipe( + Effect.map(([tcp4, tcp6]) => + parseWindowsListenerOutput(`${tcp4.stdout}\n${tcp6.stdout}`, terminalByProcessId), + ), + Effect.catchTags({ + ProcessSpawnError: recoverWindowsProbeFailure, + ProcessStdinError: recoverWindowsProbeFailure, + ProcessOutputLimitError: recoverWindowsProbeFailure, + ProcessReadError: recoverWindowsProbeFailure, + ProcessTimeoutError: recoverWindowsProbeFailure, + }), + ); if (listeners !== null) return yield* probeWebServers(listeners, configuredUrls); return yield* probeWebServers([], configuredUrls); } From 18c708407f8af45afea9ccb9954c2d345b7da017 Mon Sep 17 00:00:00 2001 From: Max Weinbach Date: Mon, 12 Oct 2026 01:31:02 -0400 Subject: [PATCH 2/7] fix(server): size netstat output limit for full connection lists Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/PortScanner.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index d16239adfb17..7209450fe285 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -77,6 +77,8 @@ const PROC_FD_WALK_LIMIT = 50_000; /** Scans that retry the fd walk for a listening socket whose owner was not found. */ const SOCKET_OWNER_ATTEMPTS = 3; const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; +/** `netstat -ano` lists every connection, not just listeners; ~80 bytes per row, sized for ~200k rows. */ +const WINDOWS_NETSTAT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const WEB_PROBE_TIMEOUT = Duration.seconds(1); const WEB_PROBE_CACHE_TTL_MS = Duration.toMillis(Duration.seconds(15)); const WEB_PROBE_CONCURRENCY = 16; @@ -678,7 +680,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { command: "netstat.exe", args: ["-ano", "-p", protocol], timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), - maxOutputBytes: 1024 * 1024, + maxOutputBytes: WINDOWS_NETSTAT_MAX_OUTPUT_BYTES, }); const listeners = yield* Effect.all([runNetstat("tcp"), runNetstat("tcpv6")], { concurrency: 2, From 6c2f50d53faf75735e5553706196d8d6aaa5e114 Mon Sep 17 00:00:00 2001 From: Max Weinbach Date: Mon, 12 Oct 2026 01:34:06 -0400 Subject: [PATCH 3/7] fix(server): restore Windows listener process names via cached tasklist lookup Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/PortScanner.test.ts | 28 ++++++++--- apps/server/src/preview/PortScanner.ts | 51 ++++++++++++++++++++- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 50aee010fc4a..f9e492f911c4 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -791,7 +791,7 @@ effectIt.effect("does not swallow process probe interruption", () => ); effectIt.effect( - "parses Windows netstat TCP and TCPv6 listeners and skips process spawns when idle", + "parses Windows netstat listeners, names them once via tasklist, and skips spawns when idle", () => { const spawnedArgs: Array> = []; const fetchFn = (() => @@ -807,9 +807,12 @@ effectIt.effect( spawnedArgs.push(input.args); const isIpv6 = input.args.includes("tcpv6"); return Effect.succeed({ - stdout: isIpv6 - ? " TCP [::1]:43123 [::]:0 ABHÖREN 1234\r\n TCP [::1]:43123 [::1]:52000 HERGESTELLT 1234\r\n" - : " TCP 127.0.0.1:5173 0.0.0.0:0 LISTENING 1234\r\n TCP 127.0.0.1:5173 127.0.0.1:60123 ESTABLISHED 9999\r\n", + stdout: + input.command === "tasklist.exe" + ? '"node.exe","1234","Console","1","93,164 K"\r\n' + : isIpv6 + ? " TCP [::1]:43123 [::]:0 ABHÖREN 1234\r\n TCP [::1]:43123 [::1]:52000 HERGESTELLT 1234\r\n" + : " TCP 127.0.0.1:5173 0.0.0.0:0 LISTENING 1234\r\n TCP 127.0.0.1:5173 127.0.0.1:60123 ESTABLISHED 9999\r\n", stderr: "", code: null, timedOut: false, @@ -837,14 +840,25 @@ effectIt.effect( processIds: [1234], }); const discovered = yield* scanner.scan(); - expect(discovered.map((entry) => ({ port: entry.port, pid: entry.pid }))).toEqual([ - { port: 5173, pid: 1234 }, - { port: 43123, pid: 1234 }, + expect( + discovered.map((entry) => ({ + port: entry.port, + pid: entry.pid, + processName: entry.processName, + })), + ).toEqual([ + { port: 5173, pid: 1234, processName: "node" }, + { port: 43123, pid: 1234, processName: "node" }, ]); expect(spawnedArgs).toEqual([ ["-ano", "-p", "tcp"], ["-ano", "-p", "tcpv6"], + ["/FO", "CSV", "/NH", "/FI", "PID eq 1234"], ]); + + // Names are cached per PID, so later polls spawn only netstat. + yield* scanner.scan(); + expect(spawnedArgs.filter((args) => args.includes("/FI"))).toHaveLength(1); }).pipe(Effect.provide(layer)); }, ); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 7209450fe285..3f7b8fd64390 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -301,6 +301,15 @@ const parseWindowsListenerLine = ( }; }; +/** Reads the image name from `tasklist.exe /FO CSV /NH /FI "PID eq "` output. */ +const parseTasklistProcessName = (raw: string, pid: number): string | null => { + for (const line of raw.split(/\r?\n/g)) { + const match = /^"([^"]*)","(\d+)"/.exec(line.trim()); + if (match && Number(match[2]) === pid) return match[1]!.replace(/\.exe$/i, "") || null; + } + return null; +}; + const parseWindowsListenerOutput = ( raw: string, terminalByProcessId: ReadonlyMap = new Map(), @@ -406,6 +415,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const webProbeCacheRef = yield* Ref.make>(new Map()); const scanSemaphore = yield* Semaphore.make(1); const lsofMissingRef = yield* Ref.make(false); + /** Windows image names by PID; netstat reports only PIDs. */ + const windowsProcessNamesRef = yield* Ref.make>(new Map()); /** Confirmed owners of listening socket inodes. */ const socketOwnersRef = yield* Ref.make< ReadonlyMap @@ -652,6 +663,41 @@ export const make = Effect.gen(function* PortDiscoveryMake() { return { discovered, configured } satisfies WebProbeSnapshot; }); + // `tasklist` costs ~200ms per PID, so look up only visible servers and only once per PID. + const withWindowsProcessNames = Effect.fn("PortDiscovery.withWindowsProcessNames")(function* ( + snapshot: WebProbeSnapshot, + ) { + const servers = [...snapshot.discovered, ...snapshot.configured.values()]; + const pids = new Set(servers.flatMap((server) => (server.pid === null ? [] : [server.pid]))); + const cached = yield* Ref.get(windowsProcessNamesRef); + const fetched = yield* Effect.forEach( + [...pids].filter((pid) => !cached.has(pid)), + (pid) => + processRunner + .run({ + command: "tasklist.exe", + args: ["/FO", "CSV", "/NH", "/FI", `PID eq ${pid}`], + timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), + maxOutputBytes: 64 * 1024, + }) + .pipe( + Effect.map((result) => [pid, parseTasklistProcessName(result.stdout, pid)] as const), + Effect.orElseSucceed(() => [pid, null] as const), + ), + { concurrency: "unbounded" }, + ); + const names = new Map([...cached, ...fetched].filter(([pid]) => pids.has(pid))); + yield* Ref.set(windowsProcessNamesRef, names); + const withName = (server: DiscoveredLocalServer): DiscoveredLocalServer => + server.pid === null || server.processName !== null + ? server + : { ...server, processName: names.get(server.pid) ?? null }; + return { + discovered: snapshot.discovered.map(withName), + configured: new Map([...snapshot.configured].map(([key, server]) => [key, withName(server)])), + } satisfies WebProbeSnapshot; + }); + const recoverProcessProbeFailure = (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) => Effect.logDebug("preview port process probe failed; probing configured URLs only", { @@ -696,8 +742,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ProcessTimeoutError: recoverWindowsProbeFailure, }), ); - if (listeners !== null) return yield* probeWebServers(listeners, configuredUrls); - return yield* probeWebServers([], configuredUrls); + return yield* withWindowsProcessNames( + yield* probeWebServers(listeners ?? [], configuredUrls), + ); } const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); if (yield* Ref.get(lsofMissingRef)) { From 7d88a63c06ce7e7f8a6bc7efee2c31acd0729bb5 Mon Sep 17 00:00:00 2001 From: Max Weinbach Date: Mon, 12 Oct 2026 01:37:56 -0400 Subject: [PATCH 4/7] fix(server): retry failed Windows process-name lookups instead of caching them Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/PortScanner.test.ts | 47 +++++++++++++-------- apps/server/src/preview/PortScanner.ts | 11 +++-- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index f9e492f911c4..a0b0fbca0e3d 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -8,6 +8,7 @@ import { } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Cause from "effect/Cause"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -791,9 +792,10 @@ effectIt.effect("does not swallow process probe interruption", () => ); effectIt.effect( - "parses Windows netstat listeners, names them once via tasklist, and skips spawns when idle", + "parses Windows netstat listeners, names them via tasklist with retry, and skips spawns when idle", () => { const spawnedArgs: Array> = []; + let tasklistCalls = 0; const fetchFn = (() => Promise.resolve( new Response("hello", { headers: { "content-type": "text/html" } }), @@ -806,15 +808,17 @@ effectIt.effect( run: (input) => { spawnedArgs.push(input.args); const isIpv6 = input.args.includes("tcpv6"); + const isTasklist = input.command === "tasklist.exe"; + // The first lookup fails, so it must not be cached as "no name". + const failTasklist = isTasklist && tasklistCalls++ === 0; return Effect.succeed({ - stdout: - input.command === "tasklist.exe" - ? '"node.exe","1234","Console","1","93,164 K"\r\n' - : isIpv6 - ? " TCP [::1]:43123 [::]:0 ABHÖREN 1234\r\n TCP [::1]:43123 [::1]:52000 HERGESTELLT 1234\r\n" - : " TCP 127.0.0.1:5173 0.0.0.0:0 LISTENING 1234\r\n TCP 127.0.0.1:5173 127.0.0.1:60123 ESTABLISHED 9999\r\n", + stdout: isTasklist + ? '"node.exe","1234","Console","1","93,164 K"\r\n' + : isIpv6 + ? " TCP [::1]:43123 [::]:0 ABHÖREN 1234\r\n TCP [::1]:43123 [::1]:52000 HERGESTELLT 1234\r\n" + : " TCP 127.0.0.1:5173 0.0.0.0:0 LISTENING 1234\r\n TCP 127.0.0.1:5173 127.0.0.1:60123 ESTABLISHED 9999\r\n", stderr: "", - code: null, + code: failTasklist ? ChildProcessSpawner.ExitCode(1) : null, timedOut: false, stdoutTruncated: false, stderrTruncated: false, @@ -839,16 +843,21 @@ effectIt.effect( terminalId: "scanner-terminal", processIds: [1234], }); - const discovered = yield* scanner.scan(); - expect( - discovered.map((entry) => ({ + const summarize = ( + servers: ReadonlyArray<{ + readonly port: number; + readonly pid: number | null; + readonly processName: string | null; + }>, + ) => + servers.map((entry) => ({ port: entry.port, pid: entry.pid, processName: entry.processName, - })), - ).toEqual([ - { port: 5173, pid: 1234, processName: "node" }, - { port: 43123, pid: 1234, processName: "node" }, + })); + expect(summarize(yield* scanner.scan())).toEqual([ + { port: 5173, pid: 1234, processName: null }, + { port: 43123, pid: 1234, processName: null }, ]); expect(spawnedArgs).toEqual([ ["-ano", "-p", "tcp"], @@ -856,9 +865,13 @@ effectIt.effect( ["/FO", "CSV", "/NH", "/FI", "PID eq 1234"], ]); - // Names are cached per PID, so later polls spawn only netstat. + // The failed lookup retries; the result is then cached, so later polls spawn only netstat. + expect(summarize(yield* scanner.scan())).toEqual([ + { port: 5173, pid: 1234, processName: "node" }, + { port: 43123, pid: 1234, processName: "node" }, + ]); yield* scanner.scan(); - expect(spawnedArgs.filter((args) => args.includes("/FI"))).toHaveLength(1); + expect(tasklistCalls).toBe(2); }).pipe(Effect.provide(layer)); }, ); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 3f7b8fd64390..85d56fafc758 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -681,12 +681,17 @@ export const make = Effect.gen(function* PortDiscoveryMake() { maxOutputBytes: 64 * 1024, }) .pipe( - Effect.map((result) => [pid, parseTasklistProcessName(result.stdout, pid)] as const), - Effect.orElseSucceed(() => [pid, null] as const), + // Only a completed lookup is cached; failures retry on the next scan. + Effect.map((result) => + result.code !== null && result.code !== 0 + ? [] + : [[pid, parseTasklistProcessName(result.stdout, pid)] as const], + ), + Effect.orElseSucceed(() => []), ), { concurrency: "unbounded" }, ); - const names = new Map([...cached, ...fetched].filter(([pid]) => pids.has(pid))); + const names = new Map([...cached, ...fetched.flat()].filter(([pid]) => pids.has(pid))); yield* Ref.set(windowsProcessNamesRef, names); const withName = (server: DiscoveredLocalServer): DiscoveredLocalServer => server.pid === null || server.processName !== null From df1be1ee59a5cbc53f0c4231537ed3631ded267d Mon Sep 17 00:00:00 2001 From: Max Weinbach Date: Mon, 12 Oct 2026 01:49:23 -0400 Subject: [PATCH 5/7] fix(server): drop cached Windows process names when scanning goes idle Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/PortScanner.test.ts | 14 ++++++++++++++ apps/server/src/preview/PortScanner.ts | 2 ++ 2 files changed, 16 insertions(+) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index a0b0fbca0e3d..46e8c98af1b0 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -872,6 +872,20 @@ effectIt.effect( ]); yield* scanner.scan(); expect(tasklistCalls).toBe(2); + + // An idle scan drops cached names, since Windows may hand the PID to another process. + yield* scanner.unregisterTerminal({ + threadId: "scanner-thread", + terminalId: "scanner-terminal", + }); + expect(yield* scanner.scan()).toEqual([]); + yield* scanner.registerTerminalProcesses({ + threadId: "scanner-thread", + terminalId: "scanner-terminal", + processIds: [1234], + }); + yield* scanner.scan(); + expect(tasklistCalls).toBe(3); }).pipe(Effect.provide(layer)); }, ); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 85d56fafc758..987adfcbde5b 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -723,6 +723,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() { } if (hostPlatform === "win32") { if (terminalByProcessId.size === 0 && configuredUrls.length === 0) { + // Idle scans skip the pruning in withWindowsProcessNames, and Windows may reuse a PID. + yield* Ref.set(windowsProcessNamesRef, new Map()); return { discovered: [], configured: new Map() } satisfies WebProbeSnapshot; } const recoverWindowsProbeFailure = recoverProcessProbeFailure("windows-listeners"); From 0c11e6a2d5d2922acab93432c6652768cd94efc5 Mon Sep 17 00:00:00 2001 From: Max Weinbach Date: Mon, 12 Oct 2026 08:06:37 -0400 Subject: [PATCH 6/7] fix(server): expire cached Windows process names and bound tasklist concurrency Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/PortScanner.test.ts | 13 ++------ apps/server/src/preview/PortScanner.ts | 34 +++++++++++++++------ 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 46e8c98af1b0..551bfd743045 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -873,17 +873,8 @@ effectIt.effect( yield* scanner.scan(); expect(tasklistCalls).toBe(2); - // An idle scan drops cached names, since Windows may hand the PID to another process. - yield* scanner.unregisterTerminal({ - threadId: "scanner-thread", - terminalId: "scanner-terminal", - }); - expect(yield* scanner.scan()).toEqual([]); - yield* scanner.registerTerminalProcesses({ - threadId: "scanner-thread", - terminalId: "scanner-terminal", - processIds: [1234], - }); + // Cached names expire, since Windows may hand the PID to another process. + yield* TestClock.adjust(Duration.seconds(61)); yield* scanner.scan(); expect(tasklistCalls).toBe(3); }).pipe(Effect.provide(layer)); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 987adfcbde5b..b7a5355e320b 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -81,6 +81,9 @@ const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; const WINDOWS_NETSTAT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const WEB_PROBE_TIMEOUT = Duration.seconds(1); const WEB_PROBE_CACHE_TTL_MS = Duration.toMillis(Duration.seconds(15)); +/** Bounds how long a cached Windows image name can outlive its process if Windows reuses the PID. */ +const WINDOWS_PROCESS_NAME_TTL_MS = Duration.toMillis(Duration.seconds(60)); +const WINDOWS_PROCESS_NAME_CONCURRENCY = 4; const WEB_PROBE_CONCURRENCY = 16; const NAVIGATION_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); @@ -416,7 +419,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const scanSemaphore = yield* Semaphore.make(1); const lsofMissingRef = yield* Ref.make(false); /** Windows image names by PID; netstat reports only PIDs. */ - const windowsProcessNamesRef = yield* Ref.make>(new Map()); + const windowsProcessNamesRef = yield* Ref.make< + ReadonlyMap + >(new Map()); /** Confirmed owners of listening socket inodes. */ const socketOwnersRef = yield* Ref.make< ReadonlyMap @@ -663,15 +668,21 @@ export const make = Effect.gen(function* PortDiscoveryMake() { return { discovered, configured } satisfies WebProbeSnapshot; }); - // `tasklist` costs ~200ms per PID, so look up only visible servers and only once per PID. + // `tasklist` costs ~200-300ms per PID, so look up only visible servers, a few at a time, and + // reuse each name until its TTL expires. const withWindowsProcessNames = Effect.fn("PortDiscovery.withWindowsProcessNames")(function* ( snapshot: WebProbeSnapshot, ) { const servers = [...snapshot.discovered, ...snapshot.configured.values()]; const pids = new Set(servers.flatMap((server) => (server.pid === null ? [] : [server.pid]))); - const cached = yield* Ref.get(windowsProcessNamesRef); + const nowMillis = yield* Clock.currentTimeMillis; + const cached = [...(yield* Ref.get(windowsProcessNamesRef))].filter( + ([pid, entry]) => pids.has(pid) && entry.expiresAtMillis > nowMillis, + ); + const cachedPids = new Set(cached.map(([pid]) => pid)); + const expiresAtMillis = nowMillis + WINDOWS_PROCESS_NAME_TTL_MS; const fetched = yield* Effect.forEach( - [...pids].filter((pid) => !cached.has(pid)), + [...pids].filter((pid) => !cachedPids.has(pid)), (pid) => processRunner .run({ @@ -685,18 +696,23 @@ export const make = Effect.gen(function* PortDiscoveryMake() { Effect.map((result) => result.code !== null && result.code !== 0 ? [] - : [[pid, parseTasklistProcessName(result.stdout, pid)] as const], + : [ + [ + pid, + { name: parseTasklistProcessName(result.stdout, pid), expiresAtMillis }, + ] as const, + ], ), Effect.orElseSucceed(() => []), ), - { concurrency: "unbounded" }, + { concurrency: WINDOWS_PROCESS_NAME_CONCURRENCY }, ); - const names = new Map([...cached, ...fetched.flat()].filter(([pid]) => pids.has(pid))); + const names = new Map([...cached, ...fetched.flat()]); yield* Ref.set(windowsProcessNamesRef, names); const withName = (server: DiscoveredLocalServer): DiscoveredLocalServer => server.pid === null || server.processName !== null ? server - : { ...server, processName: names.get(server.pid) ?? null }; + : { ...server, processName: names.get(server.pid)?.name ?? null }; return { discovered: snapshot.discovered.map(withName), configured: new Map([...snapshot.configured].map(([key, server]) => [key, withName(server)])), @@ -723,8 +739,6 @@ export const make = Effect.gen(function* PortDiscoveryMake() { } if (hostPlatform === "win32") { if (terminalByProcessId.size === 0 && configuredUrls.length === 0) { - // Idle scans skip the pruning in withWindowsProcessNames, and Windows may reuse a PID. - yield* Ref.set(windowsProcessNamesRef, new Map()); return { discovered: [], configured: new Map() } satisfies WebProbeSnapshot; } const recoverWindowsProbeFailure = recoverProcessProbeFailure("windows-listeners"); From a6a8e0f620e534dc38da68421f6c90b8a5e9df8f Mon Sep 17 00:00:00 2001 From: Max Weinbach Date: Mon, 12 Oct 2026 08:34:39 -0400 Subject: [PATCH 7/7] fix(server): ignore partial Windows netstat snapshots on nonzero exit Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/PortScanner.test.ts | 43 +++++++++++++++++++++ apps/server/src/preview/PortScanner.ts | 12 +++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 551bfd743045..3e2495a072a7 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -880,3 +880,46 @@ effectIt.effect( }).pipe(Effect.provide(layer)); }, ); + +effectIt.effect("ignores a partial Windows netstat snapshot when one query exits nonzero", () => { + const fetchFn = (() => + Promise.resolve( + new Response("hello", { headers: { "content-type": "text/html" } }), + )) as typeof globalThis.fetch; + const layer = PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerNoProc, + Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => { + const isIpv6 = input.args.includes("tcpv6"); + return Effect.succeed({ + stdout: isIpv6 + ? "" + : " TCP 127.0.0.1:5173 0.0.0.0:0 LISTENING 1234\r\n", + stderr: "", + code: isIpv6 ? ChildProcessSpawner.ExitCode(1) : null, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }); + }, + }), + Layer.succeed(HostProcess.Platform, "win32"), + FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))), + ), + ), + ); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + yield* scanner.registerTerminalProcesses({ + threadId: "scanner-thread", + terminalId: "scanner-terminal", + processIds: [1234], + }); + expect(yield* scanner.scan()).toEqual([]); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index b7a5355e320b..84a80ff96511 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -304,6 +304,10 @@ const parseWindowsListenerLine = ( }; }; +/** `ProcessRunner` reports a nonzero exit as a result, not a failure. */ +const exitedWithError = (result: ProcessRunner.ProcessRunOutput): boolean => + result.code !== null && result.code !== 0; + /** Reads the image name from `tasklist.exe /FO CSV /NH /FI "PID eq "` output. */ const parseTasklistProcessName = (raw: string, pid: number): string | null => { for (const line of raw.split(/\r?\n/g)) { @@ -694,7 +698,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { .pipe( // Only a completed lookup is cached; failures retry on the next scan. Effect.map((result) => - result.code !== null && result.code !== 0 + exitedWithError(result) ? [] : [ [ @@ -752,8 +756,12 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const listeners = yield* Effect.all([runNetstat("tcp"), runNetstat("tcpv6")], { concurrency: 2, }).pipe( + // One failed query leaves a partial snapshot that could hide another process sharing a + // port across IPv4/IPv6, so fall back to configured URLs instead. Effect.map(([tcp4, tcp6]) => - parseWindowsListenerOutput(`${tcp4.stdout}\n${tcp6.stdout}`, terminalByProcessId), + exitedWithError(tcp4) || exitedWithError(tcp6) + ? null + : parseWindowsListenerOutput(`${tcp4.stdout}\n${tcp6.stdout}`, terminalByProcessId), ), Effect.catchTags({ ProcessSpawnError: recoverWindowsProbeFailure,