| author | |
| committer | |
| log | e82b98738697e58644fef8d0836bf730c2b45613 |
| tree | 559dbc5f07acb618995e5eda76e6a74be40a5667 |
| parent | ea9e61b26671572ccefd49d3ba14b270d2fe1fbe |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 115 insertions(+), 8 deletions(-)
apps/desktop/src/backendPort.test.ts created+56| ... | ... | @@ -0,0 +1,56 @@ |
| 1 | import { describe, expect, it, vi } from "vitest"; | |
| 2 | ||
| 3 | import { resolveDesktopBackendPort } from "./backendPort"; | |
| 4 | ||
| 5 | describe("resolveDesktopBackendPort", () => { | |
| 6 | it("returns the starting port when it is available", async () => { | |
| 7 | const canListenOnHost = vi.fn(async (port: number) => port === 3773); | |
| 8 | ||
| 9 | await expect( | |
| 10 | resolveDesktopBackendPort({ | |
| 11 | host: "127.0.0.1", | |
| 12 | startPort: 3773, | |
| 13 | canListenOnHost, | |
| 14 | }), | |
| 15 | ).resolves.toBe(3773); | |
| 16 | ||
| 17 | expect(canListenOnHost).toHaveBeenCalledTimes(1); | |
| 18 | expect(canListenOnHost).toHaveBeenCalledWith(3773, "127.0.0.1"); | |
| 19 | }); | |
| 20 | ||
| 21 | it("increments sequentially until it finds an available port", async () => { | |
| 22 | const canListenOnHost = vi.fn(async (port: number) => port === 3775); | |
| 23 | ||
| 24 | await expect( | |
| 25 | resolveDesktopBackendPort({ | |
| 26 | host: "127.0.0.1", | |
| 27 | startPort: 3773, | |
| 28 | canListenOnHost, | |
| 29 | }), | |
| 30 | ).resolves.toBe(3775); | |
| 31 | ||
| 32 | expect(canListenOnHost.mock.calls).toEqual([ | |
| 33 | [3773, "127.0.0.1"], | |
| 34 | [3774, "127.0.0.1"], | |
| 35 | [3775, "127.0.0.1"], | |
| 36 | ]); | |
| 37 | }); | |
| 38 | ||
| 39 | it("fails when the scan range is exhausted", async () => { | |
| 40 | const canListenOnHost = vi.fn(async () => false); | |
| 41 | ||
| 42 | await expect( | |
| 43 | resolveDesktopBackendPort({ | |
| 44 | host: "127.0.0.1", | |
| 45 | startPort: 65534, | |
| 46 | maxPort: 65535, | |
| 47 | canListenOnHost, | |
| 48 | }), | |
| 49 | ).rejects.toThrow("No desktop backend port is available on 127.0.0.1 between 65534 and 65535"); | |
| 50 | ||
| 51 | expect(canListenOnHost.mock.calls).toEqual([ | |
| 52 | [65534, "127.0.0.1"], | |
| 53 | [65535, "127.0.0.1"], | |
| 54 | ]); | |
| 55 | }); | |
| 56 | }); |
apps/desktop/src/backendPort.ts created+53| ... | ... | @@ -0,0 +1,53 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import { NetService } from "@t3tools/shared/Net"; | |
| 3 | ||
| 4 | export const DEFAULT_DESKTOP_BACKEND_PORT = 3773; | |
| 5 | const MAX_TCP_PORT = 65_535; | |
| 6 | ||
| 7 | export interface ResolveDesktopBackendPortOptions { | |
| 8 | readonly host: string; | |
| 9 | readonly startPort?: number; | |
| 10 | readonly maxPort?: number; | |
| 11 | readonly canListenOnHost?: (port: number, host: string) => Promise<boolean>; | |
| 12 | } | |
| 13 | ||
| 14 | const defaultCanListenOnHost = async (port: number, host: string): Promise<boolean> => | |
| 15 | Effect.service(NetService).pipe( | |
| 16 | Effect.flatMap((net) => net.canListenOnHost(port, host)), | |
| 17 | Effect.provide(NetService.layer), | |
| 18 | Effect.runPromise, | |
| 19 | ); | |
| 20 | ||
| 21 | const isValidPort = (port: number): boolean => | |
| 22 | Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT; | |
| 23 | ||
| 24 | export async function resolveDesktopBackendPort({ | |
| 25 | host, | |
| 26 | startPort = DEFAULT_DESKTOP_BACKEND_PORT, | |
| 27 | maxPort = MAX_TCP_PORT, | |
| 28 | canListenOnHost = defaultCanListenOnHost, | |
| 29 | }: ResolveDesktopBackendPortOptions): Promise<number> { | |
| 30 | if (!isValidPort(startPort)) { | |
| 31 | throw new Error(`Invalid desktop backend start port: ${startPort}`); | |
| 32 | } | |
| 33 | ||
| 34 | if (!isValidPort(maxPort)) { | |
| 35 | throw new Error(`Invalid desktop backend max port: ${maxPort}`); | |
| 36 | } | |
| 37 | ||
| 38 | if (maxPort < startPort) { | |
| 39 | throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`); | |
| 40 | } | |
| 41 | ||
| 42 | // Keep desktop startup predictable across app restarts by probing upward from | |
| 43 | // the same preferred port instead of picking a fresh ephemeral port. | |
| 44 | for (let port = startPort; port <= maxPort; port += 1) { | |
| 45 | if (await canListenOnHost(port, host)) { | |
| 46 | return port; | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | throw new Error( | |
| 51 | `No desktop backend port is available on ${host} between ${startPort} and ${maxPort}`, | |
| 52 | ); | |
| 53 | } |
apps/desktop/src/main.ts+6-8| ... | ... | @@ -18,7 +18,6 @@ import { |
| 18 | 18 | shell, |
| 19 | 19 | } from "electron"; |
| 20 | 20 | import type { MenuItemConstructorOptions } from "electron"; |
| 21 | import * as Effect from "effect/Effect"; | |
| 22 | 21 | import type { |
| 23 | 22 | ClientSettings, |
| 24 | 23 | DesktopTheme, |
| ... | ... | @@ -32,9 +31,9 @@ import type { |
| 32 | 31 | import { autoUpdater } from "electron-updater"; |
| 33 | 32 | |
| 34 | 33 | import type { ContextMenuItem } from "@t3tools/contracts"; |
| 35 | import { NetService } from "@t3tools/shared/Net"; | |
| 36 | 34 | import { RotatingFileSink } from "@t3tools/shared/logging"; |
| 37 | 35 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 36 | import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort"; | |
| 38 | 37 | import { |
| 39 | 38 | DEFAULT_DESKTOP_SETTINGS, |
| 40 | 39 | readDesktopSettings, |
| ... | ... | @@ -1771,14 +1770,13 @@ async function bootstrap(): Promise<void> { |
| 1771 | 1770 | |
| 1772 | 1771 | backendPort = |
| 1773 | 1772 | configuredBackendPort ?? |
| 1774 | (await Effect.service(NetService).pipe( | |
| 1775 | Effect.flatMap((net) => net.reserveLoopbackPort(DESKTOP_LOOPBACK_HOST)), | |
| 1776 | Effect.provide(NetService.layer), | |
| 1777 | Effect.runPromise, | |
| 1778 | )); | |
| 1773 | (await resolveDesktopBackendPort({ | |
| 1774 | host: DESKTOP_LOOPBACK_HOST, | |
| 1775 | startPort: DEFAULT_DESKTOP_BACKEND_PORT, | |
| 1776 | })); | |
| 1779 | 1777 | writeDesktopLogHeader( |
| 1780 | 1778 | configuredBackendPort === undefined |
| 1781 | ? `reserved backend port via NetService port=${backendPort}` | |
| 1779 | ? `selected backend port via sequential scan startPort=${DEFAULT_DESKTOP_BACKEND_PORT} port=${backendPort}` | |
| 1782 | 1780 | : `using configured backend port port=${backendPort}`, |
| 1783 | 1781 | ); |
| 1784 | 1782 | backendBootstrapToken = Crypto.randomBytes(24).toString("hex"); |