From 6a426bdd37a64ea40607bdb9780c5d1285c6d1e7 Mon Sep 17 00:00:00 2001 From: Stef Date: Tue, 1 Sep 2026 05:55:58 +0100 Subject: [PATCH] fix(desktop): refuse same-home port fallback --- apps/desktop/src/app/DesktopApp.ts | 58 +++++++-------- apps/desktop/src/app/DesktopAppErrors.test.ts | 74 ++++++++++++++++--- 2 files changed, 89 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 4101840530f6..3d882b1b77d1 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -30,7 +30,6 @@ import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; const DEFAULT_DESKTOP_BACKEND_PORT = 3773; -const MAX_TCP_PORT = 65_535; const DESKTOP_BACKEND_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::"] as const; const makeDesktopRunId = Crypto.Crypto.pipe( @@ -38,16 +37,19 @@ const makeDesktopRunId = Crypto.Crypto.pipe( Effect.map((value) => value.replaceAll("-", "").slice(0, 12)), ); -export class DesktopBackendPortUnavailableError extends Schema.TaggedErrorClass()( - "DesktopBackendPortUnavailableError", +export class DesktopBackendPortInUseError extends Schema.TaggedErrorClass()( + "DesktopBackendPortInUseError", { - startPort: Schema.Int, - maxPort: Schema.Int, + port: Schema.Int, hosts: Schema.Array(Schema.String), }, ) { override get message(): string { - return `No desktop backend port is available on hosts ${this.hosts.join(", ")} between ${this.startPort} and ${this.maxPort}.`; + return [ + `Desktop backend port ${this.port} is already in use on ${this.hosts.join(", ")}.`, + "T3 Code will not start another backend on a fallback port while using the same data directory.", + "Connect to the running T3 Code server, stop it cleanly, or use a different T3CODE_HOME.", + ].join("\n"); } } @@ -66,40 +68,31 @@ const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } = const { logInfo: logStartupInfo, logError: logStartupError } = DesktopObservability.makeComponentLogger("desktop-startup"); -const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* ( +export const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* ( configuredPort: Option.Option, ) { if (Option.isSome(configuredPort)) { return { port: configuredPort.value, - selectedByScan: false, } as const; } const net = yield* NetService.NetService; - for (let port = DEFAULT_DESKTOP_BACKEND_PORT; port <= MAX_TCP_PORT; port += 1) { - let availableOnEveryHost = true; - - for (const host of DESKTOP_BACKEND_PORT_PROBE_HOSTS) { - if (!(yield* net.canListenOnHost(port, host))) { - availableOnEveryHost = false; - break; - } - } - - if (availableOnEveryHost) { - return { - port, - selectedByScan: true, - } as const; + const unavailableHosts: string[] = []; + for (const host of DESKTOP_BACKEND_PORT_PROBE_HOSTS) { + if (!(yield* net.canListenOnHost(DEFAULT_DESKTOP_BACKEND_PORT, host))) { + unavailableHosts.push(host); } } - - return yield* new DesktopBackendPortUnavailableError({ - startPort: DEFAULT_DESKTOP_BACKEND_PORT, - maxPort: MAX_TCP_PORT, - hosts: DESKTOP_BACKEND_PORT_PROBE_HOSTS, - }); + if (unavailableHosts.length > 0) { + return yield* new DesktopBackendPortInUseError({ + port: DEFAULT_DESKTOP_BACKEND_PORT, + hosts: unavailableHosts, + }); + } + return { + port: DEFAULT_DESKTOP_BACKEND_PORT, + } as const; }); const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupError")(function* ( @@ -157,12 +150,11 @@ const bootstrap = Effect.gen(function* () { const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); const backendPort = backendPortSelection.port; yield* logBootstrapInfo( - backendPortSelection.selectedByScan - ? "selected backend port via sequential scan" - : "using configured backend port", + Option.isSome(environment.configuredBackendPort) + ? "using configured backend port" + : "using default backend port", { port: backendPort, - ...(backendPortSelection.selectedByScan ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}), }, ); diff --git a/apps/desktop/src/app/DesktopAppErrors.test.ts b/apps/desktop/src/app/DesktopAppErrors.test.ts index 666c36d391de..d709637f6939 100644 --- a/apps/desktop/src/app/DesktopAppErrors.test.ts +++ b/apps/desktop/src/app/DesktopAppErrors.test.ts @@ -1,27 +1,81 @@ +import * as NetService from "@t3tools/shared/Net"; import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { - DesktopBackendPortUnavailableError, + DesktopBackendPortInUseError, DesktopDevelopmentBackendPortRequiredError, + resolveDesktopBackendPort, } from "./DesktopApp.ts"; +const netLayer = (canListenOnHost: (port: number, host: string) => boolean) => + Layer.succeed(NetService.NetService, { + canListenOnHost: (port, host) => Effect.succeed(canListenOnHost(port, host)), + isPortAvailableOnLoopback: () => Effect.die("unexpected isPortAvailableOnLoopback"), + hasListenerOnHost: () => Effect.die("unexpected hasListenerOnHost"), + reserveLoopbackPort: () => Effect.die("unexpected reserveLoopbackPort"), + findAvailablePort: () => Effect.die("unexpected findAvailablePort"), + } satisfies NetService.NetService["Service"]); + describe("DesktopApp errors", () => { - it("preserves unavailable backend port context", () => { - const error = new DesktopBackendPortUnavailableError({ - startPort: 3_773, - maxPort: 65_535, - hosts: ["127.0.0.1", "0.0.0.0", "::"], + it("preserves occupied default-port context", () => { + const error = new DesktopBackendPortInUseError({ + port: 3_773, + hosts: ["127.0.0.1"], }); - assert.equal(error.startPort, 3_773); - assert.equal(error.maxPort, 65_535); - assert.deepEqual(error.hosts, ["127.0.0.1", "0.0.0.0", "::"]); + assert.equal(error.port, 3_773); + assert.deepEqual(error.hosts, ["127.0.0.1"]); assert.equal( error.message, - "No desktop backend port is available on hosts 127.0.0.1, 0.0.0.0, :: between 3773 and 65535.", + [ + "Desktop backend port 3773 is already in use on 127.0.0.1.", + "T3 Code will not start another backend on a fallback port while using the same data directory.", + "Connect to the running T3 Code server, stop it cleanly, or use a different T3CODE_HOME.", + ].join("\n"), + ); + }); + + it.effect("uses the default port when every bind host is available", () => + Effect.gen(function* () { + const selection = yield* resolveDesktopBackendPort(Option.none()); + assert.deepEqual(selection, { port: 3_773 }); + }).pipe(Effect.provide(netLayer(() => true))), + ); + + it.effect("refuses instead of scanning to another same-home port", () => { + const probes: Array<{ readonly port: number; readonly host: string }> = []; + return Effect.gen(function* () { + const failure = yield* resolveDesktopBackendPort(Option.none()).pipe(Effect.flip); + assert.strictEqual(failure._tag, "DesktopBackendPortInUseError"); + if (failure._tag === "DesktopBackendPortInUseError") { + assert.strictEqual(failure.port, 3_773); + assert.deepEqual(failure.hosts, ["127.0.0.1"]); + } + assert.deepEqual(probes, [ + { port: 3_773, host: "127.0.0.1" }, + { port: 3_773, host: "0.0.0.0" }, + { port: 3_773, host: "::" }, + ]); + }).pipe( + Effect.provide( + netLayer((port, host) => { + probes.push({ port, host }); + return host !== "127.0.0.1"; + }), + ), ); }); + it.effect("preserves an explicitly configured backend port", () => + Effect.gen(function* () { + const selection = yield* resolveDesktopBackendPort(Option.some(9_999)); + assert.deepEqual(selection, { port: 9_999 }); + }).pipe(Effect.provide(netLayer(() => false))), + ); + it("reports the required development port", () => { const error = new DesktopDevelopmentBackendPortRequiredError();