From c44d8b2480b752f71804fe58cda8c4426ffdf2e2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 06:05:00 -0700 Subject: [PATCH 1/5] refactor: structure loopback port errors Co-authored-by: codex --- packages/shared/src/Net.test.ts | 28 ++++++--- packages/shared/src/Net.ts | 104 +++++++++++++++++++++++++++----- 2 files changed, 107 insertions(+), 25 deletions(-) diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index 7c3325fce2e..6a62d07c75d 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -2,9 +2,12 @@ import * as NodeNet from "node:net"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import * as NetService from "./Net.ts"; +const isLoopbackPortListenError = Schema.is(NetService.LoopbackPortListenError); + const closeServer = (server: NodeNet.Server) => Effect.sync(() => { try { @@ -19,19 +22,19 @@ const getPort = (server: NodeNet.Server): number => { return typeof address === "object" && address !== null ? address.port : 0; }; -const openServer = (host?: string): Effect.Effect => - Effect.callback((resume) => { +const openServer = (host?: string): Effect.Effect => + Effect.callback((resume) => { const server = NodeNet.createServer(); let settled = false; - const settle = (effect: Effect.Effect) => { + const settle = (effect: Effect.Effect) => { if (settled) return; settled = true; resume(effect); }; server.once("error", (cause) => { - settle(Effect.fail(new NetService.NetError({ host: host ?? "localhost", cause }))); + settle(Effect.fail(cause)); }); if (host) { @@ -45,11 +48,6 @@ const openServer = (host?: string): Effect.Effect { describe("Net helpers", () => { - it("preserves the loopback reservation error message", () => { - const error = new NetService.NetError({ host: "127.0.0.1" }); - assert.equal(error.message, "Failed to reserve loopback port"); - }); - it.effect("reserveLoopbackPort returns a positive loopback port", () => Effect.gen(function* () { const net = yield* NetService.NetService; @@ -59,6 +57,18 @@ it.layer(NetService.layer)("NetService", (it) => { }), ); + it.effect("retains the host and listen cause when reservation fails", () => + Effect.gen(function* () { + const net = yield* NetService.NetService; + const error = yield* net.reserveLoopbackPort("256.256.256.256").pipe(Effect.flip); + + assert(isLoopbackPortListenError(error)); + assert.equal(error.host, "256.256.256.256"); + assert.match(error.message, /256\.256\.256\.256/u); + assert.equal((error.cause as NodeJS.ErrnoException).code, "ENOTFOUND"); + }), + ); + it.effect("isPortAvailableOnLoopback reports false for an occupied port", () => Effect.acquireUseRelease( openServer("127.0.0.1"), diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index d5d4cfafac0..6f7410b3e72 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -6,15 +6,52 @@ import * as Layer from "effect/Layer"; import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; -export class NetError extends Schema.TaggedErrorClass()("NetError", { - host: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) { +export class LoopbackPortListenError extends Schema.TaggedErrorClass()( + "LoopbackPortListenError", + { + host: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to listen for an ephemeral loopback port on ${this.host}.`; + } +} + +export class LoopbackPortAddressUnavailableError extends Schema.TaggedErrorClass()( + "LoopbackPortAddressUnavailableError", + { + host: Schema.String, + address: Schema.NullOr(Schema.String), + family: Schema.NullOr(Schema.String), + port: Schema.NullOr(Schema.Number), + }, +) { override get message(): string { - return "Failed to reserve loopback port"; + return `Failed to read a usable ephemeral loopback port for ${this.host} (address ${this.address ?? "unavailable"}, family ${this.family ?? "unavailable"}, port ${this.port ?? "unavailable"}).`; } } +export class LoopbackPortReleaseError extends Schema.TaggedErrorClass()( + "LoopbackPortReleaseError", + { + host: Schema.String, + port: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to release ephemeral loopback port ${this.port} on ${this.host}.`; + } +} + +export const NetError = Schema.Union([ + LoopbackPortListenError, + LoopbackPortAddressUnavailableError, + LoopbackPortReleaseError, +]); +export type NetError = typeof NetError.Type; + const isErrnoExceptionWithCode = ( cause: unknown, ): cause is { @@ -163,20 +200,55 @@ export const make = () => { }; probe.once("error", (cause) => { - settle(Effect.fail(new NetError({ host, cause }))); + settle(Effect.fail(new LoopbackPortListenError({ host, cause }))); }); - probe.listen(0, host, () => { - const address = probe.address(); - const port = typeof address === "object" && address !== null ? address.port : 0; - probe.close(() => { - if (port > 0) { - settle(Effect.succeed(port)); - return; - } - settle(Effect.fail(new NetError({ host }))); + try { + probe.listen(0, host, () => { + const address = probe.address(); + const addressDetails = + typeof address === "object" && address !== null + ? { + address: address.address, + family: address.family, + port: address.port, + } + : { + address, + family: null, + port: null, + }; + + probe.close((cause) => { + if (cause) { + settle( + Effect.fail( + new LoopbackPortReleaseError({ + host, + port: addressDetails.port ?? 0, + cause, + }), + ), + ); + return; + } + if (addressDetails.port !== null && addressDetails.port > 0) { + settle(Effect.succeed(addressDetails.port)); + return; + } + settle( + Effect.fail( + new LoopbackPortAddressUnavailableError({ + host, + ...addressDetails, + }), + ), + ); + }); }); - }); + } catch (cause) { + settle(Effect.fail(new LoopbackPortListenError({ host, cause }))); + } return Effect.sync(() => { closeServer(probe); From c0e06c27128017307fec08220cdb06e9df8f4f05 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:10:05 -0700 Subject: [PATCH 2/5] Classify loopback release errors structurally Co-authored-by: codex --- packages/shared/src/Net.test.ts | 30 ++++++++++++++++++++++++++++++ packages/shared/src/Net.ts | 25 ++++++++++++++++++++----- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index 6a62d07c75d..fa7e78c7cc7 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -7,6 +7,7 @@ import * as Schema from "effect/Schema"; import * as NetService from "./Net.ts"; const isLoopbackPortListenError = Schema.is(NetService.LoopbackPortListenError); +const isLoopbackPortReleaseError = Schema.is(NetService.LoopbackPortReleaseError); const closeServer = (server: NodeNet.Server) => Effect.sync(() => { @@ -69,6 +70,35 @@ it.layer(NetService.layer)("NetService", (it) => { }), ); + it.effect("classifies server errors during close as release failures", () => { + const probe = NodeNet.createServer(); + const cause = new Error("close failed"); + probe.unref = (() => probe) as typeof probe.unref; + probe.address = (() => ({ + address: "127.0.0.1", + family: "IPv4", + port: 43123, + })) as typeof probe.address; + probe.listen = ((_port: number, _host: string, listeningListener: () => void) => { + listeningListener(); + return probe; + }) as typeof probe.listen; + probe.close = (() => { + probe.emit("error", cause); + return probe; + }) as typeof probe.close; + const net = NetService.make({ createServer: () => probe }); + + return Effect.gen(function* () { + const error = yield* net.reserveLoopbackPort().pipe(Effect.flip); + + assert(isLoopbackPortReleaseError(error)); + assert.equal(error.host, "127.0.0.1"); + assert.equal(error.port, 43123); + assert.strictEqual(error.cause, cause); + }); + }); + it.effect("isPortAvailableOnLoopback reports false for an occupied port", () => Effect.acquireUseRelease( openServer("127.0.0.1"), diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index 6f7410b3e72..c261df273f9 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -97,7 +97,13 @@ export class NetService extends Context.Service< } >()("@t3tools/shared/Net/NetService") {} -export const make = () => { +export const make = ( + options: { + readonly createServer?: () => NodeNet.Server; + } = {}, +) => { + const createServer = options.createServer ?? NodeNet.createServer; + /** * Returns true when a TCP server can bind to {host, port}. * `EADDRNOTAVAIL` is treated as available so IPv6-absent hosts don't fail @@ -105,7 +111,7 @@ export const make = () => { */ const canListenOnHost = (port: number, host: string): Effect.Effect => Effect.callback((resume) => { - const server = NodeNet.createServer(); + const server = createServer(); let settled = false; const settle = (value: boolean) => { @@ -190,8 +196,9 @@ export const make = () => { */ const reserveLoopbackPort = (host = "127.0.0.1"): Effect.Effect => Effect.callback((resume) => { - const probe = NodeNet.createServer(); + const probe = createServer(); let settled = false; + let releasePort: number | undefined; const settle = (effect: Effect.Effect) => { if (settled) return; @@ -200,7 +207,13 @@ export const make = () => { }; probe.once("error", (cause) => { - settle(Effect.fail(new LoopbackPortListenError({ host, cause }))); + settle( + Effect.fail( + releasePort === undefined + ? new LoopbackPortListenError({ host, cause }) + : new LoopbackPortReleaseError({ host, port: releasePort, cause }), + ), + ); }); try { @@ -218,6 +231,8 @@ export const make = () => { family: null, port: null, }; + const port = addressDetails.port ?? 0; + releasePort = port; probe.close((cause) => { if (cause) { @@ -225,7 +240,7 @@ export const make = () => { Effect.fail( new LoopbackPortReleaseError({ host, - port: addressDetails.port ?? 0, + port, cause, }), ), From 3fb511abafb150cc80a18545fab2ee4219a57e77 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:16:58 -0700 Subject: [PATCH 3/5] Preserve unusable loopback address context Co-authored-by: codex --- packages/shared/src/Net.test.ts | 30 +++++++++++++++++++ packages/shared/src/Net.ts | 51 +++++++++++++++++++++------------ 2 files changed, 62 insertions(+), 19 deletions(-) diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index fa7e78c7cc7..4531e659433 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -7,6 +7,9 @@ import * as Schema from "effect/Schema"; import * as NetService from "./Net.ts"; const isLoopbackPortListenError = Schema.is(NetService.LoopbackPortListenError); +const isLoopbackPortAddressUnavailableError = Schema.is( + NetService.LoopbackPortAddressUnavailableError, +); const isLoopbackPortReleaseError = Schema.is(NetService.LoopbackPortReleaseError); const closeServer = (server: NodeNet.Server) => @@ -99,6 +102,33 @@ it.layer(NetService.layer)("NetService", (it) => { }); }); + it.effect("preserves address-read context when closing an unusable reservation", () => { + const probe = NodeNet.createServer(); + const cause = new Error("close failed"); + probe.unref = (() => probe) as typeof probe.unref; + probe.address = (() => null) as typeof probe.address; + probe.listen = ((_port: number, _host: string, listeningListener: () => void) => { + listeningListener(); + return probe; + }) as typeof probe.listen; + probe.close = (() => { + probe.emit("error", cause); + return probe; + }) as typeof probe.close; + const net = NetService.make({ createServer: () => probe }); + + return Effect.gen(function* () { + const error = yield* net.reserveLoopbackPort().pipe(Effect.flip); + + assert(isLoopbackPortAddressUnavailableError(error)); + assert.equal(error.host, "127.0.0.1"); + assert.equal(error.address, null); + assert.equal(error.family, null); + assert.equal(error.port, null); + assert.strictEqual(error.cause, cause); + }); + }); + it.effect("isPortAvailableOnLoopback reports false for an occupied port", () => Effect.acquireUseRelease( openServer("127.0.0.1"), diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index c261df273f9..44dc6388fde 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -25,6 +25,7 @@ export class LoopbackPortAddressUnavailableError extends Schema.TaggedErrorClass address: Schema.NullOr(Schema.String), family: Schema.NullOr(Schema.String), port: Schema.NullOr(Schema.Number), + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { @@ -198,7 +199,13 @@ export const make = ( Effect.callback((resume) => { const probe = createServer(); let settled = false; - let releasePort: number | undefined; + let addressDetails: + | { + readonly address: string | null; + readonly family: string | null; + readonly port: number | null; + } + | undefined; const settle = (effect: Effect.Effect) => { if (settled) return; @@ -209,9 +216,15 @@ export const make = ( probe.once("error", (cause) => { settle( Effect.fail( - releasePort === undefined + addressDetails === undefined ? new LoopbackPortListenError({ host, cause }) - : new LoopbackPortReleaseError({ host, port: releasePort, cause }), + : addressDetails.port !== null && addressDetails.port > 0 + ? new LoopbackPortReleaseError({ host, port: addressDetails.port, cause }) + : new LoopbackPortAddressUnavailableError({ + host, + ...addressDetails, + cause, + }), ), ); }); @@ -219,7 +232,7 @@ export const make = ( try { probe.listen(0, host, () => { const address = probe.address(); - const addressDetails = + const resolvedAddressDetails = typeof address === "object" && address !== null ? { address: address.address, @@ -231,34 +244,34 @@ export const make = ( family: null, port: null, }; - const port = addressDetails.port ?? 0; - releasePort = port; + addressDetails = resolvedAddressDetails; probe.close((cause) => { + if (resolvedAddressDetails.port === null || resolvedAddressDetails.port <= 0) { + settle( + Effect.fail( + new LoopbackPortAddressUnavailableError({ + host, + ...resolvedAddressDetails, + ...(cause === undefined ? {} : { cause }), + }), + ), + ); + return; + } if (cause) { settle( Effect.fail( new LoopbackPortReleaseError({ host, - port, + port: resolvedAddressDetails.port, cause, }), ), ); return; } - if (addressDetails.port !== null && addressDetails.port > 0) { - settle(Effect.succeed(addressDetails.port)); - return; - } - settle( - Effect.fail( - new LoopbackPortAddressUnavailableError({ - host, - ...addressDetails, - }), - ), - ); + settle(Effect.succeed(resolvedAddressDetails.port)); }); }); } catch (cause) { From bf8ca6a086ea8ba1a844de9affda104858b6ebbf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:18:31 -0700 Subject: [PATCH 4/5] fix(shared): reject invalid reserved ports Co-authored-by: codex --- packages/shared/src/Net.test.ts | 29 +++++++++++++++++++++++++++++ packages/shared/src/Net.ts | 12 ++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index 4531e659433..ac25ccf24d7 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -129,6 +129,35 @@ it.layer(NetService.layer)("NetService", (it) => { }); }); + it.effect("rejects missing and non-finite ports returned by the server", () => + Effect.gen(function* () { + for (const invalidPort of [undefined, Number.NaN]) { + const probe = NodeNet.createServer(); + probe.unref = (() => probe) as typeof probe.unref; + probe.address = (() => ({ + address: "127.0.0.1", + family: "IPv4", + port: invalidPort, + })) as unknown as typeof probe.address; + probe.listen = ((_port: number, _host: string, listeningListener: () => void) => { + listeningListener(); + return probe; + }) as typeof probe.listen; + probe.close = ((callback?: (cause?: Error) => void) => { + callback?.(); + return probe; + }) as typeof probe.close; + const net = NetService.make({ createServer: () => probe }); + + const error = yield* net.reserveLoopbackPort().pipe(Effect.flip); + + assert(isLoopbackPortAddressUnavailableError(error)); + assert.equal(error.port, null); + assert.equal("cause" in error, false); + } + }), + ); + it.effect("isPortAvailableOnLoopback reports false for an occupied port", () => Effect.acquireUseRelease( openServer("127.0.0.1"), diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index 44dc6388fde..7a22e2c1f8f 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -237,7 +237,10 @@ export const make = ( ? { address: address.address, family: address.family, - port: address.port, + port: + typeof address.port === "number" && Number.isFinite(address.port) + ? address.port + : null, } : { address, @@ -247,7 +250,8 @@ export const make = ( addressDetails = resolvedAddressDetails; probe.close((cause) => { - if (resolvedAddressDetails.port === null || resolvedAddressDetails.port <= 0) { + const port = resolvedAddressDetails.port; + if (port === null || !Number.isInteger(port) || port <= 0 || port > 65_535) { settle( Effect.fail( new LoopbackPortAddressUnavailableError({ @@ -264,14 +268,14 @@ export const make = ( Effect.fail( new LoopbackPortReleaseError({ host, - port: resolvedAddressDetails.port, + port, cause, }), ), ); return; } - settle(Effect.succeed(resolvedAddressDetails.port)); + settle(Effect.succeed(port)); }); }); } catch (cause) { From ce790f395cd29b518a07dfcc3d429624d825932d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:21:51 -0700 Subject: [PATCH 5/5] fix(shared): align loopback port validation Co-authored-by: codex --- packages/shared/src/Net.test.ts | 54 ++++++++++++++++++--------------- packages/shared/src/Net.ts | 7 +++-- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index ac25ccf24d7..d1e95bb2fa5 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -102,32 +102,38 @@ it.layer(NetService.layer)("NetService", (it) => { }); }); - it.effect("preserves address-read context when closing an unusable reservation", () => { - const probe = NodeNet.createServer(); - const cause = new Error("close failed"); - probe.unref = (() => probe) as typeof probe.unref; - probe.address = (() => null) as typeof probe.address; - probe.listen = ((_port: number, _host: string, listeningListener: () => void) => { - listeningListener(); - return probe; - }) as typeof probe.listen; - probe.close = (() => { - probe.emit("error", cause); - return probe; - }) as typeof probe.close; - const net = NetService.make({ createServer: () => probe }); + it.effect("preserves address context when an unusable reservation errors during close", () => + Effect.gen(function* () { + for (const invalidPort of [null, 43.5, 65_536]) { + const probe = NodeNet.createServer(); + const cause = new Error("close failed"); + probe.unref = (() => probe) as typeof probe.unref; + probe.address = (() => ({ + address: "127.0.0.1", + family: "IPv4", + port: invalidPort, + })) as unknown as typeof probe.address; + probe.listen = ((_port: number, _host: string, listeningListener: () => void) => { + listeningListener(); + return probe; + }) as typeof probe.listen; + probe.close = (() => { + probe.emit("error", cause); + return probe; + }) as typeof probe.close; + const net = NetService.make({ createServer: () => probe }); - return Effect.gen(function* () { - const error = yield* net.reserveLoopbackPort().pipe(Effect.flip); + const error = yield* net.reserveLoopbackPort().pipe(Effect.flip); - assert(isLoopbackPortAddressUnavailableError(error)); - assert.equal(error.host, "127.0.0.1"); - assert.equal(error.address, null); - assert.equal(error.family, null); - assert.equal(error.port, null); - assert.strictEqual(error.cause, cause); - }); - }); + assert(isLoopbackPortAddressUnavailableError(error)); + assert.equal(error.host, "127.0.0.1"); + assert.equal(error.address, "127.0.0.1"); + assert.equal(error.family, "IPv4"); + assert.equal(error.port, invalidPort); + assert.strictEqual(error.cause, cause); + } + }), + ); it.effect("rejects missing and non-finite ports returned by the server", () => Effect.gen(function* () { diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index 7a22e2c1f8f..50bb65e4580 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -62,6 +62,9 @@ const isErrnoExceptionWithCode = ( Predicate.hasProperty(cause, "code") && Predicate.isString(cause.code); +const isUsablePort = (port: number | null): port is number => + port !== null && Number.isInteger(port) && port > 0 && port <= 65_535; + const closeServer = (server: NodeNet.Server) => { try { server.close(); @@ -218,7 +221,7 @@ export const make = ( Effect.fail( addressDetails === undefined ? new LoopbackPortListenError({ host, cause }) - : addressDetails.port !== null && addressDetails.port > 0 + : isUsablePort(addressDetails.port) ? new LoopbackPortReleaseError({ host, port: addressDetails.port, cause }) : new LoopbackPortAddressUnavailableError({ host, @@ -251,7 +254,7 @@ export const make = ( probe.close((cause) => { const port = resolvedAddressDetails.port; - if (port === null || !Number.isInteger(port) || port <= 0 || port > 65_535) { + if (!isUsablePort(port)) { settle( Effect.fail( new LoopbackPortAddressUnavailableError({