diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index 7c3325fce2e..d1e95bb2fa5 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -2,9 +2,16 @@ 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 isLoopbackPortAddressUnavailableError = Schema.is( + NetService.LoopbackPortAddressUnavailableError, +); +const isLoopbackPortReleaseError = Schema.is(NetService.LoopbackPortReleaseError); + const closeServer = (server: NodeNet.Server) => Effect.sync(() => { try { @@ -19,19 +26,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 +52,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 +61,109 @@ 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("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("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 }); + + const error = yield* net.reserveLoopbackPort().pipe(Effect.flip); + + 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* () { + 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 d5d4cfafac0..50bb65e4580 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -6,15 +6,53 @@ 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), + cause: Schema.optional(Schema.Defect()), + }, +) { 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 { @@ -24,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(); @@ -60,7 +101,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 @@ -68,7 +115,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) => { @@ -153,8 +200,15 @@ 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 addressDetails: + | { + readonly address: string | null; + readonly family: string | null; + readonly port: number | null; + } + | undefined; const settle = (effect: Effect.Effect) => { if (settled) return; @@ -163,20 +217,73 @@ export const make = () => { }; probe.once("error", (cause) => { - settle(Effect.fail(new NetError({ host, cause }))); + settle( + Effect.fail( + addressDetails === undefined + ? new LoopbackPortListenError({ host, cause }) + : isUsablePort(addressDetails.port) + ? new LoopbackPortReleaseError({ host, port: addressDetails.port, cause }) + : new LoopbackPortAddressUnavailableError({ + host, + ...addressDetails, + cause, + }), + ), + ); }); - probe.listen(0, host, () => { - const address = probe.address(); - const port = typeof address === "object" && address !== null ? address.port : 0; - probe.close(() => { - if (port > 0) { + try { + probe.listen(0, host, () => { + const address = probe.address(); + const resolvedAddressDetails = + typeof address === "object" && address !== null + ? { + address: address.address, + family: address.family, + port: + typeof address.port === "number" && Number.isFinite(address.port) + ? address.port + : null, + } + : { + address, + family: null, + port: null, + }; + addressDetails = resolvedAddressDetails; + + probe.close((cause) => { + const port = resolvedAddressDetails.port; + if (!isUsablePort(port)) { + settle( + Effect.fail( + new LoopbackPortAddressUnavailableError({ + host, + ...resolvedAddressDetails, + ...(cause === undefined ? {} : { cause }), + }), + ), + ); + return; + } + if (cause) { + settle( + Effect.fail( + new LoopbackPortReleaseError({ + host, + port, + cause, + }), + ), + ); + return; + } settle(Effect.succeed(port)); - return; - } - settle(Effect.fail(new NetError({ host }))); + }); }); - }); + } catch (cause) { + settle(Effect.fail(new LoopbackPortListenError({ host, cause }))); + } return Effect.sync(() => { closeServer(probe);