Skip to content
Closed
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
91 changes: 91 additions & 0 deletions apps/server/src/httpListener.test.ts
Original file line number Diff line number Diff line change
@@ -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<NodeNet.Server>((resume) => {
const server = NodeNet.createServer();
server.once("error", (error) => resume(Effect.die(error)));
server.listen(0, host, () => resume(Effect.succeed(server)));
}),
(server) => Effect.callback<void>((resume) => void server.close(() => resume(Effect.void))),
).pipe(Effect.map((server) => (server.address() as NodeNet.AddressInfo).port));

const listen = (
overrides: Pick<ServerConfig.ServerConfig["Service"], "host" | "port"> & {
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),
);
});
79 changes: 79 additions & 0 deletions apps/server/src/httpListener.ts
Original file line number Diff line number Diff line change
@@ -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),
),
),
);
}),
);
49 changes: 5 additions & 44 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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";
Expand All @@ -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),
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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),
Expand Down
Loading