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),