Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions apps/desktop/src/backendPort.test.ts
Original file line number Diff line number Diff line change
@@ -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"],
]);
});
});
53 changes: 53 additions & 0 deletions apps/desktop/src/backendPort.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
}

const defaultCanListenOnHost = async (port: number, host: string): Promise<boolean> =>
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<number> {
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}`,
);
}
14 changes: 6 additions & 8 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
shell,
} from "electron";
import type { MenuItemConstructorOptions } from "electron";
import * as Effect from "effect/Effect";
import type {
ClientSettings,
DesktopTheme,
Expand All @@ -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,
Expand Down Expand Up @@ -1771,14 +1770,13 @@ async function bootstrap(): Promise<void> {

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");
Expand Down
Loading