Skip to content
Merged
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
72 changes: 70 additions & 2 deletions apps/server/src/preview/PortScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,48 @@ import * as NodeNet from "node:net";
import { it as effectIt } from "@effect/vitest";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Net from "@t3tools/shared/Net";
import { Effect, Layer } from "effect";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as PlatformError from "effect/PlatformError";
import { expect } from "vite-plus/test";

import * as ProcessRunner from "../processRunner.ts";
import * as PortScanner from "./PortScanner.ts";
const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"),
run: (input) =>
Effect.fail(
new ProcessRunner.ProcessSpawnError({
command: input.command,
argumentCount: input.args.length,
cwd: input.cwd,
cause: PlatformError.systemError({
_tag: "NotFound",
module: "ChildProcess",
method: "spawn",
description: "PowerShell is not installed in the test environment",
}),
}),
),
});

const makeProbeFailureLayer = (run: ProcessRunner.ProcessRunner["Service"]["run"]) =>
PortScanner.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.succeed(ProcessRunner.ProcessRunner, { run }),
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, "linux"),
),
),
);

const TestPortDiscoveryLive = PortScanner.layer.pipe(
Layer.provide(
Layer.mergeAll(TestProcessRunner, Net.layer, Layer.succeed(HostProcessPlatform, "win32")),
Expand Down Expand Up @@ -87,3 +121,37 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall
}),
);
});

effectIt("does not swallow process probe defects", () =>
Effect.gen(function* () {
const defect = new Error("unexpected process probe defect");
const layer = makeProbeFailureLayer(() => Effect.die(defect));

const exit = yield* Effect.flatMap(PortScanner.PortDiscovery, (scanner) => scanner.scan()).pipe(
Effect.provide(layer),
Effect.exit,
);

expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
expect(Cause.hasDies(exit.cause)).toBe(true);
expect(Cause.squash(exit.cause)).toBe(defect);
}
}),
);

effectIt("does not swallow process probe interruption", () =>
Effect.gen(function* () {
const layer = makeProbeFailureLayer(() => Effect.interrupt);

const exit = yield* Effect.flatMap(PortScanner.PortDiscovery, (scanner) => scanner.scan()).pipe(
Effect.provide(layer),
Effect.exit,
);

expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true);
}
}),
);
26 changes: 24 additions & 2 deletions apps/server/src/preview/PortScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,14 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
}));
});

const recoverProcessProbeFailure =
(probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) =>
Effect.logDebug("preview port process probe failed; falling back to common-port probes", {
cause: error,
probe,
platform: hostPlatform,
}).pipe(Effect.as(null));

const scanOnce = Effect.fn("PortDiscovery.scan")(function* () {
const state = yield* Ref.get(stateRef);
const terminalByProcessId = new Map<number, TerminalProcessOwner>();
Expand All @@ -230,6 +238,7 @@ 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
Expand All @@ -242,11 +251,18 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
})
.pipe(
Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)),
Effect.catchCause(() => Effect.succeed(null)),
Effect.catchTags({
ProcessSpawnError: recoverWindowsProbeFailure,
ProcessStdinError: recoverWindowsProbeFailure,
ProcessOutputLimitError: recoverWindowsProbeFailure,
ProcessReadError: recoverWindowsProbeFailure,
ProcessTimeoutError: recoverWindowsProbeFailure,
}),
);
if (listeners !== null) return listeners;
return yield* probeCommonPorts();
}
const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof");
const lsofResult = yield* processRunner
.run({
command: "lsof",
Expand All @@ -257,7 +273,13 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
})
.pipe(
Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)),
Effect.catchCause(() => Effect.succeed(null)),
Effect.catchTags({
ProcessSpawnError: recoverLsofProbeFailure,
ProcessStdinError: recoverLsofProbeFailure,
ProcessOutputLimitError: recoverLsofProbeFailure,
ProcessReadError: recoverLsofProbeFailure,
ProcessTimeoutError: recoverLsofProbeFailure,
}),
);
if (lsofResult !== null) return lsofResult;
return yield* probeCommonPorts();
Expand Down
Loading