diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 554b467f81..5c34ab18ba 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -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.`, + ), + ), + ); }, }, ); diff --git a/apps/server/src/cli/server.ts b/apps/server/src/cli/server.ts index e43f90d9bc..9da2fbb7cd 100644 --- a/apps/server/src/cli/server.ts +++ b/apps/server/src/cli/server.ts @@ -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)); @@ -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), ); diff --git a/apps/server/src/cli/viewcodeAddressInUse.ts b/apps/server/src/cli/viewcodeAddressInUse.ts new file mode 100644 index 0000000000..3cf4cdc797 --- /dev/null +++ b/apps/server/src/cli/viewcodeAddressInUse.ts @@ -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 .`; +} diff --git a/apps/server/src/cli/viewcodeAddressInUse.viewcode.test.ts b/apps/server/src/cli/viewcodeAddressInUse.viewcode.test.ts new file mode 100644 index 0000000000..b7a8963acb --- /dev/null +++ b/apps/server/src/cli/viewcodeAddressInUse.viewcode.test.ts @@ -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 .", + ); + }); + + 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.", + ); + }); +}); diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index db43939c27..d9cc817b46 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -42,6 +42,8 @@ export class NetService extends Context.Service()( "@t3tools/shared/Net/NetService", ) {} +const PORT_SCAN_LENGTH = 20; + /** @public Service construction is part of the canonical Effect module API. */ export const make = () => { /** @@ -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(); }), diff --git a/packages/shared/src/Net.viewcode.test.ts b/packages/shared/src/Net.viewcode.test.ts new file mode 100644 index 0000000000..b185b810fb --- /dev/null +++ b/packages/shared/src/Net.viewcode.test.ts @@ -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 => + Effect.callback((resume) => { + const server = NodeNet.createServer(); + let settled = false; + + const settle = (effect: Effect.Effect) => { + 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, + ), + ); +});