From d1cb88e7468f49e6274cbbc7c6f488f0a9a2e47b Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Mon, 26 Oct 2026 00:40:47 +0000 Subject: [PATCH] fix(server): service falls back to loopback when its port is held on another address A managed service with no explicit host listens on 0.0.0.0. When another process such as Tailscale Serve already holds the same port on one specific address, that wildcard bind fails with EADDRINUSE and a desktop-triggered update rolls back, even though 127.0.0.1 on that port is free. The HTTP listener now moves into its own layer. Only for the implicit private-network wildcard, an EADDRINUSE retries 127.0.0.1 on the same port with a warning, and the layer re-provides ServerConfig with the host it actually bound so runtime state, pairing, direct endpoints, and auth policy never advertise the failed LAN listener. Explicit hosts, other errors, and an occupied loopback still fail. Fixes #25161 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/httpListener.test.ts | 91 ++++++++++++++++++++++++++++ apps/server/src/httpListener.ts | 79 ++++++++++++++++++++++++ apps/server/src/server.ts | 49 ++------------- 3 files changed, 175 insertions(+), 44 deletions(-) create mode 100644 apps/server/src/httpListener.test.ts create mode 100644 apps/server/src/httpListener.ts diff --git a/apps/server/src/httpListener.test.ts b/apps/server/src/httpListener.test.ts new file mode 100644 index 0000000000000..8740737536c22 --- /dev/null +++ b/apps/server/src/httpListener.test.ts @@ -0,0 +1,91 @@ +// Real sockets stand in for another process holding the port on one address. +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeNet from "node:net"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { assert, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpServer } from "effect/http"; +import * as NetAddress from "effect/net/NetAddress"; + +import * as ServerConfig from "./config.ts"; +import * as HttpListener from "./httpListener.ts"; + +// Holding 127.0.0.2 needs Linux's whole-127/8 loopback, and the wildcard +// conflict below is Linux bind semantics; other platforms allow or refuse differently. +const notLinux = HostProcess.Platform.defaultValue() !== "linux"; + +const holdPort = (host: string) => + Effect.acquireRelease( + Effect.callback((resume) => { + const server = NodeNet.createServer(); + server.once("error", (error) => resume(Effect.die(error))); + server.listen(0, host, () => resume(Effect.succeed(server))); + }), + (server) => Effect.callback((resume) => void server.close(() => resume(Effect.void))), + ).pipe(Effect.map((server) => (server.address() as NodeNet.AddressInfo).port)); + +const listen = ( + overrides: Pick & { + readonly privateNetworkOnly?: boolean; + }, +) => + Effect.gen(function* () { + const base = yield* ServerConfig.ServerConfig; + const context = yield* Layer.build( + HttpListener.layer.pipe(Layer.provide(ServerConfig.layer({ ...base, ...overrides }))), + ); + const config = Context.get(context, ServerConfig.ServerConfig); + return { + address: NetAddress.formatSocketAddress(Context.get(context, HttpServer.HttpServer).address), + host: config.host, + privateNetworkOnly: config.privateNetworkOnly, + }; + }); + +const addressInUse = (error: { readonly cause: unknown }) => + (error.cause as NodeJS.ErrnoException).code === "EADDRINUSE"; + +it.layer( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-http-listener-" }).pipe( + Layer.provideMerge(NodeServices.layer), + ), +)("HttpListener.layer", (it) => { + it.effect("keeps the private-network wildcard listener when the port is free", () => + Effect.gen(function* () { + const bound = yield* listen({ host: "0.0.0.0", port: 0, privateNetworkOnly: true }); + assert.match(bound.address, /^0\.0\.0\.0:\d+$/); + assert.strictEqual(bound.host, "0.0.0.0"); + assert.strictEqual(bound.privateNetworkOnly, true); + }).pipe(Effect.scoped), + ); + + it.effect.skipIf(notLinux)("falls back to loopback when another address holds the port", () => + Effect.gen(function* () { + const port = yield* holdPort("127.0.0.2"); + const bound = yield* listen({ host: "0.0.0.0", port, privateNetworkOnly: true }); + assert.strictEqual(bound.address, `127.0.0.1:${port}`); + assert.strictEqual(bound.host, "127.0.0.1"); + assert.strictEqual(bound.privateNetworkOnly, false); + }).pipe(Effect.scoped), + ); + + it.effect.skipIf(notLinux)("fails an explicit wildcard host instead of falling back", () => + Effect.gen(function* () { + const port = yield* holdPort("127.0.0.2"); + const error = yield* Effect.flip(listen({ host: "0.0.0.0", port })); + assert.isTrue(addressInUse(error)); + }).pipe(Effect.scoped), + ); + + it.effect.skipIf(notLinux)("fails when loopback holds the port too", () => + Effect.gen(function* () { + const port = yield* holdPort("127.0.0.1"); + const error = yield* Effect.flip(listen({ host: "0.0.0.0", port, privateNetworkOnly: true })); + assert.isTrue(addressInUse(error)); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/httpListener.ts b/apps/server/src/httpListener.ts new file mode 100644 index 0000000000000..6acb56d32c291 --- /dev/null +++ b/apps/server/src/httpListener.ts @@ -0,0 +1,79 @@ +// Effect wraps this raw Node HTTP server after its startup and write-error guards attach. +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeHttp from "node:http"; + +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type { ServeError } from "effect/http/HttpServerError"; + +import * as ServerConfig from "./config.ts"; +import { + guardHttpResponseWriteErrors, + rejectRequestsUntilServing, +} from "./httpResponseErrorGuard.ts"; +import { acceptsPrivateNetworkConnection } from "./startupAccess.ts"; + +// Skip Effect's default 20 s grace; WebSocket scope finalizers already close connections gracefully. +const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; +const LOOPBACK_HOST = "127.0.0.1"; + +const isAddressInUse = (error: ServeError) => + typeof error.cause === "object" && + error.cause !== null && + "code" in error.cause && + error.cause.code === "EADDRINUSE"; + +const listen = (config: ServerConfig.ServerConfig["Service"]) => + NodeHttpServer.layer( + () => { + const server = rejectRequestsUntilServing( + guardHttpResponseWriteErrors(NodeHttp.createServer()), + ); + if (config.privateNetworkOnly) { + server.on("connection", (socket) => { + if (!acceptsPrivateNetworkConnection(socket)) { + socket.destroy(); + } + }); + } + return server; + }, + { + host: config.host ?? LOOPBACK_HOST, + port: config.port, + gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, + // Context takeover stays enabled (ws default) so the compression + // window is shared across frames — that also makes small frames cheap + // to compress, so no size threshold is set (ws only honors + // `threshold` when context takeover is disabled). + websocket: { perMessageDeflate: true }, + }, + ).pipe(Layer.merge(ServerConfig.layer(config))); + +/** + * Re-provides `ServerConfig` with the host actually bound, so advertised + * endpoints, pairing, and auth policy never describe a listener that failed. + */ +export const layer = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + if (!config.privateNetworkOnly) return listen(config); + // Another process (Tailscale Serve, for one) can hold this port on one + // specific address, which blocks the wildcard bind but not loopback. + return listen(config).pipe( + Layer.catchTag("ServeError", (error) => + Layer.unwrap( + isAddressInUse(error) + ? Effect.logWarning( + `Port ${config.port} is already in use on another address, so the server listens on ${LOOPBACK_HOST} only and other devices cannot reach it directly. Set T3CODE_HOST to choose an address.`, + { cause: error.cause }, + ).pipe( + Effect.as(listen({ ...config, host: LOOPBACK_HOST, privateNetworkOnly: false })), + ) + : Effect.fail(error), + ), + ), + ); + }), +); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ebedbdfa87302..ac5ab1449429d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -6,11 +6,7 @@ import * as StorageCleanup from "./storageCleanup.ts"; import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; import * as PullRequestWatchReactor from "./orchestration-v2/PullRequestWatchReactor.ts"; import * as DelegatedTaskQuestionReactor from "./orchestration-v2/DelegatedTaskQuestionReactor.ts"; -// Effect wraps this raw Node HTTP server after its startup and write-error guards attach. -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeHttp from "node:http"; -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentHttpApi, type RepositoryIdentity } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -28,10 +24,7 @@ import * as HostPowerMonitor from "./background/HostPowerMonitor.ts"; import * as ServerConfig from "./config.ts"; import { withUntracedRequests } from "./http.ts"; import * as ServerHttp from "./http.ts"; -import { - guardHttpResponseWriteErrors, - rejectRequestsUntilServing, -} from "./httpResponseErrorGuard.ts"; +import * as HttpListener from "./httpListener.ts"; import { fixPath } from "./os-jank.ts"; import * as Ws from "./ws.ts"; import * as AgentScopeLive from "./process/agentScope.ts"; @@ -187,7 +180,7 @@ import * as EventStoreV2 from "./orchestration-v2/EventStore.ts"; import * as ProjectionMaintenance from "./orchestration-v2/ProjectionMaintenance.ts"; import * as ProjectionStoreV2 from "./orchestration-v2/ProjectionStore.ts"; import { makePersistedServerRuntimeState } from "./serverRuntimeState.ts"; -import { acceptsPrivateNetworkConnection, loopbackUrlHostForAddress } from "./startupAccess.ts"; +import { loopbackUrlHostForAddress } from "./startupAccess.ts"; import * as OrchestrationHttp from "./orchestration-v2/http.ts"; import * as ProjectHttp from "./project/http.ts"; import * as NetService from "@t3tools/shared/Net"; @@ -200,8 +193,6 @@ const HTTP_ROUTER_CONFIG = { maxParamLength: 512, } as const; -// Skip Effect's default 20 s grace; WebSocket scope finalizers already close connections gracefully. -const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; const layerResourceAttribution = ResourceAttribution.layer; const layerApplicationObservability = EventLoopMonitor.layer.pipe( Layer.provideMerge(Observability.layer), @@ -292,37 +283,6 @@ const layerRelayClient = Layer.unwrap( }), ); -const layerHttpServer = Layer.unwrap( - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - return NodeHttpServer.layer( - () => { - const server = rejectRequestsUntilServing( - guardHttpResponseWriteErrors(NodeHttp.createServer()), - ); - if (config.privateNetworkOnly) { - server.on("connection", (socket) => { - if (!acceptsPrivateNetworkConnection(socket)) { - socket.destroy(); - } - }); - } - return server; - }, - { - host: config.host ?? "127.0.0.1", - port: config.port, - gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, - // Context takeover stays enabled (ws default) so the compression - // window is shared across frames — that also makes small frames cheap - // to compress, so no size threshold is set (ws only honors - // `threshold` when context takeover is disabled). - websocket: { perMessageDeflate: true }, - }, - ); - }), -); - const layerPlatformServices = NodeServices.layer; const layerPersistence = Layer.empty.pipe(Layer.provideMerge(SqlitePersistence.layerConfig)); @@ -775,7 +735,8 @@ const layerMakeServer = Layer.unwrap( const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; const state = yield* makePersistedServerRuntimeState({ - config, + // The listener re-provides the config with the host it actually bound. + config: yield* ServerConfig.ServerConfig, port: address.port, serviceManaged: launcher.managed, }); @@ -1062,7 +1023,7 @@ const layerMakeServer = Layer.unwrap( ), Layer.provide(layerActivation), Layer.provideMerge(RelayTracing.layerServerRelayBroker), - Layer.provideMerge(layerHttpServer), + Layer.provideMerge(HttpListener.layer), Layer.provide(layerApplicationObservability), Layer.provideMerge(FetchHttpClient.layer), Layer.provide(VcsProcess.layer),