authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-09 21:51:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-09 21:51:04-07:00
loge82b98738697e58644fef8d0836bf730c2b45613
tree559dbc5f07acb618995e5eda76e6a74be40a5667
parentea9e61b26671572ccefd49d3ba14b270d2fe1fbe
signature Signed by PGP key B5690EEEBB952194

Select desktop backend port by sequential scan (#1870)


3 files changed, 115 insertions(+), 8 deletions(-)

apps/desktop/src/backendPort.test.ts created+56
...@@ -0,0 +1,56 @@
1import { describe, expect, it, vi } from "vitest";
2
3import { resolveDesktopBackendPort } from "./backendPort";
4
5describe("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 @@
1import * as Effect from "effect/Effect";
2import { NetService } from "@t3tools/shared/Net";
3
4export const DEFAULT_DESKTOP_BACKEND_PORT = 3773;
5const MAX_TCP_PORT = 65_535;
6
7export 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
14const 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
21const isValidPort = (port: number): boolean =>
22 Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT;
23
24export 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,7 +18,6 @@ import {
18 shell,18 shell,
19} from "electron";19} from "electron";
20import type { MenuItemConstructorOptions } from "electron";20import type { MenuItemConstructorOptions } from "electron";
21import * as Effect from "effect/Effect";
22import type {21import type {
23 ClientSettings,22 ClientSettings,
24 DesktopTheme,23 DesktopTheme,
...@@ -32,9 +31,9 @@ import type {...@@ -32,9 +31,9 @@ import type {
32import { autoUpdater } from "electron-updater";31import { autoUpdater } from "electron-updater";
3332
34import type { ContextMenuItem } from "@t3tools/contracts";33import type { ContextMenuItem } from "@t3tools/contracts";
35import { NetService } from "@t3tools/shared/Net";
36import { RotatingFileSink } from "@t3tools/shared/logging";34import { RotatingFileSink } from "@t3tools/shared/logging";
37import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";35import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
36import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort";
38import {37import {
39 DEFAULT_DESKTOP_SETTINGS,38 DEFAULT_DESKTOP_SETTINGS,
40 readDesktopSettings,39 readDesktopSettings,
...@@ -1771,14 +1770,13 @@ async function bootstrap(): Promise<void> {...@@ -1771,14 +1770,13 @@ async function bootstrap(): Promise<void> {
17711770
1772 backendPort =1771 backendPort =
1773 configuredBackendPort ??1772 configuredBackendPort ??
1774 (await Effect.service(NetService).pipe(1773 (await resolveDesktopBackendPort({
1775 Effect.flatMap((net) => net.reserveLoopbackPort(DESKTOP_LOOPBACK_HOST)),1774 host: DESKTOP_LOOPBACK_HOST,
1776 Effect.provide(NetService.layer),1775 startPort: DEFAULT_DESKTOP_BACKEND_PORT,
1777 Effect.runPromise,1776 }));
1778 ));
1779 writeDesktopLogHeader(1777 writeDesktopLogHeader(
1780 configuredBackendPort === undefined1778 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 : `using configured backend port port=${backendPort}`,1780 : `using configured backend port port=${backendPort}`,
1783 );1781 );
1784 backendBootstrapToken = Crypto.randomBytes(24).toString("hex");1782 backendBootstrapToken = Crypto.randomBytes(24).toString("hex");