diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 69b5729164da..d69f6cd746f1 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -122,6 +122,167 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall ); }); +effectIt("Windows listener probe builds the process-name map once", () => + Effect.gen(function* () { + let seenCommand: string | undefined; + const layer = PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => { + seenCommand = input.args.at(-1); + return Effect.succeed({ + stdout: "127.0.0.1|5173|4242|node\n", + stderr: "", + code: 0, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }); + }, + }), + Layer.succeed(Net.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + reserveLoopbackPort: () => Effect.succeed(40_000), + findAvailablePort: (preferred) => Effect.succeed(preferred), + }), + Layer.succeed(HostProcessPlatform, "win32"), + ), + ), + ); + + const servers = yield* Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + return yield* scanner.scan(); + }).pipe(Effect.provide(layer), Effect.scoped); + + expect(seenCommand).toBe(PortScanner.WINDOWS_LISTENER_COMMAND); + // Regression for #5900: never call Get-Process -Id per listener. + expect(seenCommand).toContain("$m = @{}"); + expect(seenCommand).not.toMatch(/Get-Process\s+-Id/); + expect(servers).toEqual([ + { + host: "localhost", + port: 5173, + url: "http://localhost:5173", + processName: "node", + pid: 4242, + terminal: null, + }, + ]); + }), +); + +effectIt("Windows listener probe cools down after a timeout", () => + Effect.gen(function* () { + let probeRuns = 0; + const layer = PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ProcessRunner.ProcessRunner, { + run: () => { + probeRuns += 1; + return Effect.fail( + new ProcessRunner.ProcessTimeoutError({ + command: "powershell.exe", + argumentCount: 4, + timeoutMs: 5_000, + }), + ); + }, + }), + Layer.succeed(Net.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + reserveLoopbackPort: () => Effect.succeed(40_000), + findAvailablePort: (preferred) => Effect.succeed(preferred), + }), + Layer.succeed(HostProcessPlatform, "win32"), + ), + ), + ); + + yield* Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + // First scan hits the probe and records a wall-clock cooldown. + yield* scanner.scan(); + expect(probeRuns).toBe(1); + // Immediate re-scans (retain, subscribe, poll) must not re-spawn PowerShell. + yield* scanner.scan(); + yield* scanner.scan(); + expect(probeRuns).toBe(1); + }).pipe(Effect.provide(layer), Effect.scoped); + }), +); + +effectIt("Windows listener probe is single-flight under concurrent scan()", () => + Effect.gen(function* () { + let probeRuns = 0; + let releaseProbe: (() => void) | undefined; + const probeGate = new Promise((resolve) => { + releaseProbe = resolve; + }); + const layer = PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ProcessRunner.ProcessRunner, { + run: () => { + probeRuns += 1; + return Effect.tryPromise({ + try: async () => { + await probeGate; + return { + stdout: "127.0.0.1|5173|4242|node\n", + stderr: "", + code: 0, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }, + catch: (cause) => + new ProcessRunner.ProcessReadError({ + command: "powershell.exe", + argumentCount: 4, + stream: "stdout", + cause, + }), + }); + }, + }), + Layer.succeed(Net.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + reserveLoopbackPort: () => Effect.succeed(40_000), + findAvailablePort: (preferred) => Effect.succeed(preferred), + }), + Layer.succeed(HostProcessPlatform, "win32"), + ), + ), + ); + + yield* Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + const first = scanner.scan().pipe(Effect.forkChild); + // Second claim must skip while the first probe is in flight. + yield* Effect.yieldNow(); + const second = yield* scanner.scan(); + expect(probeRuns).toBe(1); + // Common-port fallback while the expensive probe is busy. + expect(second.every((server) => server.processName === null)).toBe(true); + + releaseProbe?.(); + yield* first; + expect(probeRuns).toBe(1); + }).pipe(Effect.provide(layer), Effect.scoped); + }), +); + effectIt("does not swallow process probe defects", () => Effect.gen(function* () { const defect = new Error("unexpected process probe defect"); @@ -155,3 +316,62 @@ effectIt("does not swallow process probe interruption", () => } }), ); + +effectIt("clears Windows listener probe in-flight flag after interruption", () => + Effect.gen(function* () { + let probeRuns = 0; + const layer = PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ProcessRunner.ProcessRunner, { + run: () => { + probeRuns += 1; + if (probeRuns === 1) { + return Effect.interrupt; + } + return Effect.succeed({ + stdout: "127.0.0.1|5173|4242|node\n", + stderr: "", + code: 0, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }); + }, + }), + Layer.succeed(Net.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + reserveLoopbackPort: () => Effect.succeed(40_000), + findAvailablePort: (preferred) => Effect.succeed(preferred), + }), + Layer.succeed(HostProcessPlatform, "win32"), + ), + ), + ); + + yield* Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + const first = yield* scanner.scan().pipe(Effect.exit); + expect(Exit.isFailure(first)).toBe(true); + if (Exit.isFailure(first)) { + expect(Cause.hasInterruptsOnly(first.cause)).toBe(true); + } + // Release must clear inFlight; otherwise every later scan would skip. + const second = yield* scanner.scan(); + expect(probeRuns).toBe(2); + expect(second).toEqual([ + { + host: "localhost", + port: 5173, + url: "http://localhost:5173", + processName: "node", + pid: 4242, + terminal: null, + }, + ]); + }).pipe(Effect.provide(layer), Effect.scoped); + }), +); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index c306fca2b337..4f1923439efc 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -5,8 +5,13 @@ * stable line-prefixed field format; this is the only `lsof` flag set we rely * on). * - * Windows / lsof missing: checks a curated list of common dev ports through - * the shared Net service. + * Windows: PowerShell lists listening sockets (one PID→name map per probe). + * On timeout, fall back to common ports for a wall-clock cool-off and only one + * PowerShell probe runs at a time so retain/scan/poll cannot stack WMI work + * (#5900). + * + * lsof missing / probe failed: checks a curated list of common dev ports + * through the shared Net service. * * Polling is reference-counted via scoped `retain`. A single layer-scoped fiber * polls forever, but each tick is a no-op when the retain count is zero. @@ -53,9 +58,20 @@ export const COMMON_DEV_PORTS: ReadonlyArray = Object.freeze([ const POLL_INTERVAL = Duration.seconds(3); const LSOF_TIMEOUT_MS = 5_000; const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; +/** After a timeout, skip PowerShell and use common ports until this many ms pass. */ +const WINDOWS_LISTENER_COOLDOWN_MS = 60_000; + +/** + * Windows listener probe command. Builds the process-name map once; per-listener + * `Get-Process -Id` was the cost that made this miss its 5s timeout (#5900). + */ +export const WINDOWS_LISTENER_COMMAND = + '$m = @{}; Get-Process | ForEach-Object { $m[$_.Id] = $_.ProcessName }; Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$($m[[int]$_.OwningProcess])" }'; type Listener = (servers: ReadonlyArray) => Effect.Effect; +type WindowsListenerProbeGate = "run" | "skip"; + interface ScannerState { readonly lastSnapshot: ReadonlyArray; readonly listeners: ReadonlySet; @@ -67,6 +83,13 @@ interface ScannerState { } >; readonly retainCount: number; + /** + * Epoch ms. While `Date.now() < this`, skip PowerShell and use common ports. + * Wall-clock so retain/scan/poll cannot burn a tick budget early. + */ + readonly windowsListenerCooldownUntilMs: number; + /** Single-flight: only one PowerShell listener probe at a time. */ + readonly windowsListenerProbeInFlight: boolean; } interface TerminalProcessOwner { @@ -195,6 +218,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() { listeners: new Set(), terminalProcesses: new Map(), retainCount: 0, + windowsListenerCooldownUntilMs: 0, + windowsListenerProbeInFlight: false, }); const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { @@ -229,6 +254,28 @@ export const make = Effect.gen(function* PortDiscoveryMake() { platform: hostPlatform, }).pipe(Effect.as(null)); + const claimWindowsListenerProbe = Ref.modify(stateRef, (current) => { + const now = Date.now(); + if (now < current.windowsListenerCooldownUntilMs || current.windowsListenerProbeInFlight) { + return ["skip", current] as const satisfies readonly [WindowsListenerProbeGate, ScannerState]; + } + return [ + "run", + { ...current, windowsListenerProbeInFlight: true }, + ] as const satisfies readonly [WindowsListenerProbeGate, ScannerState]; + }); + + const releaseWindowsListenerProbe = Ref.update(stateRef, (current) => + current.windowsListenerProbeInFlight + ? { ...current, windowsListenerProbeInFlight: false } + : current, + ); + + const armWindowsListenerCooldown = Ref.update(stateRef, (current) => ({ + ...current, + windowsListenerCooldownUntilMs: Date.now() + WINDOWS_LISTENER_COOLDOWN_MS, + })); + const scanOnce = Effect.fn("PortDiscovery.scan")(function* () { const state = yield* Ref.get(stateRef); const terminalByProcessId = new Map(); @@ -238,27 +285,42 @@ export const make = Effect.gen(function* PortDiscoveryMake() { } } if (hostPlatform === "win32") { + // Wall-clock cooldown + single-flight so retain/scan/poll cannot burn a + // tick budget or spawn overlapping PowerShell/WMI probes (#5900). + // acquireUseRelease (not claim + later ensuring): if the fiber is + // interrupted after claim and before ensuring is installed, inFlight + // would stick true and every later scan would skip forever. 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], - timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), - maxOutputBytes: 1024 * 1024, - outputMode: "truncate", - }) - .pipe( - Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), - Effect.catchTags({ - ProcessSpawnError: recoverWindowsProbeFailure, - ProcessStdinError: recoverWindowsProbeFailure, - ProcessOutputLimitError: recoverWindowsProbeFailure, - ProcessReadError: recoverWindowsProbeFailure, - ProcessTimeoutError: recoverWindowsProbeFailure, - }), - ); + const listeners = yield* Effect.acquireUseRelease( + claimWindowsListenerProbe, + (gate) => { + if (gate === "skip") { + return Effect.succeed(null); + } + return processRunner + .run({ + command: "powershell.exe", + args: ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_LISTENER_COMMAND], + timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), + maxOutputBytes: 1024 * 1024, + outputMode: "truncate", + }) + .pipe( + Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), + Effect.catchTags({ + ProcessSpawnError: recoverWindowsProbeFailure, + ProcessStdinError: recoverWindowsProbeFailure, + ProcessOutputLimitError: recoverWindowsProbeFailure, + ProcessReadError: recoverWindowsProbeFailure, + ProcessTimeoutError: (error) => + armWindowsListenerCooldown.pipe( + Effect.zipRight(recoverWindowsProbeFailure(error)), + ), + }), + ); + }, + (gate) => (gate === "run" ? releaseWindowsListenerProbe : Effect.void), + ); if (listeners !== null) return listeners; return yield* probeCommonPorts(); }