Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions apps/server/src/preview/PortScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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");
Expand Down Expand Up @@ -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);
}),
);
106 changes: 84 additions & 22 deletions apps/server/src/preview/PortScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -53,9 +58,20 @@ export const COMMON_DEV_PORTS: ReadonlyArray<number> = 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<DiscoveredLocalServer>) => Effect.Effect<void>;

type WindowsListenerProbeGate = "run" | "skip";

interface ScannerState {
readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>;
readonly listeners: ReadonlySet<Listener>;
Expand All @@ -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 {
Expand Down Expand Up @@ -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* () {
Expand Down Expand Up @@ -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<number, TerminalProcessOwner>();
Expand All @@ -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();
}
Expand Down
Loading