From 6068555c48a5e82577c27bc9e8aeddc96f3c8953 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 14:00:46 -0700 Subject: [PATCH 1/4] Derive session cookie names from server mode - Use port-scoped cookies for desktop mode - Log rejected session credentials with reasons - Update auth policy and server tests --- apps/server/src/auth/Layers/ServerAuth.ts | 25 +++++++++++++++++++ .../src/auth/Layers/ServerAuthPolicy.test.ts | 3 +++ .../src/auth/Layers/ServerAuthPolicy.ts | 7 ++++-- .../auth/Layers/SessionCredentialService.ts | 10 ++++++-- apps/server/src/auth/utils.ts | 13 +++++++++- apps/server/src/server.test.ts | 2 +- 6 files changed, 54 insertions(+), 6 deletions(-) diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts index fa7191110a..973a22eaa9 100644 --- a/apps/server/src/auth/Layers/ServerAuth.ts +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -31,6 +31,24 @@ type BootstrapExchangeResult = { const AUTHORIZATION_PREFIX = "Bearer "; const WEBSOCKET_TOKEN_QUERY_PARAM = "wsToken"; +function authFailureReason(cause: unknown): string { + if (cause instanceof Error && cause.message.trim().length > 0) { + return cause.message; + } + + if ( + typeof cause === "object" && + cause !== null && + "message" in cause && + typeof cause.message === "string" && + cause.message.trim().length > 0 + ) { + return cause.message; + } + + return "unknown"; +} + export function toBootstrapExchangeAuthError(cause: BootstrapCredentialError): AuthError { if (cause.status === 500) { return new AuthError({ @@ -65,6 +83,13 @@ export const makeServerAuth = Effect.gen(function* () { const authenticateToken = (token: string): Effect.Effect => sessions.verify(token).pipe( + Effect.tapError((cause) => + Effect.logWarning("Rejected authenticated session credential.").pipe( + Effect.annotateLogs({ + reason: authFailureReason(cause), + }), + ), + ), Effect.map((session) => ({ sessionId: session.sessionId, subject: session.subject, diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts index 640cc030f8..13ca0233ee 100644 --- a/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts @@ -33,10 +33,12 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { expect(descriptor.policy).toBe("desktop-managed-local"); expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); + expect(descriptor.sessionCookieName).toBe("t3_session_3773"); }).pipe( Effect.provide( makeServerAuthPolicyLayer({ mode: "desktop", + port: 3773, }), ), ), @@ -66,6 +68,7 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { expect(descriptor.policy).toBe("loopback-browser"); expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); + expect(descriptor.sessionCookieName).toBe("t3_session"); }).pipe( Effect.provide( makeServerAuthPolicyLayer({ diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.ts index 9f952cc9ec..43735b4761 100644 --- a/apps/server/src/auth/Layers/ServerAuthPolicy.ts +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.ts @@ -3,7 +3,7 @@ import { Effect, Layer } from "effect"; import { ServerConfig } from "../../config.ts"; import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts"; -import { SESSION_COOKIE_NAME } from "../utils.ts"; +import { resolveSessionCookieName } from "../utils.ts"; import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts"; export const makeServerAuthPolicy = Effect.gen(function* () { @@ -30,7 +30,10 @@ export const makeServerAuthPolicy = Effect.gen(function* () { policy, bootstrapMethods, sessionMethods: ["browser-session-cookie", "bearer-session-token"], - sessionCookieName: SESSION_COOKIE_NAME, + sessionCookieName: resolveSessionCookieName({ + mode: config.mode, + port: config.port, + }), }; return { diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts index 53b6ae2abf..c1ca279286 100644 --- a/apps/server/src/auth/Layers/SessionCredentialService.ts +++ b/apps/server/src/auth/Layers/SessionCredentialService.ts @@ -2,10 +2,10 @@ import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from " import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect"; import { Option } from "effect"; +import { ServerConfig } from "../../config.ts"; import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts"; import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts"; import { ServerSecretStore } from "../Services/ServerSecretStore.ts"; -import { SESSION_COOKIE_NAME } from "../utils.ts"; import { SessionCredentialError, SessionCredentialService, @@ -17,6 +17,7 @@ import { import { base64UrlDecodeUtf8, base64UrlEncode, + resolveSessionCookieName, signPayload, timingSafeEqualBase64Url, } from "../utils.ts"; @@ -81,11 +82,16 @@ function toAuthClientSession(input: Omit): AuthCli } export const makeSessionCredentialService = Effect.gen(function* () { + const serverConfig = yield* ServerConfig; const secretStore = yield* ServerSecretStore; const authSessions = yield* AuthSessionRepository; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); const connectedSessionsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); + const cookieName = resolveSessionCookieName({ + mode: serverConfig.mode, + port: serverConfig.port, + }); const toSessionCredentialError = (message: string) => (cause: unknown) => new SessionCredentialError({ @@ -472,7 +478,7 @@ export const makeSessionCredentialService = Effect.gen(function* () { }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions."))); return { - cookieName: SESSION_COOKIE_NAME, + cookieName, issue, verify, issueWebSocketToken, diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index e87c66c6b9..2c76a81f65 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -2,7 +2,18 @@ import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/ import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as Crypto from "node:crypto"; -export const SESSION_COOKIE_NAME = "t3_session"; +const SESSION_COOKIE_NAME = "t3_session"; + +export function resolveSessionCookieName(input: { + readonly mode: "web" | "desktop"; + readonly port: number; +}): string { + if (input.mode !== "desktop") { + return SESSION_COOKIE_NAME; + } + + return `${SESSION_COOKIE_NAME}_${input.port}`; +} export function base64UrlEncode(input: string | Uint8Array): string { const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 98e1395e13..c3543294ee 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -789,7 +789,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "browser-session-cookie", "bearer-session-token", ]); - assert.equal(body.auth.sessionCookieName, "t3_session"); + assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_")); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); From 1c0b8ecac900a407841d47459ada7a6eef27dbc9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 14:14:11 -0700 Subject: [PATCH 2/4] Use credential error messages in auth logging - Log `SessionCredentialError.message` directly when session auth fails - Remove redundant auth failure reason helper --- apps/server/src/auth/Layers/ServerAuth.ts | 27 +++++------------------ 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts index 973a22eaa9..cb1c6fa4c4 100644 --- a/apps/server/src/auth/Layers/ServerAuth.ts +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -20,7 +20,10 @@ import { AuthError, type ServerAuthShape, } from "../Services/ServerAuth.ts"; -import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; +import { + SessionCredentialError, + SessionCredentialService, +} from "../Services/SessionCredentialService.ts"; import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts"; type BootstrapExchangeResult = { @@ -31,24 +34,6 @@ type BootstrapExchangeResult = { const AUTHORIZATION_PREFIX = "Bearer "; const WEBSOCKET_TOKEN_QUERY_PARAM = "wsToken"; -function authFailureReason(cause: unknown): string { - if (cause instanceof Error && cause.message.trim().length > 0) { - return cause.message; - } - - if ( - typeof cause === "object" && - cause !== null && - "message" in cause && - typeof cause.message === "string" && - cause.message.trim().length > 0 - ) { - return cause.message; - } - - return "unknown"; -} - export function toBootstrapExchangeAuthError(cause: BootstrapCredentialError): AuthError { if (cause.status === 500) { return new AuthError({ @@ -83,10 +68,10 @@ export const makeServerAuth = Effect.gen(function* () { const authenticateToken = (token: string): Effect.Effect => sessions.verify(token).pipe( - Effect.tapError((cause) => + Effect.tapError((cause: SessionCredentialError) => Effect.logWarning("Rejected authenticated session credential.").pipe( Effect.annotateLogs({ - reason: authFailureReason(cause), + reason: cause.message, }), ), ), From 1213650889ab18d116325b7691ab5f80c7a84310 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 14:53:49 -0700 Subject: [PATCH 3/4] Harden desktop and dev port probing for wildcard binds - probe required wildcard hosts before reusing a port - move desktop/dev defaults to the higher port range - add coverage for host-specific port availability --- apps/desktop/src/backendPort.test.ts | 24 +++++++++++++++++++ apps/desktop/src/backendPort.ts | 21 +++++++++++++++- apps/desktop/src/main.ts | 2 ++ scripts/dev-runner.test.ts | 36 ++++++++++++++++++++++------ scripts/dev-runner.ts | 8 +++++-- 5 files changed, 81 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/backendPort.test.ts b/apps/desktop/src/backendPort.test.ts index 57a066eb58..e5213a0782 100644 --- a/apps/desktop/src/backendPort.test.ts +++ b/apps/desktop/src/backendPort.test.ts @@ -36,6 +36,30 @@ describe("resolveDesktopBackendPort", () => { ]); }); + it("treats wildcard-bound ports as unavailable even when loopback probing succeeds", async () => { + const canListenOnHost = vi.fn(async (port: number, host: string) => { + if (port === 3773 && host === "127.0.0.1") return true; + if (port === 3773 && host === "0.0.0.0") return false; + return port === 3774; + }); + + await expect( + resolveDesktopBackendPort({ + host: "127.0.0.1", + requiredHosts: ["0.0.0.0"], + startPort: 3773, + canListenOnHost, + }), + ).resolves.toBe(3774); + + expect(canListenOnHost.mock.calls).toEqual([ + [3773, "127.0.0.1"], + [3773, "0.0.0.0"], + [3774, "127.0.0.1"], + [3774, "0.0.0.0"], + ]); + }); + it("fails when the scan range is exhausted", async () => { const canListenOnHost = vi.fn(async () => false); diff --git a/apps/desktop/src/backendPort.ts b/apps/desktop/src/backendPort.ts index e70272c397..09e2c24545 100644 --- a/apps/desktop/src/backendPort.ts +++ b/apps/desktop/src/backendPort.ts @@ -8,6 +8,7 @@ export interface ResolveDesktopBackendPortOptions { readonly host: string; readonly startPort?: number; readonly maxPort?: number; + readonly requiredHosts?: ReadonlyArray; readonly canListenOnHost?: (port: number, host: string) => Promise; } @@ -21,10 +22,23 @@ const defaultCanListenOnHost = async (port: number, host: string): Promise Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT; +const normalizeHosts = ( + host: string, + requiredHosts: ReadonlyArray, +): ReadonlyArray => + Array.from( + new Set( + [host, ...requiredHosts] + .map((candidate) => candidate.trim()) + .filter((candidate) => candidate.length > 0), + ), + ); + export async function resolveDesktopBackendPort({ host, startPort = DEFAULT_DESKTOP_BACKEND_PORT, maxPort = MAX_TCP_PORT, + requiredHosts = [], canListenOnHost = defaultCanListenOnHost, }: ResolveDesktopBackendPortOptions): Promise { if (!isValidPort(startPort)) { @@ -39,10 +53,15 @@ export async function resolveDesktopBackendPort({ throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`); } + const hostsToCheck = normalizeHosts(host, requiredHosts); + // Keep desktop startup predictable across app restarts by probing upward from // the same preferred port instead of picking a fresh ephemeral port. for (let port = startPort; port <= maxPort; port += 1) { - if (await canListenOnHost(port, host)) { + const availability = await Promise.all( + hostsToCheck.map((candidateHost) => canListenOnHost(port, candidateHost)), + ); + if (availability.every(Boolean)) { return port; } } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 310b973458..0ce03aeace 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -117,6 +117,7 @@ const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; const DESKTOP_UPDATE_CHANNEL = "latest"; const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; +const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; type LinuxDesktopNamedApp = Electron.App & { @@ -1773,6 +1774,7 @@ async function bootstrap(): Promise { (await resolveDesktopBackendPort({ host: DESKTOP_LOOPBACK_HOST, startPort: DEFAULT_DESKTOP_BACKEND_PORT, + requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS, })); writeDesktopLogHeader( configuredBackendPort === undefined diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 0918e6986a..1dba9773fd 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -162,11 +162,11 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { const env = yield* createDevRunnerEnv({ mode: "dev:desktop", baseEnv: { - T3CODE_PORT: "3773", + T3CODE_PORT: "13773", T3CODE_MODE: "web", T3CODE_NO_BROWSER: "0", T3CODE_HOST: "0.0.0.0", - VITE_WS_URL: "ws://localhost:3773", + VITE_WS_URL: "ws://localhost:13773", }, serverOffset: 0, webOffset: 0, @@ -191,6 +191,28 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:4222"); }), ); + + it.effect("defaults dev server mode to the higher backend port range", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + noBrowser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_PORT, "13773"); + assert.equal(env.VITE_HTTP_URL, "http://localhost:13773"); + assert.equal(env.VITE_WS_URL, "ws://localhost:13773"); + }), + ); }); describe("findFirstAvailableOffset", () => { @@ -209,7 +231,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { it.effect("advances until all required ports are available", () => Effect.gen(function* () { - const taken = new Set([3773, 5733, 3774, 5734]); + const taken = new Set([13773, 5733, 13774, 5734]); const offset = yield* findFirstAvailableOffset({ startOffset: 0, requireServerPort: true, @@ -224,13 +246,13 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { it.effect("allows offsets where only non-required ports exceed max", () => Effect.gen(function* () { const offset = yield* findFirstAvailableOffset({ - startOffset: 59_803, + startOffset: 51_762, requireServerPort: true, requireWebPort: false, checkPortAvailability: () => Effect.succeed(true), }); - assert.equal(offset, 59_803); + assert.equal(offset, 51_762); }), ); }); @@ -238,7 +260,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { describe("resolveModePortOffsets", () => { it.effect("uses a shared fallback offset for dev mode", () => Effect.gen(function* () { - const taken = new Set([3773, 5733]); + const taken = new Set([13773, 5733]); const offsets = yield* resolveModePortOffsets({ mode: "dev", startOffset: 0, @@ -268,7 +290,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { it.effect("shifts only server offset for dev:server", () => Effect.gen(function* () { - const taken = new Set([3773]); + const taken = new Set([13773]); const offsets = yield* resolveModePortOffsets({ mode: "dev:server", startOffset: 0, diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 4522c21d70..dc72bf29ec 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -9,11 +9,12 @@ import { Config, Data, Effect, Hash, Layer, Logger, Option, Path, Schema } from import { Argument, Command, Flag } from "effect/unstable/cli"; import { ChildProcess } from "effect/unstable/process"; -const BASE_SERVER_PORT = 3773; +const BASE_SERVER_PORT = 13773; const BASE_WEB_PORT = 5733; const MAX_HASH_OFFSET = 3000; const MAX_PORT = 65535; const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; +const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const; export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => path.join(homedir(), ".t3"), @@ -224,7 +225,10 @@ function portPairForOffset(offset: number): { const defaultCheckPortAvailability: PortAvailabilityCheck = (port) => Effect.gen(function* () { const net = yield* NetService; - return yield* net.isPortAvailableOnLoopback(port); + const availability = yield* Effect.all( + DEV_PORT_PROBE_HOSTS.map((host) => net.canListenOnHost(port, host)), + ); + return availability.every(Boolean); }); interface FindFirstAvailableOffsetInput { From d8c3a5aba982ba5965cd5ead5df353b2dc0c6661 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 10 Apr 2026 15:23:11 -0700 Subject: [PATCH 4/4] Serialize port host checks to avoid self-interference - Probe candidate hosts sequentially for desktop and dev runner ports - Update tests for the new availability check behavior and error text --- apps/desktop/src/backendPort.test.ts | 31 ++++++++++++++++++++- apps/desktop/src/backendPort.ts | 21 ++++++++++---- scripts/dev-runner.test.ts | 41 ++++++++++++++++++++++++---- scripts/dev-runner.ts | 21 ++++++++++++-- 4 files changed, 100 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/backendPort.test.ts b/apps/desktop/src/backendPort.test.ts index e5213a0782..8f586deb70 100644 --- a/apps/desktop/src/backendPort.test.ts +++ b/apps/desktop/src/backendPort.test.ts @@ -60,6 +60,33 @@ describe("resolveDesktopBackendPort", () => { ]); }); + it("checks overlapping hosts sequentially to avoid self-interference", async () => { + let inFlightCount = 0; + const canListenOnHost = vi.fn(async (_port: number, _host: string) => { + inFlightCount += 1; + const overlapped = inFlightCount > 1; + await Promise.resolve(); + inFlightCount -= 1; + return !overlapped; + }); + + await expect( + resolveDesktopBackendPort({ + host: "127.0.0.1", + requiredHosts: ["0.0.0.0", "::"], + startPort: 3773, + maxPort: 3773, + canListenOnHost, + }), + ).resolves.toBe(3773); + + expect(canListenOnHost.mock.calls).toEqual([ + [3773, "127.0.0.1"], + [3773, "0.0.0.0"], + [3773, "::"], + ]); + }); + it("fails when the scan range is exhausted", async () => { const canListenOnHost = vi.fn(async () => false); @@ -70,7 +97,9 @@ describe("resolveDesktopBackendPort", () => { maxPort: 65535, canListenOnHost, }), - ).rejects.toThrow("No desktop backend port is available on 127.0.0.1 between 65534 and 65535"); + ).rejects.toThrow( + "No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535", + ); expect(canListenOnHost.mock.calls).toEqual([ [65534, "127.0.0.1"], diff --git a/apps/desktop/src/backendPort.ts b/apps/desktop/src/backendPort.ts index 09e2c24545..1ce90a257f 100644 --- a/apps/desktop/src/backendPort.ts +++ b/apps/desktop/src/backendPort.ts @@ -34,6 +34,20 @@ const normalizeHosts = ( ), ); +async function canListenOnAllHosts( + port: number, + hosts: ReadonlyArray, + canListenOnHost: (port: number, host: string) => Promise, +): Promise { + for (const candidateHost of hosts) { + if (!(await canListenOnHost(port, candidateHost))) { + return false; + } + } + + return true; +} + export async function resolveDesktopBackendPort({ host, startPort = DEFAULT_DESKTOP_BACKEND_PORT, @@ -58,15 +72,12 @@ export async function resolveDesktopBackendPort({ // Keep desktop startup predictable across app restarts by probing upward from // the same preferred port instead of picking a fresh ephemeral port. for (let port = startPort; port <= maxPort; port += 1) { - const availability = await Promise.all( - hostsToCheck.map((candidateHost) => canListenOnHost(port, candidateHost)), - ); - if (availability.every(Boolean)) { + if (await canListenOnAllHosts(port, hostsToCheck, canListenOnHost)) { return port; } } throw new Error( - `No desktop backend port is available on ${host} between ${startPort} and ${maxPort}`, + `No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`, ); } diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 1dba9773fd..41dd4d462f 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -5,6 +5,7 @@ import { assert, describe, it } from "@effect/vitest"; import { Effect } from "effect"; import { + checkPortAvailabilityOnHosts, createDevRunnerEnv, findFirstAvailableOffset, resolveModePortOffsets, @@ -243,16 +244,46 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); - it.effect("allows offsets where only non-required ports exceed max", () => + it.effect("allows offsets where the non-required server port exceeds max", () => Effect.gen(function* () { const offset = yield* findFirstAvailableOffset({ - startOffset: 51_762, - requireServerPort: true, - requireWebPort: false, + startOffset: 59_802, + requireServerPort: false, + requireWebPort: true, checkPortAvailability: () => Effect.succeed(true), }); - assert.equal(offset, 51_762); + assert.equal(offset, 59_802); + }), + ); + }); + + describe("checkPortAvailabilityOnHosts", () => { + it.effect("checks overlapping hosts sequentially to avoid self-interference", () => + Effect.gen(function* () { + let inFlightCount = 0; + const calls: Array<[number, string]> = []; + + const available = yield* checkPortAvailabilityOnHosts( + 13_773, + ["127.0.0.1", "0.0.0.0", "::"], + (port, host) => + Effect.promise(async () => { + calls.push([port, host]); + inFlightCount += 1; + const overlapped = inFlightCount > 1; + await Promise.resolve(); + inFlightCount -= 1; + return !overlapped; + }), + ); + + assert.equal(available, true); + assert.deepStrictEqual(calls, [ + [13_773, "127.0.0.1"], + [13_773, "0.0.0.0"], + [13_773, "::"], + ]); }), ); }); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index dc72bf29ec..59f9f15b3b 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -222,13 +222,28 @@ function portPairForOffset(offset: number): { }; } +export function checkPortAvailabilityOnHosts( + port: number, + hosts: ReadonlyArray, + canListenOnHost: (port: number, host: string) => Effect.Effect, +): Effect.Effect { + return Effect.gen(function* () { + for (const host of hosts) { + if (!(yield* canListenOnHost(port, host))) { + return false; + } + } + + return true; + }); +} + const defaultCheckPortAvailability: PortAvailabilityCheck = (port) => Effect.gen(function* () { const net = yield* NetService; - const availability = yield* Effect.all( - DEV_PORT_PROBE_HOSTS.map((host) => net.canListenOnHost(port, host)), + return yield* checkPortAvailabilityOnHosts(port, DEV_PORT_PROBE_HOSTS, (candidatePort, host) => + net.canListenOnHost(candidatePort, host), ); - return availability.every(Boolean); }); interface FindFirstAvailableOffsetInput {