| author | |
| committer | |
| log | a76b7bbfb5ad1e87714d85516e68bda594f9f830 |
| tree | 6a1ab1d9ee611931d91c579bcdb2b64db25fdc96 |
| parent | a51691a9b0a98e65f0e9b2b775b22a2382595e87 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>2 files changed, 94 insertions(+), 4 deletions(-)
apps/server/src/preview/PortScanner.test.ts+70-2| ... | ... | @@ -3,14 +3,48 @@ import * as NodeNet from "node:net"; |
| 3 | 3 | import { it as effectIt } from "@effect/vitest"; |
| 4 | 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 5 | 5 | import * as Net from "@t3tools/shared/Net"; |
| 6 | import { Effect, Layer } from "effect"; | |
| 6 | import * as Cause from "effect/Cause"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as Exit from "effect/Exit"; | |
| 9 | import * as Layer from "effect/Layer"; | |
| 10 | import * as PlatformError from "effect/PlatformError"; | |
| 7 | 11 | import { expect } from "vite-plus/test"; |
| 8 | 12 | |
| 9 | 13 | import * as ProcessRunner from "../processRunner.ts"; |
| 10 | 14 | import * as PortScanner from "./PortScanner.ts"; |
| 11 | 15 | const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { |
| 12 | run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), | |
| 16 | run: (input) => | |
| 17 | Effect.fail( | |
| 18 | new ProcessRunner.ProcessSpawnError({ | |
| 19 | command: input.command, | |
| 20 | argumentCount: input.args.length, | |
| 21 | cwd: input.cwd, | |
| 22 | cause: PlatformError.systemError({ | |
| 23 | _tag: "NotFound", | |
| 24 | module: "ChildProcess", | |
| 25 | method: "spawn", | |
| 26 | description: "PowerShell is not installed in the test environment", | |
| 27 | }), | |
| 28 | }), | |
| 29 | ), | |
| 13 | 30 | }); |
| 31 | ||
| 32 | const makeProbeFailureLayer = (run: ProcessRunner.ProcessRunner["Service"]["run"]) => | |
| 33 | PortScanner.layer.pipe( | |
| 34 | Layer.provide( | |
| 35 | Layer.mergeAll( | |
| 36 | Layer.succeed(ProcessRunner.ProcessRunner, { run }), | |
| 37 | Layer.succeed(Net.NetService, { | |
| 38 | canListenOnHost: () => Effect.succeed(true), | |
| 39 | isPortAvailableOnLoopback: () => Effect.succeed(true), | |
| 40 | reserveLoopbackPort: () => Effect.succeed(40_000), | |
| 41 | findAvailablePort: (preferred) => Effect.succeed(preferred), | |
| 42 | }), | |
| 43 | Layer.succeed(HostProcessPlatform, "linux"), | |
| 44 | ), | |
| 45 | ), | |
| 46 | ); | |
| 47 | ||
| 14 | 48 | const TestPortDiscoveryLive = PortScanner.layer.pipe( |
| 15 | 49 | Layer.provide( |
| 16 | 50 | Layer.mergeAll(TestProcessRunner, Net.layer, Layer.succeed(HostProcessPlatform, "win32")), |
| ... | ... | @@ -87,3 +121,37 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall |
| 87 | 121 | }), |
| 88 | 122 | ); |
| 89 | 123 | }); |
| 124 | ||
| 125 | effectIt("does not swallow process probe defects", () => | |
| 126 | Effect.gen(function* () { | |
| 127 | const defect = new Error("unexpected process probe defect"); | |
| 128 | const layer = makeProbeFailureLayer(() => Effect.die(defect)); | |
| 129 | ||
| 130 | const exit = yield* Effect.flatMap(PortScanner.PortDiscovery, (scanner) => scanner.scan()).pipe( | |
| 131 | Effect.provide(layer), | |
| 132 | Effect.exit, | |
| 133 | ); | |
| 134 | ||
| 135 | expect(Exit.isFailure(exit)).toBe(true); | |
| 136 | if (Exit.isFailure(exit)) { | |
| 137 | expect(Cause.hasDies(exit.cause)).toBe(true); | |
| 138 | expect(Cause.squash(exit.cause)).toBe(defect); | |
| 139 | } | |
| 140 | }), | |
| 141 | ); | |
| 142 | ||
| 143 | effectIt("does not swallow process probe interruption", () => | |
| 144 | Effect.gen(function* () { | |
| 145 | const layer = makeProbeFailureLayer(() => Effect.interrupt); | |
| 146 | ||
| 147 | const exit = yield* Effect.flatMap(PortScanner.PortDiscovery, (scanner) => scanner.scan()).pipe( | |
| 148 | Effect.provide(layer), | |
| 149 | Effect.exit, | |
| 150 | ); | |
| 151 | ||
| 152 | expect(Exit.isFailure(exit)).toBe(true); | |
| 153 | if (Exit.isFailure(exit)) { | |
| 154 | expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true); | |
| 155 | } | |
| 156 | }), | |
| 157 | ); |
apps/server/src/preview/PortScanner.ts+24-2| ... | ... | @@ -221,6 +221,14 @@ export const make = Effect.gen(function* PortDiscoveryMake() { |
| 221 | 221 | })); |
| 222 | 222 | }); |
| 223 | 223 | |
| 224 | const recoverProcessProbeFailure = | |
| 225 | (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) => | |
| 226 | Effect.logDebug("preview port process probe failed; falling back to common-port probes", { | |
| 227 | cause: error, | |
| 228 | probe, | |
| 229 | platform: hostPlatform, | |
| 230 | }).pipe(Effect.as(null)); | |
| 231 | ||
| 224 | 232 | const scanOnce = Effect.fn("PortDiscovery.scan")(function* () { |
| 225 | 233 | const state = yield* Ref.get(stateRef); |
| 226 | 234 | const terminalByProcessId = new Map<number, TerminalProcessOwner>(); |
| ... | ... | @@ -230,6 +238,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { |
| 230 | 238 | } |
| 231 | 239 | } |
| 232 | 240 | if (hostPlatform === "win32") { |
| 241 | const recoverWindowsProbeFailure = recoverProcessProbeFailure("windows-listeners"); | |
| 233 | 242 | const command = |
| 234 | 243 | 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; |
| 235 | 244 | const listeners = yield* processRunner |
| ... | ... | @@ -242,11 +251,18 @@ export const make = Effect.gen(function* PortDiscoveryMake() { |
| 242 | 251 | }) |
| 243 | 252 | .pipe( |
| 244 | 253 | Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), |
| 245 | Effect.catchCause(() => Effect.succeed(null)), | |
| 254 | Effect.catchTags({ | |
| 255 | ProcessSpawnError: recoverWindowsProbeFailure, | |
| 256 | ProcessStdinError: recoverWindowsProbeFailure, | |
| 257 | ProcessOutputLimitError: recoverWindowsProbeFailure, | |
| 258 | ProcessReadError: recoverWindowsProbeFailure, | |
| 259 | ProcessTimeoutError: recoverWindowsProbeFailure, | |
| 260 | }), | |
| 246 | 261 | ); |
| 247 | 262 | if (listeners !== null) return listeners; |
| 248 | 263 | return yield* probeCommonPorts(); |
| 249 | 264 | } |
| 265 | const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); | |
| 250 | 266 | const lsofResult = yield* processRunner |
| 251 | 267 | .run({ |
| 252 | 268 | command: "lsof", |
| ... | ... | @@ -257,7 +273,13 @@ export const make = Effect.gen(function* PortDiscoveryMake() { |
| 257 | 273 | }) |
| 258 | 274 | .pipe( |
| 259 | 275 | Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), |
| 260 | Effect.catchCause(() => Effect.succeed(null)), | |
| 276 | Effect.catchTags({ | |
| 277 | ProcessSpawnError: recoverLsofProbeFailure, | |
| 278 | ProcessStdinError: recoverLsofProbeFailure, | |
| 279 | ProcessOutputLimitError: recoverLsofProbeFailure, | |
| 280 | ProcessReadError: recoverLsofProbeFailure, | |
| 281 | ProcessTimeoutError: recoverLsofProbeFailure, | |
| 282 | }), | |
| 261 | 283 | ); |
| 262 | 284 | if (lsofResult !== null) return lsofResult; |
| 263 | 285 | return yield* probeCommonPorts(); |