diff --git a/apps/desktop/src/backendPort.test.ts b/apps/desktop/src/backendPort.test.ts new file mode 100644 index 0000000000..57a066eb58 --- /dev/null +++ b/apps/desktop/src/backendPort.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; + +import { resolveDesktopBackendPort } from "./backendPort"; + +describe("resolveDesktopBackendPort", () => { + it("returns the starting port when it is available", async () => { + const canListenOnHost = vi.fn(async (port: number) => port === 3773); + + await expect( + resolveDesktopBackendPort({ + host: "127.0.0.1", + startPort: 3773, + canListenOnHost, + }), + ).resolves.toBe(3773); + + expect(canListenOnHost).toHaveBeenCalledTimes(1); + expect(canListenOnHost).toHaveBeenCalledWith(3773, "127.0.0.1"); + }); + + it("increments sequentially until it finds an available port", async () => { + const canListenOnHost = vi.fn(async (port: number) => port === 3775); + + await expect( + resolveDesktopBackendPort({ + host: "127.0.0.1", + startPort: 3773, + canListenOnHost, + }), + ).resolves.toBe(3775); + + expect(canListenOnHost.mock.calls).toEqual([ + [3773, "127.0.0.1"], + [3774, "127.0.0.1"], + [3775, "127.0.0.1"], + ]); + }); + + it("fails when the scan range is exhausted", async () => { + const canListenOnHost = vi.fn(async () => false); + + await expect( + resolveDesktopBackendPort({ + host: "127.0.0.1", + startPort: 65534, + maxPort: 65535, + canListenOnHost, + }), + ).rejects.toThrow("No desktop backend port is available on 127.0.0.1 between 65534 and 65535"); + + expect(canListenOnHost.mock.calls).toEqual([ + [65534, "127.0.0.1"], + [65535, "127.0.0.1"], + ]); + }); +}); diff --git a/apps/desktop/src/backendPort.ts b/apps/desktop/src/backendPort.ts new file mode 100644 index 0000000000..e70272c397 --- /dev/null +++ b/apps/desktop/src/backendPort.ts @@ -0,0 +1,53 @@ +import * as Effect from "effect/Effect"; +import { NetService } from "@t3tools/shared/Net"; + +export const DEFAULT_DESKTOP_BACKEND_PORT = 3773; +const MAX_TCP_PORT = 65_535; + +export interface ResolveDesktopBackendPortOptions { + readonly host: string; + readonly startPort?: number; + readonly maxPort?: number; + readonly canListenOnHost?: (port: number, host: string) => Promise; +} + +const defaultCanListenOnHost = async (port: number, host: string): Promise => + Effect.service(NetService).pipe( + Effect.flatMap((net) => net.canListenOnHost(port, host)), + Effect.provide(NetService.layer), + Effect.runPromise, + ); + +const isValidPort = (port: number): boolean => + Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT; + +export async function resolveDesktopBackendPort({ + host, + startPort = DEFAULT_DESKTOP_BACKEND_PORT, + maxPort = MAX_TCP_PORT, + canListenOnHost = defaultCanListenOnHost, +}: ResolveDesktopBackendPortOptions): Promise { + if (!isValidPort(startPort)) { + throw new Error(`Invalid desktop backend start port: ${startPort}`); + } + + if (!isValidPort(maxPort)) { + throw new Error(`Invalid desktop backend max port: ${maxPort}`); + } + + if (maxPort < startPort) { + throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`); + } + + // 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)) { + return port; + } + } + + throw new Error( + `No desktop backend port is available on ${host} between ${startPort} and ${maxPort}`, + ); +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0c3268f492..310b973458 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -18,7 +18,6 @@ import { shell, } from "electron"; import type { MenuItemConstructorOptions } from "electron"; -import * as Effect from "effect/Effect"; import type { ClientSettings, DesktopTheme, @@ -32,9 +31,9 @@ import type { import { autoUpdater } from "electron-updater"; import type { ContextMenuItem } from "@t3tools/contracts"; -import { NetService } from "@t3tools/shared/Net"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort"; import { DEFAULT_DESKTOP_SETTINGS, readDesktopSettings, @@ -1771,14 +1770,13 @@ async function bootstrap(): Promise { backendPort = configuredBackendPort ?? - (await Effect.service(NetService).pipe( - Effect.flatMap((net) => net.reserveLoopbackPort(DESKTOP_LOOPBACK_HOST)), - Effect.provide(NetService.layer), - Effect.runPromise, - )); + (await resolveDesktopBackendPort({ + host: DESKTOP_LOOPBACK_HOST, + startPort: DEFAULT_DESKTOP_BACKEND_PORT, + })); writeDesktopLogHeader( configuredBackendPort === undefined - ? `reserved backend port via NetService port=${backendPort}` + ? `selected backend port via sequential scan startPort=${DEFAULT_DESKTOP_BACKEND_PORT} port=${backendPort}` : `using configured backend port port=${backendPort}`, ); backendBootstrapToken = Crypto.randomBytes(24).toString("hex");