From a78e13b87f058723ce73868c27971df45f4df04b Mon Sep 17 00:00:00 2001 From: RioPlay Date: Tue, 11 Aug 2026 18:55:41 -0500 Subject: [PATCH 1/3] perf(server): stop Windows port discovery thrashing WMI --- apps/server/src/preview/PortScanner.test.ts | 95 +++++++++++++++++++++ apps/server/src/preview/PortScanner.ts | 40 +++++++-- 2 files changed, 129 insertions(+), 6 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 69b5729164da..0d4a9078b556 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -122,6 +122,101 @@ 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 cooldown. + yield* scanner.scan(); + expect(probeRuns).toBe(1); + // Immediate re-scan must use the common-port fallback, not re-spawn PowerShell. + yield* scanner.scan(); + 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"); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index c306fca2b337..f3a21e246ebb 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -5,8 +5,12 @@ * 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 the curated common-port list and cool the probe + * briefly so a doomed WMI query is not re-spawned every poll (#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,6 +57,15 @@ 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 the expensive probe for this many poll ticks (~60s). */ +const WINDOWS_LISTENER_TIMEOUT_COOLDOWN_TICKS = 20; + +/** + * 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; @@ -67,6 +80,8 @@ interface ScannerState { } >; readonly retainCount: number; + /** Poll ticks remaining before the Windows listener probe is tried again. */ + readonly windowsListenerCooldownTicks: number; } interface TerminalProcessOwner { @@ -195,6 +210,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { listeners: new Set(), terminalProcesses: new Map(), retainCount: 0, + windowsListenerCooldownTicks: 0, }); const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { @@ -238,13 +254,21 @@ export const make = Effect.gen(function* PortDiscoveryMake() { } } if (hostPlatform === "win32") { + // A probe that just timed out will time out again until the machine + // cools off; skip it for a while and use the cheap common-port path. + if (state.windowsListenerCooldownTicks > 0) { + yield* Ref.update(stateRef, (current) => ({ + ...current, + windowsListenerCooldownTicks: Math.max(0, current.windowsListenerCooldownTicks - 1), + })); + return yield* probeCommonPorts(); + } + 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], + args: ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_LISTENER_COMMAND], timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), maxOutputBytes: 1024 * 1024, outputMode: "truncate", @@ -256,7 +280,11 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ProcessStdinError: recoverWindowsProbeFailure, ProcessOutputLimitError: recoverWindowsProbeFailure, ProcessReadError: recoverWindowsProbeFailure, - ProcessTimeoutError: recoverWindowsProbeFailure, + ProcessTimeoutError: (error) => + Ref.update(stateRef, (current) => ({ + ...current, + windowsListenerCooldownTicks: WINDOWS_LISTENER_TIMEOUT_COOLDOWN_TICKS, + })).pipe(Effect.zipRight(recoverWindowsProbeFailure(error))), }), ); if (listeners !== null) return listeners; From c74fb6bbd93004b6bcffb89c1b9a14388af1534b Mon Sep 17 00:00:00 2001 From: RioPlay Date: Tue, 11 Aug 2026 20:21:44 -0500 Subject: [PATCH 2/3] fix(server): make Windows port-probe cooldown wall-clock and single-flight Review: tick-based cool-off could be burned by retain/scan/poll, and overlapping scans could both spawn PowerShell before either armed the cooldown. Use a wall-clock cooldown deadline and claim single-flight ownership before spawning the listener probe. --- apps/server/src/preview/PortScanner.test.ts | 70 ++++++++++++++++++++- apps/server/src/preview/PortScanner.ts | 62 ++++++++++++------ 2 files changed, 112 insertions(+), 20 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 0d4a9078b556..993bebc82d03 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -207,11 +207,77 @@ effectIt("Windows listener probe cools down after a timeout", () => yield* Effect.gen(function* () { const scanner = yield* PortScanner.PortDiscovery; - // First scan hits the probe and records a cooldown. + // First scan hits the probe and records a wall-clock cooldown. yield* scanner.scan(); expect(probeRuns).toBe(1); - // Immediate re-scan must use the common-port fallback, not re-spawn PowerShell. + // 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); }), diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index f3a21e246ebb..46d15f685f67 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -6,8 +6,9 @@ * on). * * Windows: PowerShell lists listening sockets (one PID→name map per probe). - * On timeout, fall back to the curated common-port list and cool the probe - * briefly so a doomed WMI query is not re-spawned every poll (#5900). + * 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. @@ -57,8 +58,8 @@ 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 the expensive probe for this many poll ticks (~60s). */ -const WINDOWS_LISTENER_TIMEOUT_COOLDOWN_TICKS = 20; +/** 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 @@ -69,6 +70,8 @@ export const WINDOWS_LISTENER_COMMAND = type Listener = (servers: ReadonlyArray) => Effect.Effect; +type WindowsListenerProbeGate = "run" | "skip"; + interface ScannerState { readonly lastSnapshot: ReadonlyArray; readonly listeners: ReadonlySet; @@ -80,8 +83,13 @@ interface ScannerState { } >; readonly retainCount: number; - /** Poll ticks remaining before the Windows listener probe is tried again. */ - readonly windowsListenerCooldownTicks: 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 { @@ -210,7 +218,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() { listeners: new Set(), terminalProcesses: new Map(), retainCount: 0, - windowsListenerCooldownTicks: 0, + windowsListenerCooldownUntilMs: 0, + windowsListenerProbeInFlight: false, }); const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { @@ -245,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(); @@ -254,13 +285,10 @@ export const make = Effect.gen(function* PortDiscoveryMake() { } } if (hostPlatform === "win32") { - // A probe that just timed out will time out again until the machine - // cools off; skip it for a while and use the cheap common-port path. - if (state.windowsListenerCooldownTicks > 0) { - yield* Ref.update(stateRef, (current) => ({ - ...current, - windowsListenerCooldownTicks: Math.max(0, current.windowsListenerCooldownTicks - 1), - })); + // Wall-clock cooldown + single-flight so retain/scan/poll cannot burn a + // tick budget or spawn overlapping PowerShell/WMI probes (#5900). + const gate = yield* claimWindowsListenerProbe; + if (gate === "skip") { return yield* probeCommonPorts(); } @@ -281,11 +309,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ProcessOutputLimitError: recoverWindowsProbeFailure, ProcessReadError: recoverWindowsProbeFailure, ProcessTimeoutError: (error) => - Ref.update(stateRef, (current) => ({ - ...current, - windowsListenerCooldownTicks: WINDOWS_LISTENER_TIMEOUT_COOLDOWN_TICKS, - })).pipe(Effect.zipRight(recoverWindowsProbeFailure(error))), + armWindowsListenerCooldown.pipe(Effect.zipRight(recoverWindowsProbeFailure(error))), }), + Effect.ensuring(releaseWindowsListenerProbe), ); if (listeners !== null) return listeners; return yield* probeCommonPorts(); From 920f8251d9c63d0b7e78995ae4bcb3b65f5e6fbf Mon Sep 17 00:00:00 2001 From: RioPlay Date: Tue, 11 Aug 2026 20:44:06 -0500 Subject: [PATCH 3/3] fix(server): clear Windows port-probe in-flight flag on interrupt Claiming the single-flight probe then attaching Effect.ensuring later left a window where interruption stuck windowsListenerProbeInFlight true forever. Bracket claim/use/release with acquireUseRelease and cover recovery after interrupt. --- apps/server/src/preview/PortScanner.test.ts | 59 +++++++++++++++++++++ apps/server/src/preview/PortScanner.ts | 58 +++++++++++--------- 2 files changed, 92 insertions(+), 25 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 993bebc82d03..d69f6cd746f1 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -316,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 46d15f685f67..4f1923439efc 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -287,32 +287,40 @@ 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). - const gate = yield* claimWindowsListenerProbe; - if (gate === "skip") { - return yield* probeCommonPorts(); - } - + // 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 listeners = yield* 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))), - }), - Effect.ensuring(releaseWindowsListenerProbe), - ); + 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(); }