From ad03d34fd93aa3a6c325e14404a77d0a50c43822 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 00:31:56 +0000 Subject: [PATCH 1/5] test(server): managed service wildcard bind falls back to loopback on EADDRINUSE Red test for #25161. Exports layerHttpServer so the bind can be tested. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/server.test.ts | 74 ++++++++++++++++++++++++++++++++++ apps/server/src/server.ts | 2 +- 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/server.test.ts diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts new file mode 100644 index 0000000000000..3cc5a7cd7f8fb --- /dev/null +++ b/apps/server/src/server.test.ts @@ -0,0 +1,74 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import { HttpServer } from "effect/http"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; + +import * as ServerConfig from "./config.ts"; +import { layerHttpServer } from "./server.ts"; + +// Holds a port on a specific non-loopback address, so a wildcard bind on that +// port fails with EADDRINUSE while a loopback bind is still free. +const occupyNonLoopbackPort = Effect.acquireRelease( + Effect.callback((resume) => { + const address = Object.values(NodeOS.networkInterfaces()) + .flat() + .find((entry) => entry?.family === "IPv4" && !entry.internal)?.address; + if (address === undefined) throw new Error("no non-loopback IPv4 interface"); + const blocker = NodeNet.createServer(); + blocker.listen(0, address, () => { + const bound = blocker.address() as NodeNet.AddressInfo; + resume(Effect.succeed(Object.assign(blocker, { port: bound.port }))); + }); + }), + (blocker) => Effect.callback((resume) => void blocker.close(() => resume(Effect.void))), +); + +const httpServerWithConfig = ( + overrides: Pick, +) => + layerHttpServer.pipe( + Layer.provide( + Layer.effect( + ServerConfig.ServerConfig, + Effect.map(ServerConfig.ServerConfig, (config) => ({ ...config, ...overrides })), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-server-bind-test-" })), + ), + ), + Layer.provide(NodeServices.layer), + ); + +it.live("managed service falls back to loopback when its wildcard port is taken", () => + Effect.scoped( + Effect.gen(function* () { + const { port } = yield* occupyNonLoopbackPort; + const { address } = yield* HttpServer.HttpServer.pipe( + Effect.provide(httpServerWithConfig({ host: "0.0.0.0", port, privateNetworkOnly: true })), + ); + const listening = + address._tag === "InetAddressV4" + ? `${String(address.address)}:${address.port}` + : address._tag; + assert.strictEqual(listening, `127.0.0.1:${port}`); + }), + ), +); + +it.live("an explicit wildcard host still fails when its port is taken", () => + Effect.scoped( + Effect.gen(function* () { + const { port } = yield* occupyNonLoopbackPort; + const exit = yield* HttpServer.HttpServer.pipe( + Effect.provide( + httpServerWithConfig({ host: "0.0.0.0", port, privateNetworkOnly: undefined }), + ), + Effect.exit, + ); + assert.isTrue(Exit.isFailure(exit)); + }), + ), +); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ebedbdfa87302..41ed06c14913f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -292,7 +292,7 @@ const layerRelayClient = Layer.unwrap( }), ); -const layerHttpServer = Layer.unwrap( +export const layerHttpServer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; return NodeHttpServer.layer( From 6bc2b1a1d3ec2a4c208d0015482f5238c4803f21 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 00:39:35 +0000 Subject: [PATCH 2/5] fix(server): managed service falls back to loopback when its wildcard port is taken The managed background service binds 0.0.0.0 on its own, so a port held on one interface (EADDRINUSE) killed startup. Only for that implicit wildcard, retry on 127.0.0.1 with a warning and expose the bound host through ServerConfig, so pairing, direct endpoints and persisted runtime state advertise the address actually listening. Explicit hosts still fail. Fixes pingdotgg/t3code#25161 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/server.test.ts | 6 ++- apps/server/src/server.ts | 84 ++++++++++++++++++++++++---------- 2 files changed, 64 insertions(+), 26 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3cc5a7cd7f8fb..96a4dc8a897bb 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -46,9 +46,13 @@ it.live("managed service falls back to loopback when its wildcard port is taken" Effect.scoped( Effect.gen(function* () { const { port } = yield* occupyNonLoopbackPort; - const { address } = yield* HttpServer.HttpServer.pipe( + const { address, config } = yield* Effect.all({ + address: Effect.map(HttpServer.HttpServer, (server) => server.address), + config: ServerConfig.ServerConfig, + }).pipe( Effect.provide(httpServerWithConfig({ host: "0.0.0.0", port, privateNetworkOnly: true })), ); + assert.strictEqual(config.host, "127.0.0.1"); const listening = address._tag === "InetAddressV4" ? `${String(address.address)}:${address.port}` diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 41ed06c14913f..c956e84891cef 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -292,33 +292,67 @@ const layerRelayClient = Layer.unwrap( }), ); +const isAddressInUse = (cause: unknown) => + typeof cause === "object" && cause !== null && "code" in cause && cause.code === "EADDRINUSE"; + export 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 listen = (host: string) => + NodeHttpServer.layer( + () => { + const server = rejectRequestsUntilServing( + guardHttpResponseWriteErrors(NodeHttp.createServer()), + ); + if (config.privateNetworkOnly) { + server.on("connection", (socket) => { + if (!acceptsPrivateNetworkConnection(socket)) { + socket.destroy(); + } + }); + } + return server; + }, + { + 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 }, + }, + ); + const listening = Layer.merge( + listen(config.host ?? "127.0.0.1"), + Layer.succeed(ServerConfig.ServerConfig, config), + ); + if (!config.privateNetworkOnly) return listening; + // The managed service only chose the wildcard itself, so a port held on one + // interface should not cost the local server; layers above see the real host. + return listening.pipe( + Layer.catchTag("ServeError", (error) => + Layer.unwrap( + isAddressInUse(error.cause) + ? Effect.succeed( + Layer.mergeAll( + listen("127.0.0.1"), + Layer.succeed(ServerConfig.ServerConfig, { + ...config, + host: "127.0.0.1", + privateNetworkOnly: undefined, + }), + Layer.effectDiscard( + Effect.logWarning( + `Port ${config.port} is taken on another interface; listening on 127.0.0.1 only.`, + ), + ), + ), + ) + : Effect.fail(error), + ), + ), ); }), ); @@ -775,7 +809,7 @@ const layerMakeServer = Layer.unwrap( const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; const state = yield* makePersistedServerRuntimeState({ - config, + config: yield* ServerConfig.ServerConfig, port: address.port, serviceManaged: launcher.managed, }); From 7219262525979feb9b26c8c3385958270fea43a0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 00:44:07 +0000 Subject: [PATCH 3/5] fix(server): loopback fallback warns only once it is listening Co-Authored-By: Claude Opus 5.5 --- apps/server/src/server.test.ts | 6 +++++- apps/server/src/server.ts | 15 ++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 96a4dc8a897bb..184b9a51d2ba6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1,9 +1,10 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; -import { HttpServer } from "effect/http"; +import { HttpServer, HttpServerError } from "effect/http"; import * as NodeNet from "node:net"; import * as NodeOS from "node:os"; @@ -73,6 +74,9 @@ it.live("an explicit wildcard host still fails when its port is taken", () => Effect.exit, ); assert.isTrue(Exit.isFailure(exit)); + const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + assert.instanceOf(error, HttpServerError.ServeError); + assert.propertyVal(error.cause, "code", "EADDRINUSE"); }), ), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c956e84891cef..6baf82cf687bb 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -336,18 +336,19 @@ export const layerHttpServer = Layer.unwrap( Layer.unwrap( isAddressInUse(error.cause) ? Effect.succeed( - Layer.mergeAll( - listen("127.0.0.1"), + Layer.merge( + listen("127.0.0.1").pipe( + Layer.tap(() => + Effect.logWarning( + `Port ${config.port} is taken on another interface; listening on 127.0.0.1 only.`, + ), + ), + ), Layer.succeed(ServerConfig.ServerConfig, { ...config, host: "127.0.0.1", privateNetworkOnly: undefined, }), - Layer.effectDiscard( - Effect.logWarning( - `Port ${config.port} is taken on another interface; listening on 127.0.0.1 only.`, - ), - ), ), ) : Effect.fail(error), From 1a2815d383c736f09941715469d1fdcd255ea9a8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:10:39 +0000 Subject: [PATCH 4/5] test(server): provide NetService to the bind fallback tests layerHttpServer now runs the managed-policy Tailscale Serve check, which needs NetService. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/server.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 184b9a51d2ba6..350c09202e4ae 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NetService from "@t3tools/shared/Net"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -40,7 +41,7 @@ const httpServerWithConfig = ( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-server-bind-test-" })), ), ), - Layer.provide(NodeServices.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NetService.layer)), ); it.live("managed service falls back to loopback when its wildcard port is taken", () => From e10583f6aa4b3bbc630a64f969bec8ac952042dd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 02:21:43 +0000 Subject: [PATCH 5/5] fix(server): only an implicit service wildcard falls back to loopback --lan and T3CODE_LAN also set privateNetworkOnly, so the fallback could silently drop an explicit LAN request to loopback. Gate it on a loopbackFallback flag set only when the service picked the wildcard itself, and run the bind tests only where Linux refuses the shared wildcard bind. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/config.test.ts | 11 ++++- apps/server/src/cli/config.ts | 1 + apps/server/src/config.ts | 2 + apps/server/src/server.test.ts | 69 ++++++++++++++++++++---------- apps/server/src/server.ts | 7 +-- 5 files changed, 63 insertions(+), 27 deletions(-) diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 5e09dda2c164f..395e580ac30d6 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -919,7 +919,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { readonly env?: Record; }) => resolveServerConfig(minimalWebFlags(baseDir), Option.none()).pipe( - Effect.map(({ host, privateNetworkOnly }) => ({ host, privateNetworkOnly })), + Effect.map(({ host, privateNetworkOnly, loopbackFallback }) => ({ + host, + privateNetworkOnly, + loopbackFallback, + })), Effect.provide( Layer.mergeAll( ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), @@ -944,25 +948,30 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(yield* resolveHost({ connected: true })).toEqual({ host: "0.0.0.0", privateNetworkOnly: true, + loopbackFallback: true, }); // `service.env` reaches the server as its environment. expect(yield* resolveHost({ connected: true, env: { T3CODE_HOST: "127.0.0.1" } })).toEqual({ host: "127.0.0.1", privateNetworkOnly: undefined, + loopbackFallback: undefined, }); expect(yield* resolveHost({ connected: true, env: { T3CODE_LAN: "false" } })).toEqual({ host: undefined, privateNetworkOnly: undefined, + loopbackFallback: undefined, }); expect(yield* resolveHost({ connected: false, env: { T3CODE_LAN: "true" } })).toEqual({ host: "0.0.0.0", privateNetworkOnly: true, + loopbackFallback: undefined, }); // Without the launcher's IPC channel the context was inherited, e.g. by // `t3 serve` started from an agent inside the service. expect(yield* resolveHost({ connected: false })).toEqual({ host: undefined, privateNetworkOnly: undefined, + loopbackFallback: undefined, }); }), ); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 00aae0f3133b7..b994e62ed6365 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -505,6 +505,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct serverTracePath, host, ...(privateNetworkOnly ? { privateNetworkOnly } : {}), + ...(privateNetworkOnly && Option.isNone(lan) ? { loopbackFallback: true } : {}), staticDir, devUrl, ...(devAuthToken === undefined ? {} : { devAuthToken }), diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index d4883688c08ef..48a8e60374ea6 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -87,6 +87,8 @@ export class ServerConfig extends Context.Service< readonly host: string | undefined; /** Set when the host was defaulted to a private-network listener for the service. */ readonly privateNetworkOnly?: boolean | undefined; + /** The service picked that listener without `--lan`, so a taken port may fall back to loopback. */ + readonly loopbackFallback?: boolean | undefined; readonly cwd: string; readonly baseDir: string; readonly staticDir: string | undefined; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 350c09202e4ae..de0b11e28bd9c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; @@ -12,25 +13,33 @@ import * as NodeOS from "node:os"; import * as ServerConfig from "./config.ts"; import { layerHttpServer } from "./server.ts"; -// Holds a port on a specific non-loopback address, so a wildcard bind on that -// port fails with EADDRINUSE while a loopback bind is still free. -const occupyNonLoopbackPort = Effect.acquireRelease( - Effect.callback((resume) => { - const address = Object.values(NodeOS.networkInterfaces()) - .flat() - .find((entry) => entry?.family === "IPv4" && !entry.internal)?.address; - if (address === undefined) throw new Error("no non-loopback IPv4 interface"); - const blocker = NodeNet.createServer(); - blocker.listen(0, address, () => { - const bound = blocker.address() as NodeNet.AddressInfo; - resume(Effect.succeed(Object.assign(blocker, { port: bound.port }))); - }); - }), - (blocker) => Effect.callback((resume) => void blocker.close(() => resume(Effect.void))), -); +// Linux refuses a wildcard bind on a port one specific address holds; macOS +// lets it share the port under SO_REUSEADDR, so there is nothing to recover. +const nonLoopbackAddress = + HostProcess.Platform.defaultValue() === "linux" + ? Object.values(NodeOS.networkInterfaces()) + .flat() + .find((entry) => entry?.family === "IPv4" && !entry.internal)?.address + : undefined; + +const occupyNonLoopbackPort = (address: string) => + Effect.acquireRelease( + Effect.callback((resume) => { + const blocker = NodeNet.createServer(); + blocker.once("error", (error) => resume(Effect.die(error))); + blocker.listen(0, address, () => { + const bound = blocker.address() as NodeNet.AddressInfo; + resume(Effect.succeed(Object.assign(blocker, { port: bound.port }))); + }); + }), + (blocker) => Effect.callback((resume) => void blocker.close(() => resume(Effect.void))), + ); const httpServerWithConfig = ( - overrides: Pick, + overrides: Pick< + ServerConfig.ServerConfig["Service"], + "host" | "port" | "privateNetworkOnly" | "loopbackFallback" + >, ) => layerHttpServer.pipe( Layer.provide( @@ -44,15 +53,24 @@ const httpServerWithConfig = ( Layer.provide(Layer.mergeAll(NodeServices.layer, NetService.layer)), ); -it.live("managed service falls back to loopback when its wildcard port is taken", () => +const live = it.live.skipIf(nonLoopbackAddress === undefined); + +live("managed service falls back to loopback when its wildcard port is taken", () => Effect.scoped( Effect.gen(function* () { - const { port } = yield* occupyNonLoopbackPort; + const { port } = yield* occupyNonLoopbackPort(nonLoopbackAddress!); const { address, config } = yield* Effect.all({ address: Effect.map(HttpServer.HttpServer, (server) => server.address), config: ServerConfig.ServerConfig, }).pipe( - Effect.provide(httpServerWithConfig({ host: "0.0.0.0", port, privateNetworkOnly: true })), + Effect.provide( + httpServerWithConfig({ + host: "0.0.0.0", + port, + privateNetworkOnly: true, + loopbackFallback: true, + }), + ), ); assert.strictEqual(config.host, "127.0.0.1"); const listening = @@ -64,13 +82,18 @@ it.live("managed service falls back to loopback when its wildcard port is taken" ), ); -it.live("an explicit wildcard host still fails when its port is taken", () => +live("--lan still fails when its wildcard port is taken", () => Effect.scoped( Effect.gen(function* () { - const { port } = yield* occupyNonLoopbackPort; + const { port } = yield* occupyNonLoopbackPort(nonLoopbackAddress!); const exit = yield* HttpServer.HttpServer.pipe( Effect.provide( - httpServerWithConfig({ host: "0.0.0.0", port, privateNetworkOnly: undefined }), + httpServerWithConfig({ + host: "0.0.0.0", + port, + privateNetworkOnly: true, + loopbackFallback: undefined, + }), ), Effect.exit, ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index cd17a1a9274c3..1bf8b9ba24521 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -382,9 +382,9 @@ export const layerHttpServer = Layer.unwrap( listen(bindHost), Layer.succeed(ServerConfig.ServerConfig, config), ); - if (!config.privateNetworkOnly) return listening; - // The managed service only chose the wildcard itself, so a port held on one - // interface should not cost the local server; layers above see the real host. + if (!config.loopbackFallback) return listening; + // The service chose the wildcard on its own, so a port held on one interface + // (e.g. by Tailscale Serve) must not cost the local server; layers above see the real host. return listening.pipe( Layer.catchTag("ServeError", (error) => Layer.unwrap( @@ -402,6 +402,7 @@ export const layerHttpServer = Layer.unwrap( ...config, host: "127.0.0.1", privateNetworkOnly: undefined, + loopbackFallback: undefined, }), ), )