Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 114 additions & 9 deletions packages/shared/src/Net.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<NodeNet.Server, NetService.NetError> =>
Effect.callback<NodeNet.Server, NetService.NetError>((resume) => {
const openServer = (host?: string): Effect.Effect<NodeNet.Server, Error> =>
Effect.callback<NodeNet.Server, Error>((resume) => {
const server = NodeNet.createServer();
let settled = false;

const settle = (effect: Effect.Effect<NodeNet.Server, NetService.NetError>) => {
const settle = (effect: Effect.Effect<NodeNet.Server, Error>) => {
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) {
Expand All @@ -45,11 +52,6 @@ const openServer = (host?: string): Effect.Effect<NodeNet.Server, NetService.Net

it.layer(NetService.layer)("NetService", (it) => {
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;
Expand All @@ -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"),
Expand Down
143 changes: 125 additions & 18 deletions packages/shared/src/Net.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>()("NetError", {
host: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {
export class LoopbackPortListenError extends Schema.TaggedErrorClass<LoopbackPortListenError>()(
"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>()(
"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>()(
"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 {
Expand All @@ -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();
Expand Down Expand Up @@ -60,15 +101,21 @@ 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
* loopback availability checks.
*/
const canListenOnHost = (port: number, host: string): Effect.Effect<boolean> =>
Effect.callback<boolean>((resume) => {
const server = NodeNet.createServer();
const server = createServer();
let settled = false;

const settle = (value: boolean) => {
Expand Down Expand Up @@ -153,8 +200,15 @@ export const make = () => {
*/
const reserveLoopbackPort = (host = "127.0.0.1"): Effect.Effect<number, NetError> =>
Effect.callback<number, NetError>((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<number, NetError>) => {
if (settled) return;
Expand All @@ -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:
Comment thread
juliusmarminge marked this conversation as resolved.
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 })));
});
Comment thread
cursor[bot] marked this conversation as resolved.
});
});
} catch (cause) {
settle(Effect.fail(new LoopbackPortListenError({ host, cause })));
}

return Effect.sync(() => {
closeServer(probe);
Expand Down
Loading