authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-10 16:33:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-10 16:33:25-07:00
log4ae9de311b4627f6cf0ce00a67172cb9a3fb7c1d
tree107ea51960b81cb914f99baeab581657730910ee
parent5fa09fa2d9886a535b1629ce58a9ae88fc912f0c
signature Signed by PGP key B5690EEEBB952194

Stabilize auth session cookies per server mode (#1898)


11 files changed, 212 insertions(+), 22 deletions(-)

apps/desktop/src/backendPort.test.ts+54-1
...@@ -36,6 +36,57 @@ describe("resolveDesktopBackendPort", () => {...@@ -36,6 +36,57 @@ describe("resolveDesktopBackendPort", () => {
36 ]);36 ]);
37 });37 });
3838
39 it("treats wildcard-bound ports as unavailable even when loopback probing succeeds", async () => {
40 const canListenOnHost = vi.fn(async (port: number, host: string) => {
41 if (port === 3773 && host === "127.0.0.1") return true;
42 if (port === 3773 && host === "0.0.0.0") return false;
43 return port === 3774;
44 });
45
46 await expect(
47 resolveDesktopBackendPort({
48 host: "127.0.0.1",
49 requiredHosts: ["0.0.0.0"],
50 startPort: 3773,
51 canListenOnHost,
52 }),
53 ).resolves.toBe(3774);
54
55 expect(canListenOnHost.mock.calls).toEqual([
56 [3773, "127.0.0.1"],
57 [3773, "0.0.0.0"],
58 [3774, "127.0.0.1"],
59 [3774, "0.0.0.0"],
60 ]);
61 });
62
63 it("checks overlapping hosts sequentially to avoid self-interference", async () => {
64 let inFlightCount = 0;
65 const canListenOnHost = vi.fn(async (_port: number, _host: string) => {
66 inFlightCount += 1;
67 const overlapped = inFlightCount > 1;
68 await Promise.resolve();
69 inFlightCount -= 1;
70 return !overlapped;
71 });
72
73 await expect(
74 resolveDesktopBackendPort({
75 host: "127.0.0.1",
76 requiredHosts: ["0.0.0.0", "::"],
77 startPort: 3773,
78 maxPort: 3773,
79 canListenOnHost,
80 }),
81 ).resolves.toBe(3773);
82
83 expect(canListenOnHost.mock.calls).toEqual([
84 [3773, "127.0.0.1"],
85 [3773, "0.0.0.0"],
86 [3773, "::"],
87 ]);
88 });
89
39 it("fails when the scan range is exhausted", async () => {90 it("fails when the scan range is exhausted", async () => {
40 const canListenOnHost = vi.fn(async () => false);91 const canListenOnHost = vi.fn(async () => false);
4192
...@@ -46,7 +97,9 @@ describe("resolveDesktopBackendPort", () => {...@@ -46,7 +97,9 @@ describe("resolveDesktopBackendPort", () => {
46 maxPort: 65535,97 maxPort: 65535,
47 canListenOnHost,98 canListenOnHost,
48 }),99 }),
49 ).rejects.toThrow("No desktop backend port is available on 127.0.0.1 between 65534 and 65535");100 ).rejects.toThrow(
101 "No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535",
102 );
50103
51 expect(canListenOnHost.mock.calls).toEqual([104 expect(canListenOnHost.mock.calls).toEqual([
52 [65534, "127.0.0.1"],105 [65534, "127.0.0.1"],
apps/desktop/src/backendPort.ts+32-2
...@@ -8,6 +8,7 @@ export interface ResolveDesktopBackendPortOptions {...@@ -8,6 +8,7 @@ export interface ResolveDesktopBackendPortOptions {
8 readonly host: string;8 readonly host: string;
9 readonly startPort?: number;9 readonly startPort?: number;
10 readonly maxPort?: number;10 readonly maxPort?: number;
11 readonly requiredHosts?: ReadonlyArray<string>;
11 readonly canListenOnHost?: (port: number, host: string) => Promise<boolean>;12 readonly canListenOnHost?: (port: number, host: string) => Promise<boolean>;
12}13}
1314
...@@ -21,10 +22,37 @@ const defaultCanListenOnHost = async (port: number, host: string): Promise<boole...@@ -21,10 +22,37 @@ const defaultCanListenOnHost = async (port: number, host: string): Promise<boole
21const isValidPort = (port: number): boolean =>22const isValidPort = (port: number): boolean =>
22 Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT;23 Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT;
2324
25const normalizeHosts = (
26 host: string,
27 requiredHosts: ReadonlyArray<string>,
28): ReadonlyArray<string> =>
29 Array.from(
30 new Set(
31 [host, ...requiredHosts]
32 .map((candidate) => candidate.trim())
33 .filter((candidate) => candidate.length > 0),
34 ),
35 );
36
37async function canListenOnAllHosts(
38 port: number,
39 hosts: ReadonlyArray<string>,
40 canListenOnHost: (port: number, host: string) => Promise<boolean>,
41): Promise<boolean> {
42 for (const candidateHost of hosts) {
43 if (!(await canListenOnHost(port, candidateHost))) {
44 return false;
45 }
46 }
47
48 return true;
49}
50
24export async function resolveDesktopBackendPort({51export async function resolveDesktopBackendPort({
25 host,52 host,
26 startPort = DEFAULT_DESKTOP_BACKEND_PORT,53 startPort = DEFAULT_DESKTOP_BACKEND_PORT,
27 maxPort = MAX_TCP_PORT,54 maxPort = MAX_TCP_PORT,
55 requiredHosts = [],
28 canListenOnHost = defaultCanListenOnHost,56 canListenOnHost = defaultCanListenOnHost,
29}: ResolveDesktopBackendPortOptions): Promise<number> {57}: ResolveDesktopBackendPortOptions): Promise<number> {
30 if (!isValidPort(startPort)) {58 if (!isValidPort(startPort)) {
...@@ -39,15 +67,17 @@ export async function resolveDesktopBackendPort({...@@ -39,15 +67,17 @@ export async function resolveDesktopBackendPort({
39 throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`);67 throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`);
40 }68 }
4169
70 const hostsToCheck = normalizeHosts(host, requiredHosts);
71
42 // Keep desktop startup predictable across app restarts by probing upward from72 // Keep desktop startup predictable across app restarts by probing upward from
43 // the same preferred port instead of picking a fresh ephemeral port.73 // the same preferred port instead of picking a fresh ephemeral port.
44 for (let port = startPort; port <= maxPort; port += 1) {74 for (let port = startPort; port <= maxPort; port += 1) {
45 if (await canListenOnHost(port, host)) {75 if (await canListenOnAllHosts(port, hostsToCheck, canListenOnHost)) {
46 return port;76 return port;
47 }77 }
48 }78 }
4979
50 throw new Error(80 throw new Error(
51 `No desktop backend port is available on ${host} between ${startPort} and ${maxPort}`,81 `No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`,
52 );82 );
53}83}
apps/desktop/src/main.ts+2
...@@ -117,6 +117,7 @@ const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;...@@ -117,6 +117,7 @@ const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
117const DESKTOP_UPDATE_CHANNEL = "latest";117const DESKTOP_UPDATE_CHANNEL = "latest";
118const DESKTOP_UPDATE_ALLOW_PRERELEASE = false;118const DESKTOP_UPDATE_ALLOW_PRERELEASE = false;
119const DESKTOP_LOOPBACK_HOST = "127.0.0.1";119const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
120const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const;
120121
121type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];122type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];
122type LinuxDesktopNamedApp = Electron.App & {123type LinuxDesktopNamedApp = Electron.App & {
...@@ -1781,6 +1782,7 @@ async function bootstrap(): Promise<void> {...@@ -1781,6 +1782,7 @@ async function bootstrap(): Promise<void> {
1781 (await resolveDesktopBackendPort({1782 (await resolveDesktopBackendPort({
1782 host: DESKTOP_LOOPBACK_HOST,1783 host: DESKTOP_LOOPBACK_HOST,
1783 startPort: DEFAULT_DESKTOP_BACKEND_PORT,1784 startPort: DEFAULT_DESKTOP_BACKEND_PORT,
1785 requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS,
1784 }));1786 }));
1785 writeDesktopLogHeader(1787 writeDesktopLogHeader(
1786 configuredBackendPort === undefined1788 configuredBackendPort === undefined
apps/server/src/auth/Layers/ServerAuth.ts+11-1
...@@ -20,7 +20,10 @@ import {...@@ -20,7 +20,10 @@ import {
20 AuthError,20 AuthError,
21 type ServerAuthShape,21 type ServerAuthShape,
22} from "../Services/ServerAuth.ts";22} from "../Services/ServerAuth.ts";
23import { SessionCredentialService } from "../Services/SessionCredentialService.ts";23import {
24 SessionCredentialError,
25 SessionCredentialService,
26} from "../Services/SessionCredentialService.ts";
24import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts";27import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts";
2528
26type BootstrapExchangeResult = {29type BootstrapExchangeResult = {
...@@ -65,6 +68,13 @@ export const makeServerAuth = Effect.gen(function* () {...@@ -65,6 +68,13 @@ export const makeServerAuth = Effect.gen(function* () {
6568
66 const authenticateToken = (token: string): Effect.Effect<AuthenticatedSession, AuthError> =>69 const authenticateToken = (token: string): Effect.Effect<AuthenticatedSession, AuthError> =>
67 sessions.verify(token).pipe(70 sessions.verify(token).pipe(
71 Effect.tapError((cause: SessionCredentialError) =>
72 Effect.logWarning("Rejected authenticated session credential.").pipe(
73 Effect.annotateLogs({
74 reason: cause.message,
75 }),
76 ),
77 ),
68 Effect.map((session) => ({78 Effect.map((session) => ({
69 sessionId: session.sessionId,79 sessionId: session.sessionId,
70 subject: session.subject,80 subject: session.subject,
apps/server/src/auth/Layers/ServerAuthPolicy.test.ts+3
...@@ -33,10 +33,12 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => {...@@ -33,10 +33,12 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => {
3333
34 expect(descriptor.policy).toBe("desktop-managed-local");34 expect(descriptor.policy).toBe("desktop-managed-local");
35 expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]);35 expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]);
36 expect(descriptor.sessionCookieName).toBe("t3_session_3773");
36 }).pipe(37 }).pipe(
37 Effect.provide(38 Effect.provide(
38 makeServerAuthPolicyLayer({39 makeServerAuthPolicyLayer({
39 mode: "desktop",40 mode: "desktop",
41 port: 3773,
40 }),42 }),
41 ),43 ),
42 ),44 ),
...@@ -66,6 +68,7 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => {...@@ -66,6 +68,7 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => {
6668
67 expect(descriptor.policy).toBe("loopback-browser");69 expect(descriptor.policy).toBe("loopback-browser");
68 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);70 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
71 expect(descriptor.sessionCookieName).toBe("t3_session");
69 }).pipe(72 }).pipe(
70 Effect.provide(73 Effect.provide(
71 makeServerAuthPolicyLayer({74 makeServerAuthPolicyLayer({
apps/server/src/auth/Layers/ServerAuthPolicy.ts+5-2
...@@ -3,7 +3,7 @@ import { Effect, Layer } from "effect";...@@ -3,7 +3,7 @@ import { Effect, Layer } from "effect";
33
4import { ServerConfig } from "../../config.ts";4import { ServerConfig } from "../../config.ts";
5import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts";5import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts";
6import { SESSION_COOKIE_NAME } from "../utils.ts";6import { resolveSessionCookieName } from "../utils.ts";
7import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts";7import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts";
88
9export const makeServerAuthPolicy = Effect.gen(function* () {9export const makeServerAuthPolicy = Effect.gen(function* () {
...@@ -30,7 +30,10 @@ export const makeServerAuthPolicy = Effect.gen(function* () {...@@ -30,7 +30,10 @@ export const makeServerAuthPolicy = Effect.gen(function* () {
30 policy,30 policy,
31 bootstrapMethods,31 bootstrapMethods,
32 sessionMethods: ["browser-session-cookie", "bearer-session-token"],32 sessionMethods: ["browser-session-cookie", "bearer-session-token"],
33 sessionCookieName: SESSION_COOKIE_NAME,33 sessionCookieName: resolveSessionCookieName({
34 mode: config.mode,
35 port: config.port,
36 }),
34 };37 };
3538
36 return {39 return {
apps/server/src/auth/Layers/SessionCredentialService.ts+8-2
...@@ -2,10 +2,10 @@ import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from "...@@ -2,10 +2,10 @@ import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from "
2import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect";2import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect";
3import { Option } from "effect";3import { Option } from "effect";
44
5import { ServerConfig } from "../../config.ts";
5import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts";6import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts";
6import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts";7import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts";
7import { ServerSecretStore } from "../Services/ServerSecretStore.ts";8import { ServerSecretStore } from "../Services/ServerSecretStore.ts";
8import { SESSION_COOKIE_NAME } from "../utils.ts";
9import {9import {
10 SessionCredentialError,10 SessionCredentialError,
11 SessionCredentialService,11 SessionCredentialService,
...@@ -17,6 +17,7 @@ import {...@@ -17,6 +17,7 @@ import {
17import {17import {
18 base64UrlDecodeUtf8,18 base64UrlDecodeUtf8,
19 base64UrlEncode,19 base64UrlEncode,
20 resolveSessionCookieName,
20 signPayload,21 signPayload,
21 timingSafeEqualBase64Url,22 timingSafeEqualBase64Url,
22} from "../utils.ts";23} from "../utils.ts";
...@@ -81,11 +82,16 @@ function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthCli...@@ -81,11 +82,16 @@ function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthCli
81}82}
8283
83export const makeSessionCredentialService = Effect.gen(function* () {84export const makeSessionCredentialService = Effect.gen(function* () {
85 const serverConfig = yield* ServerConfig;
84 const secretStore = yield* ServerSecretStore;86 const secretStore = yield* ServerSecretStore;
85 const authSessions = yield* AuthSessionRepository;87 const authSessions = yield* AuthSessionRepository;
86 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);88 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
87 const connectedSessionsRef = yield* Ref.make(new Map<string, number>());89 const connectedSessionsRef = yield* Ref.make(new Map<string, number>());
88 const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();90 const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();
91 const cookieName = resolveSessionCookieName({
92 mode: serverConfig.mode,
93 port: serverConfig.port,
94 });
8995
90 const toSessionCredentialError = (message: string) => (cause: unknown) =>96 const toSessionCredentialError = (message: string) => (cause: unknown) =>
91 new SessionCredentialError({97 new SessionCredentialError({
...@@ -472,7 +478,7 @@ export const makeSessionCredentialService = Effect.gen(function* () {...@@ -472,7 +478,7 @@ export const makeSessionCredentialService = Effect.gen(function* () {
472 }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions.")));478 }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions.")));
473479
474 return {480 return {
475 cookieName: SESSION_COOKIE_NAME,481 cookieName,
476 issue,482 issue,
477 verify,483 verify,
478 issueWebSocketToken,484 issueWebSocketToken,
apps/server/src/auth/utils.ts+12-1
...@@ -2,7 +2,18 @@ import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/...@@ -2,7 +2,18 @@ import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/
2import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";2import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
3import * as Crypto from "node:crypto";3import * as Crypto from "node:crypto";
44
5export const SESSION_COOKIE_NAME = "t3_session";5const SESSION_COOKIE_NAME = "t3_session";
6
7export function resolveSessionCookieName(input: {
8 readonly mode: "web" | "desktop";
9 readonly port: number;
10}): string {
11 if (input.mode !== "desktop") {
12 return SESSION_COOKIE_NAME;
13 }
14
15 return `${SESSION_COOKIE_NAME}_${input.port}`;
16}
617
7export function base64UrlEncode(input: string | Uint8Array): string {18export function base64UrlEncode(input: string | Uint8Array): string {
8 const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input);19 const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input);
apps/server/src/server.test.ts+1-1
...@@ -789,7 +789,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -789,7 +789,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
789 "browser-session-cookie",789 "browser-session-cookie",
790 "bearer-session-token",790 "bearer-session-token",
791 ]);791 ]);
792 assert.equal(body.auth.sessionCookieName, "t3_session");792 assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_"));
793 }).pipe(Effect.provide(NodeHttpServer.layerTest)),793 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
794 );794 );
795795
scripts/dev-runner.test.ts+63-10
...@@ -5,6 +5,7 @@ import { assert, describe, it } from "@effect/vitest";...@@ -5,6 +5,7 @@ import { assert, describe, it } from "@effect/vitest";
5import { Effect } from "effect";5import { Effect } from "effect";
66
7import {7import {
8 checkPortAvailabilityOnHosts,
8 createDevRunnerEnv,9 createDevRunnerEnv,
9 findFirstAvailableOffset,10 findFirstAvailableOffset,
10 resolveModePortOffsets,11 resolveModePortOffsets,
...@@ -164,11 +165,11 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -164,11 +165,11 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
164 const env = yield* createDevRunnerEnv({165 const env = yield* createDevRunnerEnv({
165 mode: "dev:desktop",166 mode: "dev:desktop",
166 baseEnv: {167 baseEnv: {
167 T3CODE_PORT: "3773",168 T3CODE_PORT: "13773",
168 T3CODE_MODE: "web",169 T3CODE_MODE: "web",
169 T3CODE_NO_BROWSER: "0",170 T3CODE_NO_BROWSER: "0",
170 T3CODE_HOST: "0.0.0.0",171 T3CODE_HOST: "0.0.0.0",
171 VITE_WS_URL: "ws://localhost:3773",172 VITE_WS_URL: "ws://localhost:13773",
172 },173 },
173 serverOffset: 0,174 serverOffset: 0,
174 webOffset: 0,175 webOffset: 0,
...@@ -193,6 +194,28 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -193,6 +194,28 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
193 assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:4222");194 assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:4222");
194 }),195 }),
195 );196 );
197
198 it.effect("defaults dev server mode to the higher backend port range", () =>
199 Effect.gen(function* () {
200 const env = yield* createDevRunnerEnv({
201 mode: "dev",
202 baseEnv: {},
203 serverOffset: 0,
204 webOffset: 0,
205 t3Home: undefined,
206 noBrowser: undefined,
207 autoBootstrapProjectFromCwd: undefined,
208 logWebSocketEvents: undefined,
209 host: undefined,
210 port: undefined,
211 devUrl: undefined,
212 });
213
214 assert.equal(env.T3CODE_PORT, "13773");
215 assert.equal(env.VITE_HTTP_URL, "http://localhost:13773");
216 assert.equal(env.VITE_WS_URL, "ws://localhost:13773");
217 }),
218 );
196 });219 });
197220
198 describe("findFirstAvailableOffset", () => {221 describe("findFirstAvailableOffset", () => {
...@@ -211,7 +234,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -211,7 +234,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
211234
212 it.effect("advances until all required ports are available", () =>235 it.effect("advances until all required ports are available", () =>
213 Effect.gen(function* () {236 Effect.gen(function* () {
214 const taken = new Set([3773, 5733, 3774, 5734]);237 const taken = new Set([13773, 5733, 13774, 5734]);
215 const offset = yield* findFirstAvailableOffset({238 const offset = yield* findFirstAvailableOffset({
216 startOffset: 0,239 startOffset: 0,
217 requireServerPort: true,240 requireServerPort: true,
...@@ -223,16 +246,46 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -223,16 +246,46 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
223 }),246 }),
224 );247 );
225248
226 it.effect("allows offsets where only non-required ports exceed max", () =>249 it.effect("allows offsets where the non-required server port exceeds max", () =>
227 Effect.gen(function* () {250 Effect.gen(function* () {
228 const offset = yield* findFirstAvailableOffset({251 const offset = yield* findFirstAvailableOffset({
229 startOffset: 59_803,252 startOffset: 59_802,
230 requireServerPort: true,253 requireServerPort: false,
231 requireWebPort: false,254 requireWebPort: true,
232 checkPortAvailability: () => Effect.succeed(true),255 checkPortAvailability: () => Effect.succeed(true),
233 });256 });
234257
235 assert.equal(offset, 59_803);258 assert.equal(offset, 59_802);
259 }),
260 );
261 });
262
263 describe("checkPortAvailabilityOnHosts", () => {
264 it.effect("checks overlapping hosts sequentially to avoid self-interference", () =>
265 Effect.gen(function* () {
266 let inFlightCount = 0;
267 const calls: Array<[number, string]> = [];
268
269 const available = yield* checkPortAvailabilityOnHosts(
270 13_773,
271 ["127.0.0.1", "0.0.0.0", "::"],
272 (port, host) =>
273 Effect.promise(async () => {
274 calls.push([port, host]);
275 inFlightCount += 1;
276 const overlapped = inFlightCount > 1;
277 await Promise.resolve();
278 inFlightCount -= 1;
279 return !overlapped;
280 }),
281 );
282
283 assert.equal(available, true);
284 assert.deepStrictEqual(calls, [
285 [13_773, "127.0.0.1"],
286 [13_773, "0.0.0.0"],
287 [13_773, "::"],
288 ]);
236 }),289 }),
237 );290 );
238 });291 });
...@@ -240,7 +293,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -240,7 +293,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
240 describe("resolveModePortOffsets", () => {293 describe("resolveModePortOffsets", () => {
241 it.effect("uses a shared fallback offset for dev mode", () =>294 it.effect("uses a shared fallback offset for dev mode", () =>
242 Effect.gen(function* () {295 Effect.gen(function* () {
243 const taken = new Set([3773, 5733]);296 const taken = new Set([13773, 5733]);
244 const offsets = yield* resolveModePortOffsets({297 const offsets = yield* resolveModePortOffsets({
245 mode: "dev",298 mode: "dev",
246 startOffset: 0,299 startOffset: 0,
...@@ -270,7 +323,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -270,7 +323,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
270323
271 it.effect("shifts only server offset for dev:server", () =>324 it.effect("shifts only server offset for dev:server", () =>
272 Effect.gen(function* () {325 Effect.gen(function* () {
273 const taken = new Set([3773]);326 const taken = new Set([13773]);
274 const offsets = yield* resolveModePortOffsets({327 const offsets = yield* resolveModePortOffsets({
275 mode: "dev:server",328 mode: "dev:server",
276 startOffset: 0,329 startOffset: 0,
scripts/dev-runner.ts+21-2
...@@ -9,11 +9,12 @@ import { Config, Data, Effect, Hash, Layer, Logger, Option, Path, Schema } from...@@ -9,11 +9,12 @@ import { Config, Data, Effect, Hash, Layer, Logger, Option, Path, Schema } from
9import { Argument, Command, Flag } from "effect/unstable/cli";9import { Argument, Command, Flag } from "effect/unstable/cli";
10import { ChildProcess } from "effect/unstable/process";10import { ChildProcess } from "effect/unstable/process";
1111
12const BASE_SERVER_PORT = 3773;12const BASE_SERVER_PORT = 13773;
13const BASE_WEB_PORT = 5733;13const BASE_WEB_PORT = 5733;
14const MAX_HASH_OFFSET = 3000;14const MAX_HASH_OFFSET = 3000;
15const MAX_PORT = 65535;15const MAX_PORT = 65535;
16const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1";16const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1";
17const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const;
1718
18export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) =>19export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) =>
19 path.join(homedir(), ".t3"),20 path.join(homedir(), ".t3"),
...@@ -221,10 +222,28 @@ function portPairForOffset(offset: number): {...@@ -221,10 +222,28 @@ function portPairForOffset(offset: number): {
221 };222 };
222}223}
223224
225export function checkPortAvailabilityOnHosts<R>(
226 port: number,
227 hosts: ReadonlyArray<string>,
228 canListenOnHost: (port: number, host: string) => Effect.Effect<boolean, never, R>,
229): Effect.Effect<boolean, never, R> {
230 return Effect.gen(function* () {
231 for (const host of hosts) {
232 if (!(yield* canListenOnHost(port, host))) {
233 return false;
234 }
235 }
236
237 return true;
238 });
239}
240
224const defaultCheckPortAvailability: PortAvailabilityCheck<NetService> = (port) =>241const defaultCheckPortAvailability: PortAvailabilityCheck<NetService> = (port) =>
225 Effect.gen(function* () {242 Effect.gen(function* () {
226 const net = yield* NetService;243 const net = yield* NetService;
227 return yield* net.isPortAvailableOnLoopback(port);244 return yield* checkPortAvailabilityOnHosts(port, DEV_PORT_PROBE_HOSTS, (candidatePort, host) =>
245 net.canListenOnHost(candidatePort, host),
246 );
228 });247 });
229248
230interface FindFirstAvailableOffsetInput<R = NetService> {249interface FindFirstAvailableOffsetInput<R = NetService> {