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
10 changes: 9 additions & 1 deletion apps/server/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,15 @@ export const resolveServerConfig = (
if (mode === "desktop" || listenPath !== undefined) {
return Effect.succeed(ServerConfig.DEFAULT_PORT);
}
return findAvailablePort(ServerConfig.DEFAULT_PORT);
return findAvailablePort(ServerConfig.DEFAULT_PORT).pipe(
Effect.tap((port) =>
port === ServerConfig.DEFAULT_PORT
? Effect.void
: Effect.logInfo(
`Port ${ServerConfig.DEFAULT_PORT} is in use, so ViewCode is using ${port}. Pass --port to choose one.`,
),
),
);
},
},
);
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/cli/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { recordFatalCause, recordFatalErrors } from "../fatalLog.ts";
import { awaitLauncherLoss } from "../launcherWatch.ts";
import { runServer } from "../server.ts";
import { type CliServerFlags, resolveServerConfig, sharedServerCommandFlags } from "./config.ts";
import { addressInUseMessage, isAddressInUse } from "./viewcodeAddressInUse.ts";

const encodeCommand = Schema.encodeEffect(Schema.fromJsonString(Schema.String));

Expand All @@ -28,6 +29,16 @@ const runServerCommand = (
// ViewCode: a dying server leaves its last error in server-fatal.log.
yield* recordFatalErrors(config.logsDir);
const run = runServer.pipe(
// ViewCode: a taken port (only an explicit one: the default moves to a free one)
// is a usage problem, not a crash, so it gets one line instead of a stack.
Effect.catchIf(
(error) => config.listenPath === undefined && isAddressInUse(error),
() =>
Effect.sync(() => {
process.stderr.write(`${addressInUseMessage(config.port)}\n`);
process.exitCode = 1;
}),
),
recordFatalCause(config.logsDir),
Effect.provideService(ServerConfig.ServerConfig, config),
);
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/cli/viewcodeAddressInUse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as Predicate from "effect/Predicate";

/** A listen that failed because something already holds the address. */
export function isAddressInUse(error: unknown): boolean {
return (
Predicate.isTagged(error, "ServeError") &&
Predicate.hasProperty(error, "cause") &&
Predicate.hasProperty(error.cause, "code") &&
error.cause.code === "EADDRINUSE"
);
}

export function addressInUseMessage(port: number): string {
return `Port ${port} is already in use. Stop what is using it, or start ViewCode on another port with --port <number>.`;
}
37 changes: 37 additions & 0 deletions apps/server/src/cli/viewcodeAddressInUse.viewcode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// @effect-diagnostics nodeBuiltinImport:off - a read of this folder's own files, no Effect runtime.
import * as NodeFS from "node:fs";
import * as NodePath from "node:path";

import { describe, expect, it } from "vite-plus/test";

import { addressInUseMessage, isAddressInUse } from "./viewcodeAddressInUse.ts";

const read = (file: string) =>
NodeFS.readFileSync(NodePath.join(import.meta.dirname, file), "utf8");

describe("a busy port", () => {
it("recognizes only a listen that failed with EADDRINUSE", () => {
const busy = { _tag: "ServeError", cause: { code: "EADDRINUSE" } };
expect(isAddressInUse(busy)).toBe(true);
expect(isAddressInUse({ _tag: "ServeError", cause: { code: "EACCES" } })).toBe(false);
expect(isAddressInUse({ _tag: "OtherError", cause: { code: "EADDRINUSE" } })).toBe(false);
expect(isAddressInUse(new Error("EADDRINUSE"))).toBe(false);
});

it("names the port and how to choose another", () => {
expect(addressInUseMessage(3773)).toBe(
"Port 3773 is already in use. Stop what is using it, or start ViewCode on another port with --port <number>.",
);
});

it("stays wired into the server command and the default-port pick", () => {
const server = read("server.ts");
expect(server).toMatch(
/Effect\.catchIf\(\s*\(error\) => config\.listenPath === undefined && isAddressInUse\(error\)/,
);
expect(server).toContain("addressInUseMessage(config.port)");
expect(read("config.ts")).toContain(
"is in use, so ViewCode is using ${port}. Pass --port to choose one.",
);
});
});
14 changes: 12 additions & 2 deletions packages/shared/src/Net.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export class NetService extends Context.Service<NetService, NetServiceShape>()(
"@t3tools/shared/Net/NetService",
) {}

const PORT_SCAN_LENGTH = 20;

/** @public Service construction is part of the canonical Effect module API. */
export const make = () => {
/**
Expand Down Expand Up @@ -170,10 +172,18 @@ export const make = () => {
isPortAvailableOnLoopback,
hasListenerOnHost,
reserveLoopbackPort,
// ViewCode: the next free port up from the preferred one keeps a second server's
// address predictable (3773, then 3774); past a short scan, any free port.
findAvailablePort: (preferred) =>
Effect.gen(function* () {
if (preferred > 0 && (yield* isPortAvailableOnLoopback(preferred))) {
return preferred;
if (preferred > 0) {
for (
let port = preferred;
port < preferred + PORT_SCAN_LENGTH && port <= 65_535;
port++
) {
if (yield* isPortAvailableOnLoopback(port)) return port;
}
}
return yield* reserveLoopbackPort();
}),
Expand Down
63 changes: 63 additions & 0 deletions packages/shared/src/Net.viewcode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as NodeNet from "node:net";

import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";

import * as NetService from "./Net.ts";

const closeServer = (server: NodeNet.Server) =>
Effect.sync(() => {
try {
server.close();
} catch {}
});

const getPort = (server: NodeNet.Server): number => {
const address = server.address();
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 server = NodeNet.createServer();
let settled = false;

const settle = (effect: Effect.Effect<NodeNet.Server, NetService.NetError>) => {
if (settled) return;
settled = true;
resume(effect);
};

server.once("error", (cause) => {
settle(
Effect.fail(new NetService.NetError({ message: "Failed to open test server", cause })),
);
});

if (host) {
server.listen(0, host, () => settle(Effect.succeed(server)));
} else {
server.listen(0, () => settle(Effect.succeed(server)));
}

return closeServer(server);
});

it.layer(NetService.layer)("NetService (ViewCode)", (it) => {
it.effect("findAvailablePort moves to the next free port when the preferred one is busy", () =>
Effect.acquireUseRelease(
openServer("127.0.0.1"),
(server) =>
Effect.gen(function* () {
const net = yield* NetService.NetService;
const preferred = getPort(server);
const nextFree = yield* net.isPortAvailableOnLoopback(preferred + 1);

const resolved = yield* net.findAvailablePort(preferred);
assert.notEqual(resolved, preferred);
if (nextFree) assert.equal(resolved, preferred + 1);
}),
closeServer,
),
);
});
Loading