diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 6c48f6d5c8b..69b5729164d 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -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")), @@ -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); + } + }), +); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 16ff0fed58f..c306fca2b33 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -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(); @@ -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 @@ -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", @@ -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();