diff --git a/apps/server/src/portInUse.test.ts b/apps/server/src/portInUse.test.ts new file mode 100644 index 000000000000..2f45440e052a --- /dev/null +++ b/apps/server/src/portInUse.test.ts @@ -0,0 +1,150 @@ +// @effect-diagnostics nodeBuiltinImport:off - the bind failure is only real against an occupied TCP port. +import * as NodeHttp from "node:http"; +import * as NodeNet from "node:net"; + +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ServeError } from "effect/unstable/http/HttpServerError"; + +import * as ServerConfig from "./config.ts"; +import { explainPortInUse } from "./portInUse.ts"; +import { persistServerRuntimeState } from "./serverRuntimeState.ts"; + +const TestLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-port-in-use-" }).pipe( + Layer.provideMerge(NodeServices.layer), +); + +/** Holds a loopback port for the duration of the test scope. */ +const occupyLoopbackPort = Effect.acquireRelease( + Effect.callback((resume) => { + const server = NodeNet.createServer(); + server.listen(0, "127.0.0.1", () => { + resume(Effect.succeed(server)); + }); + }), + (server) => + Effect.callback((resume) => { + server.close(() => { + resume(Effect.void); + }); + }), +).pipe(Effect.map((server) => (server.address() as NodeNet.AddressInfo).port)); + +const bindHttpServer = (port: number) => + Layer.launch(NodeHttpServer.layer(() => NodeHttp.createServer(), { host: "127.0.0.1", port })); + +const configForPort = (port: number) => + Effect.map(ServerConfig.ServerConfig, (config) => ServerConfig.make({ ...config, port })); + +it.effect("names the T3 server holding the port when server-runtime.json agrees", () => + Effect.gen(function* () { + const port = yield* occupyLoopbackPort; + const config = yield* configForPort(port); + yield* persistServerRuntimeState({ + path: config.serverRuntimeStatePath, + state: { + version: 1, + pid: 424242, + port, + origin: `http://127.0.0.1:${port}`, + startedAt: "2026-08-20T13:29:55.900Z", + }, + }); + + const error = yield* explainPortInUse(bindHttpServer(port)).pipe( + Effect.provideService(ServerConfig.ServerConfig, config), + Effect.flip, + ); + + if (error._tag !== "PortInUseError") { + return assert.fail(`Expected PortInUseError, got ${error._tag}`); + } + assert.strictEqual(error.holderPid, 424242); + assert.strictEqual( + error.message, + `Port ${port} on 127.0.0.1 is already in use by a running T3 Code server (pid 424242 per server-runtime.json). Stop that server first; 't3 service status' finds it when it is the background service. If that pid is not actually a T3 server (stale descriptor, reused pid), delete '${config.serverRuntimeStatePath}' and retry.`, + ); + }).pipe(Effect.scoped, Effect.provide(TestLayer)), +); + +it.effect("names only the port when no server-runtime.json exists", () => + Effect.gen(function* () { + const port = yield* occupyLoopbackPort; + const config = yield* configForPort(port); + + const error = yield* explainPortInUse(bindHttpServer(port)).pipe( + Effect.provideService(ServerConfig.ServerConfig, config), + Effect.flip, + ); + + assert.strictEqual(error._tag, "PortInUseError"); + assert.strictEqual( + error.message, + `Port ${port} on 127.0.0.1 is already in use. Stop whatever is listening there, or start T3 Code on another port with --port; 't3 service status' says whether the T3 Code background service is holding it.`, + ); + }).pipe(Effect.scoped, Effect.provide(TestLayer)), +); + +it.effect("claims no culprit when server-runtime.json describes another port", () => + Effect.gen(function* () { + const port = yield* occupyLoopbackPort; + const config = yield* configForPort(port); + yield* persistServerRuntimeState({ + path: config.serverRuntimeStatePath, + state: { + version: 1, + pid: 424242, + port: port + 1, + origin: `http://127.0.0.1:${port + 1}`, + startedAt: "2026-08-20T13:29:55.900Z", + }, + }); + + const error = yield* explainPortInUse(bindHttpServer(port)).pipe( + Effect.provideService(ServerConfig.ServerConfig, config), + Effect.flip, + ); + + assert.strictEqual(error._tag, "PortInUseError"); + assert.notInclude(error.message, "424242"); + assert.include(error.message, `Port ${port} on 127.0.0.1 is already in use.`); + }).pipe(Effect.scoped, Effect.provide(TestLayer)), +); + +it.effect("explains the EADDRINUSE defect Bun throws instead of failing", () => + Effect.gen(function* () { + const config = yield* configForPort(3773); + const defect = Object.assign(new Error("Failed to start server. Is port 3773 in use?"), { + code: "EADDRINUSE", + }); + + const error = yield* explainPortInUse(Effect.die(defect)).pipe( + Effect.provideService(ServerConfig.ServerConfig, config), + Effect.flip, + ); + + assert.strictEqual(error._tag, "PortInUseError"); + assert.include(error.message, "Port 3773 on 127.0.0.1 is already in use."); + }).pipe(Effect.scoped, Effect.provide(TestLayer)), +); + +it.effect("leaves other bind failures alone", () => + Effect.gen(function* () { + const config = yield* configForPort(80); + const serveError = new ServeError({ + cause: Object.assign(new Error("listen EACCES: permission denied 0.0.0.0:80"), { + code: "EACCES", + }), + }); + + const error = yield* explainPortInUse(Effect.fail(serveError)).pipe( + Effect.provideService(ServerConfig.ServerConfig, config), + Effect.flip, + ); + + assert.strictEqual(error, serveError); + }).pipe(Effect.scoped, Effect.provide(TestLayer)), +); diff --git a/apps/server/src/portInUse.ts b/apps/server/src/portInUse.ts new file mode 100644 index 000000000000..005400620f4a --- /dev/null +++ b/apps/server/src/portInUse.ts @@ -0,0 +1,79 @@ +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +import * as ServerConfig from "./config.ts"; +import { readPersistedServerRuntimeState } from "./serverRuntimeState.ts"; + +/** + * Raised instead of the platform's `ServeError` when the HTTP server cannot + * bind. `holderPid` is only set when server-runtime.json names the same port, + * which is as close to proof of a culprit as we get: the descriptor can be + * stale or carry a reused pid, so the message says how to check. + */ +export class PortInUseError extends Schema.TaggedErrorClass()("PortInUseError", { + host: Schema.String, + port: Schema.Int, + holderPid: Schema.optional(Schema.Int), + serverRuntimeStatePath: Schema.String, + cause: Schema.Defect(), +}) { + override get message(): string { + const address = `Port ${this.port} on ${this.host} is already in use`; + if (this.holderPid === undefined) { + return `${address}. Stop whatever is listening there, or start T3 Code on another port with --port; 't3 service status' says whether the T3 Code background service is holding it.`; + } + return `${address} by a running T3 Code server (pid ${this.holderPid} per server-runtime.json). Stop that server first; 't3 service status' finds it when it is the background service. If that pid is not actually a T3 server (stale descriptor, reused pid), delete '${this.serverRuntimeStatePath}' and retry.`; + } +} + +/** + * The bind failure reaches us wrapped: `@effect/platform-node` puts the errno + * error inside a `ServeError`, Bun throws it as a defect. Both keep the + * original under `cause`, so walk that chain. The depth cap is there because + * nothing forbids a cyclic `cause`. + */ +const isAddressInUse = (value: unknown): boolean => { + let current: unknown = value; + for (let depth = 0; depth < 4 && Predicate.isObject(current); depth++) { + if (Predicate.hasProperty(current, "code") && current.code === "EADDRINUSE") { + return true; + } + current = Predicate.hasProperty(current, "cause") ? current.cause : undefined; + } + return false; +}; + +const reportsAddressInUse = (cause: Cause.Cause): boolean => + cause.reasons.some((reason) => + Cause.isFailReason(reason) + ? isAddressInUse(reason.error) + : Cause.isDieReason(reason) && isAddressInUse(reason.defect), + ); + +/** + * Replaces an EADDRINUSE bind failure with a message that names the port and, + * when server-runtime.json corroborates it, the process holding it. Every + * other failure passes through untouched. + */ +export const explainPortInUse = (effect: Effect.Effect) => + Effect.catchCauseIf(effect, reportsAddressInUse, (cause) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const persisted = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + const holderPid = + Option.isSome(persisted) && persisted.value.port === config.port + ? persisted.value.pid + : undefined; + + return yield* new PortInUseError({ + host: config.host ?? "127.0.0.1", + port: config.port, + ...(holderPid === undefined ? {} : { holderPid }), + serverRuntimeStatePath: config.serverRuntimeStatePath, + cause: Cause.squash(cause), + }); + }), + ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3e41b4390f82..8227a41f8a92 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -20,6 +20,7 @@ import { } from "./http.ts"; import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; import { fixPath } from "./os-jank.ts"; +import { explainPortInUse } from "./portInUse.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; @@ -685,5 +686,8 @@ export const makeServerLayer = Layer.unwrap( }), ); -// The CLI supplies configuration. -export const runServer = Layer.launch(makeServerLayer); +// The CLI supplies configuration. The bind happens while `HttpServerLive` +// builds, so `Layer.launch` is the first point where that failure is an +// ordinary Effect error again; catching on the layer itself would erase its +// `HttpServer` output type. +export const runServer = Layer.launch(makeServerLayer).pipe(explainPortInUse);