Skip to content
Draft
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
2 changes: 1 addition & 1 deletion apps/server/src/diagnostics/ProcessDiagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function makeNativeSnapshot(
processes: ResourceMonitorSnapshotEvent["processes"],
): ResourceMonitorSnapshotEvent {
return {
version: 2,
version: 4,
type: "snapshot",
sequence: 1,
sampledAtUnixMs: DateTime.toEpochMillis(DateTime.makeUnsafe("2026-05-05T10:00:00.000Z")),
Expand Down
128 changes: 128 additions & 0 deletions apps/server/src/preview/PortScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { expect } from "vite-plus/test";
import { FetchHttpClient } from "effect/unstable/http";

import * as ProcessRunner from "../processRunner.ts";
import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts";
import * as PortScanner from "./PortScanner.ts";
const processProbeFailure: ProcessRunner.ProcessRunner["Service"]["run"] = (input) =>
Effect.fail(
Expand All @@ -41,6 +42,7 @@ const processProbeFailure: ProcessRunner.ProcessRunner["Service"]["run"] = (inpu
const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
run: processProbeFailure,
});
const TestNativeTelemetry = NativeTelemetryClient.layerTest();

let integrationListeningPort: number | null = null;

Expand Down Expand Up @@ -68,6 +70,7 @@ const makeProbeFailureLayer = (
findAvailablePort: (preferred) => Effect.succeed(preferred),
}),
Layer.succeed(HostProcessPlatform, "linux"),
TestNativeTelemetry,
FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch))),
),
),
Expand All @@ -79,6 +82,7 @@ const TestPortDiscoveryLive = PortScanner.layer.pipe(
TestProcessRunner,
TestIntegrationNet,
Layer.succeed(HostProcessPlatform, "win32"),
TestNativeTelemetry,
FetchHttpClient.layer,
),
),
Expand Down Expand Up @@ -114,13 +118,39 @@ const makeLsofScannerLayer = (input: {
findAvailablePort: (preferred) => Effect.succeed(preferred),
}),
Layer.succeed(HostProcessPlatform, "linux"),
TestNativeTelemetry,
FetchHttpClient.layer.pipe(
Layer.provide(Layer.succeed(FetchHttpClient.Fetch, input.fetch)),
),
),
),
);

const makeWindowsScannerLayer = (input: {
readonly windowsListeners: NativeTelemetryClient.NativeTelemetryClient["Service"]["windowsListeners"];
readonly run: ProcessRunner.ProcessRunner["Service"]["run"];
readonly fetch?: typeof globalThis.fetch;
}) =>
PortScanner.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.succeed(ProcessRunner.ProcessRunner, { run: input.run }),
Layer.succeed(Net.NetService, {
canListenOnHost: () => Effect.succeed(true),
isPortAvailableOnLoopback: () => Effect.succeed(true),
hasListenerOnHost: () => Effect.succeed(false),
reserveLoopbackPort: () => Effect.succeed(40_000),
findAvailablePort: (preferred) => Effect.succeed(preferred),
}),
Layer.succeed(HostProcessPlatform, "win32"),
NativeTelemetryClient.layerTest({ windowsListeners: input.windowsListeners }),
FetchHttpClient.layer.pipe(
Layer.provide(Layer.succeed(FetchHttpClient.Fetch, input.fetch ?? globalThis.fetch)),
),
),
),
);

const openServer = (
port: number,
onConnection: (socket: NodeNet.Socket) => void,
Expand Down Expand Up @@ -244,6 +274,104 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall
);
});

effectIt.effect("uses native Windows listeners without spawning PowerShell", () => {
let fallbackRuns = 0;
const layer = makeWindowsScannerLayer({
windowsListeners: Effect.succeed([{ port: LSOF_TEST_PORT, pid: 4_242, processName: "node" }]),
run: (input) => {
fallbackRuns += 1;
return processProbeFailure(input);
},
fetch: ((_input: Parameters<typeof globalThis.fetch>[0]) =>
Promise.resolve(
new Response("app", { headers: { "content-type": "text/html" } }),
)) as typeof globalThis.fetch,
});

return Effect.gen(function* () {
const scanner = yield* PortScanner.PortDiscovery;
yield* scanner.registerTerminalProcesses({
threadId: "thread-1",
terminalId: "default",
processIds: [4_242],
});
const servers = yield* scanner.scan();

expect(fallbackRuns).toBe(0);
expect(servers).toEqual([
{
host: "localhost",
port: LSOF_TEST_PORT,
url: `http://localhost:${LSOF_TEST_PORT}`,
processName: "node",
pid: 4_242,
terminal: { threadId: "thread-1", terminalId: "default" },
},
]);
}).pipe(Effect.provide(layer));
});

effectIt.effect("backs off every failed Windows PowerShell fallback", () => {
let fallbackRuns = 0;
const layer = makeWindowsScannerLayer({
windowsListeners: Effect.fail(
new NativeTelemetryClient.NativeTelemetryUnavailable({ reason: "test" }),
),
run: (input) => {
fallbackRuns += 1;
return processProbeFailure(input);
},
});

return Effect.gen(function* () {
const scanner = yield* PortScanner.PortDiscovery;
yield* scanner.scan();
yield* scanner.scan();
expect(fallbackRuns).toBe(1);

yield* TestClock.adjust(Duration.seconds(3));
yield* scanner.scan();
expect(fallbackRuns).toBe(2);
}).pipe(Effect.provide(layer));
});

effectIt.effect("keeps the last Windows fallback snapshot when a retry fails", () => {
let fallbackRuns = 0;
const layer = makeWindowsScannerLayer({
windowsListeners: Effect.fail(
new NativeTelemetryClient.NativeTelemetryUnavailable({ reason: "test" }),
),
run: (input) => {
fallbackRuns += 1;
if (fallbackRuns > 1) return processProbeFailure(input);
return Effect.succeed({
stdout: `127.0.0.1|${LSOF_TEST_PORT}|4242|node\n`,
stderr: "",
code: null,
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
});
},
fetch: ((_input: Parameters<typeof globalThis.fetch>[0]) =>
Promise.resolve(
new Response("app", { headers: { "content-type": "text/html" } }),
)) as typeof globalThis.fetch,
});

return Effect.gen(function* () {
const scanner = yield* PortScanner.PortDiscovery;
expect(yield* scanner.scan()).toHaveLength(1);

yield* TestClock.adjust(Duration.seconds(3));
expect(yield* scanner.scan()).toHaveLength(1);
expect(yield* scanner.scan()).toHaveLength(1);
expect(fallbackRuns).toBe(2);
}).pipe(Effect.provide(layer));
});

effectIt.effect("revalidates a successful HTML probe after its cache entry expires", () => {
let responds = true;
const requests: string[] = [];
Expand Down
123 changes: 98 additions & 25 deletions apps/server/src/preview/PortScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
* 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: asks the persistent resource monitor for the native TCP listener
* table. If the sidecar is unavailable, a backed-off PowerShell probe runs.
*
* lsof / Windows probe missing: checks a curated list of common dev ports
* through the shared Net service.
*
* Listening ports are published only after a bounded HTTP(S) probe finds a
* successful HTML document or a redirect to one.
Expand All @@ -21,6 +24,7 @@ import {
PREVIEW_URL_MAX_LENGTH,
ThreadId,
type DiscoveredLocalServer,
type ResourceMonitorWindowsListener,
} from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Net from "@t3tools/shared/Net";
Expand All @@ -39,6 +43,7 @@ import * as Semaphore from "effect/Semaphore";
import { FetchHttpClient, HttpClient } from "effect/unstable/http";

import * as ProcessRunner from "../processRunner.ts";
import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts";

export class PortDiscovery extends Context.Service<
PortDiscovery,
Expand Down Expand Up @@ -73,6 +78,9 @@ 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;
const WINDOWS_FALLBACK_MAX_RETRY_MS = 60_000;
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])" }';
const WEB_PROBE_TIMEOUT = Duration.seconds(1);
const WEB_PROBE_CACHE_TTL_MS = Duration.toMillis(Duration.seconds(15));
const WEB_PROBE_CONCURRENCY = 16;
Expand Down Expand Up @@ -265,6 +273,29 @@ const parseWindowsListenerOutput = (
return [...seen.values()].toSorted((left, right) => left.port - right.port);
};

const windowsListenersToServers = (
listeners: ReadonlyArray<ResourceMonitorWindowsListener>,
terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(),
): ReadonlyArray<DiscoveredLocalServer> => {
const seen = new Map<number, DiscoveredLocalServer>();
for (const listener of listeners) {
if (seen.has(listener.port)) continue;
seen.set(listener.port, {
host: "localhost",
port: listener.port,
url: `http://localhost:${listener.port}`,
processName: listener.processName?.trim() || null,
pid: listener.pid,
terminal: terminalByProcessId.get(listener.pid) ?? null,
});
}
return [...seen.values()].toSorted((left, right) => left.port - right.port);
};

export function windowsFallbackRetryDelayMs(failureCount: number): number {
return Math.min(3_000 * 2 ** Math.max(0, failureCount - 1), WINDOWS_FALLBACK_MAX_RETRY_MS);
}

const serversEqual = (
left: ReadonlyArray<DiscoveredLocalServer>,
right: ReadonlyArray<DiscoveredLocalServer>,
Expand Down Expand Up @@ -292,6 +323,7 @@ const serversEqual = (
export const make = Effect.gen(function* PortDiscoveryMake() {
const net = yield* Net.NetService;
const processRunner = yield* ProcessRunner.ProcessRunner;
const nativeTelemetry = yield* NativeTelemetryClient.NativeTelemetryClient;
const hostPlatform = yield* HostProcessPlatform;
const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);
const stateRef = yield* Ref.make<ScannerState>({
Expand All @@ -301,6 +333,11 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
});
const webProbeCacheRef = yield* Ref.make<ReadonlyMap<string, WebProbeCacheEntry>>(new Map());
const scanSemaphore = yield* Semaphore.make(1);
const windowsFallbackRef = yield* Ref.make({
failureCount: 0,
nextAttemptAtMillis: 0,
lastSnapshot: null as ReadonlyArray<DiscoveredLocalServer> | null,
});

const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () {
const results = yield* Effect.forEach(
Expand Down Expand Up @@ -477,6 +514,52 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
platform: hostPlatform,
}).pipe(Effect.as(null));

const probeWindowsFallback = Effect.fn("PortDiscovery.probeWindowsFallback")(function* (
terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner>,
) {
const nowMillis = yield* Clock.currentTimeMillis;
const fallback = yield* Ref.get(windowsFallbackRef);
if (nowMillis < fallback.nextAttemptAtMillis) {
return fallback.lastSnapshot ?? (yield* probeCommonPorts());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium preview/PortScanner.ts:523

During the Windows fallback cooldown, scans return fallback.lastSnapshot with stale terminal ownership, so registering or unregistering a terminal leaves old associations (including closed terminals) visible for up to 60 seconds. Re-resolve each cached server's terminal from the current terminalByProcessId map before returning the snapshot.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/preview/PortScanner.ts around line 523:

During the Windows fallback cooldown, scans return `fallback.lastSnapshot` with stale `terminal` ownership, so registering or unregistering a terminal leaves old associations (including closed terminals) visible for up to 60 seconds. Re-resolve each cached server's `terminal` from the current `terminalByProcessId` map before returning the snapshot.

}

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: recoverWindowsProbeFailure,
}),
);
if (listeners !== null) {
yield* Ref.set(windowsFallbackRef, {
failureCount: 0,
nextAttemptAtMillis: 0,
lastSnapshot: listeners,
});
return listeners;
}

const failureCount = fallback.failureCount + 1;
yield* Ref.set(windowsFallbackRef, {
failureCount,
nextAttemptAtMillis: nowMillis + windowsFallbackRetryDelayMs(failureCount),
lastSnapshot: fallback.lastSnapshot,
});
return fallback.lastSnapshot ?? (yield* probeCommonPorts());
});

const scanUnlocked = Effect.fn("PortDiscovery.scanUnlocked")(function* (
configuredUrls: ReadonlyArray<string>,
) {
Expand All @@ -488,29 +571,19 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
}
}
if (hostPlatform === "win32") {
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,
}),
);
if (listeners !== null) return yield* probeWebServers(listeners, configuredUrls);
return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls);
const nativeListeners = yield* nativeTelemetry.windowsListeners.pipe(
Effect.map((listeners) => windowsListenersToServers(listeners, terminalByProcessId)),
Effect.catch((cause) =>
Effect.logDebug("native Windows listener discovery failed; using fallback", {
cause,
}).pipe(Effect.as(null)),
),
);
const listeners =
nativeListeners === null
? yield* probeWindowsFallback(terminalByProcessId)
: nativeListeners;
return yield* probeWebServers(listeners, configuredUrls);
}
const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof");
const lsofResult = yield* processRunner
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/resourceTelemetry/Model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function nativeSnapshot(
sequence = 1,
): ResourceMonitorSnapshotEvent {
return {
version: 2,
version: 4,
type: "snapshot",
sequence,
sampledAtUnixMs,
Expand Down
Loading
Loading