Repository navigation
fix(server): recover loopback binding when the service port is shared #25174
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ad03d34
6bc2b1a
6d2938c
7219262
0756e09
1a2815d
48cf651
e10583f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| 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"; | ||
| import * as Exit from "effect/Exit"; | ||
| import * as Layer from "effect/Layer"; | ||
| import { HttpServer, HttpServerError } 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"; | ||
|
|
||
| // 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<NodeNet.Server & { readonly port: number }>((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<void>((resume) => void blocker.close(() => resume(Effect.void))), | ||
| ); | ||
|
|
||
| const httpServerWithConfig = ( | ||
| overrides: Pick< | ||
| ServerConfig.ServerConfig["Service"], | ||
| "host" | "port" | "privateNetworkOnly" | "loopbackFallback" | ||
| >, | ||
| ) => | ||
| 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(Layer.mergeAll(NodeServices.layer, NetService.layer)), | ||
| ); | ||
|
|
||
| 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(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, | ||
| loopbackFallback: true, | ||
| }), | ||
| ), | ||
| ); | ||
| assert.strictEqual(config.host, "127.0.0.1"); | ||
| const listening = | ||
| address._tag === "InetAddressV4" | ||
| ? `${String(address.address)}:${address.port}` | ||
| : address._tag; | ||
| assert.strictEqual(listening, `127.0.0.1:${port}`); | ||
| }), | ||
| ), | ||
| ); | ||
|
|
||
| live("--lan still fails when its wildcard port is taken", () => | ||
| Effect.scoped( | ||
| Effect.gen(function* () { | ||
| const { port } = yield* occupyNonLoopbackPort(nonLoopbackAddress!); | ||
| const exit = yield* HttpServer.HttpServer.pipe( | ||
| Effect.provide( | ||
| httpServerWithConfig({ | ||
| host: "0.0.0.0", | ||
| port, | ||
| privateNetworkOnly: true, | ||
| loopbackFallback: undefined, | ||
| }), | ||
| ), | ||
| 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"); | ||
| }), | ||
| ), | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -337,7 +337,10 @@ const layerRelayClient = Layer.unwrap( | |
| }), | ||
| ).pipe(Layer.provide(ManagedPolicy.layer)); | ||
|
|
||
| const layerHttpServer = 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; | ||
| // Before binding: a host the policy does not allow must never listen, not even briefly. | ||
|
|
@@ -349,30 +352,63 @@ const layerHttpServer = Layer.unwrap( | |
| privateNetworkOnly: config.privateNetworkOnly, | ||
| }); | ||
| yield* ManagedLimits.closeTailscaleServeTo(policy, { host: bindHost, port: config.port }); | ||
| return NodeHttpServer.layer( | ||
| () => { | ||
| const server = rejectRequestsUntilServing( | ||
| guardHttpResponseWriteErrors(NodeHttp.createServer()), | ||
| ); | ||
| if (config.privateNetworkOnly) { | ||
| server.on("connection", (socket) => { | ||
| if (!acceptsPrivateNetworkConnection(socket)) { | ||
| socket.destroy(); | ||
| } | ||
| }); | ||
| } | ||
| return server; | ||
| }, | ||
| { | ||
| host: bindHost, | ||
| 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(bindHost), | ||
| Layer.succeed(ServerConfig.ServerConfig, config), | ||
| ); | ||
| 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( | ||
| isAddressInUse(error.cause) | ||
| ? Effect.succeed( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Critical On 🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| 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, | ||
| loopbackFallback: undefined, | ||
| }), | ||
| ), | ||
| ) | ||
| : Effect.fail(error), | ||
| ), | ||
| ), | ||
| ); | ||
| }), | ||
| ).pipe(Layer.provide(ManagedPolicy.layer)); | ||
|
|
@@ -884,7 +920,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, | ||
| managedPolicy: yield* ManagedPolicy.loadedPolicy(yield* ManagedPolicy.ManagedPolicy), | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔎 Supported by static analysis
Script executed:
Repository: pingdotgg/t3code
Length of output: 3195
Replace the hand-written shape check in
isAddressInUsewithPredicate.hasProperty.isAddressInUsenarrows anunknownvalue withtypeof,null, andinchecks. Theapps/server/**guideline says: "NEVER write your own helper functions likeisRecordorisString, instead use the helpers from thePredicatemodule." UsePredicate.hasPropertyfor the shape check. Keep theEADDRINUSEcomparison.♻️ Proposed refactor
Add
import * as Predicate from "effect/Predicate";to the file imports ifapps/server/src/server.tsdoes not already import it.