authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 15:14:02-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 15:14:02-07:00
loga76b7bbfb5ad1e87714d85516e68bda594f9f830
tree6a1ab1d9ee611931d91c579bcdb2b64db25fdc96
parenta51691a9b0a98e65f0e9b2b775b22a2382595e87
signature Signed by PGP key B5690EEEBB952194

Preserve PortScanner probe defects (#3282)

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,14 +3,48 @@ import * as NodeNet from "node:net";
3import { it as effectIt } from "@effect/vitest";3import { it as effectIt } from "@effect/vitest";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
5import * as Net from "@t3tools/shared/Net";5import * as Net from "@t3tools/shared/Net";
6import { Effect, Layer } from "effect";6import * as Cause from "effect/Cause";
7import * as Effect from "effect/Effect";
8import * as Exit from "effect/Exit";
9import * as Layer from "effect/Layer";
10import * as PlatformError from "effect/PlatformError";
7import { expect } from "vite-plus/test";11import { expect } from "vite-plus/test";
812
9import * as ProcessRunner from "../processRunner.ts";13import * as ProcessRunner from "../processRunner.ts";
10import * as PortScanner from "./PortScanner.ts";14import * as PortScanner from "./PortScanner.ts";
11const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, {15const 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
32const 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
14const TestPortDiscoveryLive = PortScanner.layer.pipe(48const TestPortDiscoveryLive = PortScanner.layer.pipe(
15 Layer.provide(49 Layer.provide(
16 Layer.mergeAll(TestProcessRunner, Net.layer, Layer.succeed(HostProcessPlatform, "win32")),50 Layer.mergeAll(TestProcessRunner, Net.layer, Layer.succeed(HostProcessPlatform, "win32")),
...@@ -87,3 +121,37 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall...@@ -87,3 +121,37 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall
87 }),121 }),
88 );122 );
89});123});
124
125effectIt("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
143effectIt("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,6 +221,14 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
221 }));221 }));
222 });222 });
223223
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 const scanOnce = Effect.fn("PortDiscovery.scan")(function* () {232 const scanOnce = Effect.fn("PortDiscovery.scan")(function* () {
225 const state = yield* Ref.get(stateRef);233 const state = yield* Ref.get(stateRef);
226 const terminalByProcessId = new Map<number, TerminalProcessOwner>();234 const terminalByProcessId = new Map<number, TerminalProcessOwner>();
...@@ -230,6 +238,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -230,6 +238,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
230 }238 }
231 }239 }
232 if (hostPlatform === "win32") {240 if (hostPlatform === "win32") {
241 const recoverWindowsProbeFailure = recoverProcessProbeFailure("windows-listeners");
233 const command =242 const command =
234 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }';243 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }';
235 const listeners = yield* processRunner244 const listeners = yield* processRunner
...@@ -242,11 +251,18 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -242,11 +251,18 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
242 })251 })
243 .pipe(252 .pipe(
244 Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)),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 if (listeners !== null) return listeners;262 if (listeners !== null) return listeners;
248 return yield* probeCommonPorts();263 return yield* probeCommonPorts();
249 }264 }
265 const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof");
250 const lsofResult = yield* processRunner266 const lsofResult = yield* processRunner
251 .run({267 .run({
252 command: "lsof",268 command: "lsof",
...@@ -257,7 +273,13 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -257,7 +273,13 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
257 })273 })
258 .pipe(274 .pipe(
259 Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)),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 if (lsofResult !== null) return lsofResult;284 if (lsofResult !== null) return lsofResult;
263 return yield* probeCommonPorts();285 return yield* probeCommonPorts();