Skip to content
Open
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
150 changes: 150 additions & 0 deletions apps/server/src/portInUse.test.ts
Original file line number Diff line number Diff line change
@@ -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<NodeNet.Server>((resume) => {
const server = NodeNet.createServer();
server.listen(0, "127.0.0.1", () => {
resume(Effect.succeed(server));
});
}),
(server) =>
Effect.callback<void>((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)),
);
79 changes: 79 additions & 0 deletions apps/server/src/portInUse.ts
Original file line number Diff line number Diff line change
@@ -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>()("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 = <E>(cause: Cause.Cause<E>): 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 = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
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),
});
}),
);
8 changes: 6 additions & 2 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Loading