Skip to content
Closed
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
57 changes: 57 additions & 0 deletions apps/desktop/src/app/DesktopApp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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 * as NetService from "@t3tools/shared/Net";

import { resolveDesktopBackendPort } from "./DesktopApp.ts";

// Every host probe answers "free", which is what the Electron main process
// really sees on Windows while a WSL-side listener holds the port.
const makeNetLayer = (probedPorts: number[]) =>
Layer.succeed(NetService.NetService, {
canListenOnHost: (port) =>
Effect.sync(() => {
probedPorts.push(port);
return true;
}),
isPortAvailableOnLoopback: () => Effect.succeed(true),
hasListenerOnHost: () => Effect.succeed(false),
reserveLoopbackPort: () => Effect.succeed(41_773),
findAvailablePort: (preferred) => Effect.succeed(preferred),
} satisfies NetService.NetService["Service"]);

describe("resolveDesktopBackendPort", () => {
it.effect("takes the default port when nothing is known about a distro", () =>
Effect.gen(function* () {
const selection = yield* resolveDesktopBackendPort(Option.none(), new Set());

assert.equal(selection.port, 3773);
assert.isTrue(selection.selectedByScan);
}).pipe(Effect.provide(makeNetLayer([]))),
);

it.effect("skips a port the WSL distro already listens on", () => {
const probedPorts: number[] = [];

return Effect.gen(function* () {
const selection = yield* resolveDesktopBackendPort(Option.none(), new Set([3773]));

assert.equal(selection.port, 3774);
assert.isTrue(selection.selectedByScan);
// 3773 is rejected on the distro's evidence alone; the Windows-side bind
// check would have called it free and handed it to the WSL backend.
assert.notInclude(probedPorts, 3773);
}).pipe(Effect.provide(makeNetLayer(probedPorts)));
});

it.effect("leaves an explicitly configured port alone", () =>
Effect.gen(function* () {
const selection = yield* resolveDesktopBackendPort(Option.some(9999), new Set([9999]));

assert.equal(selection.port, 9999);
assert.isFalse(selection.selectedByScan);
}).pipe(Effect.provide(makeNetLayer([]))),
);
});
64 changes: 53 additions & 11 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";
import * as DesktopState from "./DesktopState.ts";
import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts";
import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";

const DEFAULT_DESKTOP_BACKEND_PORT = 3773;
const MAX_TCP_PORT = 65_535;
Expand Down Expand Up @@ -66,8 +67,14 @@ const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } =
const { logInfo: logStartupInfo, logError: logStartupError } =
DesktopObservability.makeComponentLogger("desktop-startup");

const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* (
// `portsHeldInDistro` is non-empty only when the primary backend will bind
// inside a WSL distro. The host probes below run in the Electron main process,
// which is the Windows side, and WSL2's localhost forwarding does not reserve a
// WSL-side listener's port in the Windows port namespace — so a port that binds
// fine here can still be taken in the distro the backend actually runs in.
export const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* (
configuredPort: Option.Option<number>,
portsHeldInDistro: ReadonlySet<number>,
) {
if (Option.isSome(configuredPort)) {
return {
Expand All @@ -78,6 +85,7 @@ const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(functio

const net = yield* NetService.NetService;
for (let port = DEFAULT_DESKTOP_BACKEND_PORT; port <= MAX_TCP_PORT; port += 1) {
if (portsHeldInDistro.has(port)) continue;
let availableOnEveryHost = true;

for (const host of DESKTOP_BACKEND_PORT_PROBE_HOSTS) {
Expand Down Expand Up @@ -147,14 +155,54 @@ const bootstrap = Effect.gen(function* () {
const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
const wslBackend = yield* DesktopWslBackend.DesktopWslBackend;
const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* logBootstrapInfo("bootstrap start");

if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) {
return yield* new DesktopDevelopmentBackendPortRequiredError();
}

const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort);
const settings = yield* desktopSettings.get;
// wsl-only mode hands this port to a backend that binds inside the distro, so
// the port has to be free on both sides. Ask the distro what it already holds
// before scanning; every consumer of the port (the renderer origin registered
// below, the advertised endpoints, the backend's own bind) derives from the
// single value chosen here, so this is the only place it can be corrected.
// Both steps are skipped once something has already asked the app to quit:
// the splash would paint a window on the way out, and the probe's wsl.exe
// cold start is seconds of work for a port nobody will bind.
const startingWslPrimary =
settings.wslOnly === true &&
settings.wslBackendEnabled === true &&
!(yield* Ref.get(state.quitting));
if (startingWslPrimary) {
// In wsl-only mode the renderer is served by the WSL backend, which can be
// slow to cold-boot — show a "Connecting to WSL" splash instead of
// presenting no window until WSL is ready. (Dual mode opens fast off the
// Windows primary, so no splash there.) It goes up before the port probe
// below because that probe is what cold-starts the VM.
yield* desktopWindow.showConnectingSplash;
}
const distroPorts =
startingWslPrimary && (yield* wslEnvironment.isAvailable)
? yield* wslEnvironment.probeListeningPorts(settings.wslDistro)
: Option.none<ReadonlySet<number>>();
if (startingWslPrimary && Option.isNone(distroPorts)) {
// Worth a warning rather than a quiet field on the info line below: the
// fallback is the Windows-only scan, which for a WSL primary is exactly the
// unsound check this probe replaces. If the backend then dies on
// EADDRINUSE, this is the line that explains why we did not know better.
yield* logBootstrapWarning(
"could not read the WSL distro's listening ports; falling back to a Windows-only port scan",
{ distro: settings.wslDistro },
);
}

const backendPortSelection = yield* resolveDesktopBackendPort(
environment.configuredBackendPort,
Option.getOrElse(distroPorts, () => new Set<number>()),
);
const backendPort = backendPortSelection.port;
yield* logBootstrapInfo(
backendPortSelection.selectedByScan
Expand All @@ -163,10 +211,11 @@ const bootstrap = Effect.gen(function* () {
{
port: backendPort,
...(backendPortSelection.selectedByScan ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}),
// A failed probe is not a guarantee that the distro is clear; the child
// can still lose the race and hit EADDRINUSE at launch.
...(startingWslPrimary ? { wslDistroPortsProbed: Option.isSome(distroPorts) } : {}),
},
);

const settings = yield* desktopSettings.get;
if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) {
yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", {
mode: settings.serverExposureMode,
Expand Down Expand Up @@ -201,13 +250,6 @@ const bootstrap = Effect.gen(function* () {
yield* logBootstrapInfo("bootstrap ipc handlers registered");

if (!(yield* Ref.get(state.quitting))) {
// In wsl-only mode the renderer is served by the WSL backend, which can be
// slow to cold-boot — show a "Connecting to WSL" splash immediately so the
// app feels responsive instead of presenting no window until WSL is ready.
// (Dual mode opens fast off the Windows primary, so no splash there.)
if (settings.wslOnly === true && settings.wslBackendEnabled === true) {
yield* desktopWindow.showConnectingSplash;
}
yield* primaryBackend.start;
yield* logBootstrapInfo("bootstrap backend start requested");
// Bring up the WSL backend if the user previously enabled it. The
Expand Down
73 changes: 73 additions & 0 deletions apps/desktop/src/wsl/DesktopWslBackend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,79 @@ describe("DesktopWslBackend", () => {
);
});

it.effect("allocates a secondary port the WSL distro is not already listening on", () => {
const probedDistros: (string | null)[] = [];
let resolveWslInput: { readonly port: number; readonly distro: string | null } | undefined;
const primary = makeStubInstance({
id: DesktopBackendPool.PRIMARY_INSTANCE_ID,
label: "Windows",
snapshot: primarySnapshot,
});
const wsl = makeStubInstance({
id: DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"),
label: "WSL (Ubuntu)",
snapshot: primarySnapshot,
});
const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, {
get: () => Effect.succeed(Option.none<DesktopBackendPool.DesktopBackendInstance>()),
list: Effect.succeed([primary]),
primary: Effect.succeed(primary),
register: () => Effect.succeed(wsl),
unregister: () => Effect.die("unexpected unregister"),
} satisfies DesktopBackendPool.DesktopBackendPool["Service"]);
const recordingConfigurationLayer = Layer.succeed(
DesktopBackendConfiguration.DesktopBackendConfiguration,
{
resolvePrimary: Effect.die("unexpected resolvePrimary"),
resolvePrimaryLabel: Effect.succeed("Windows"),
// startNew calls this while building the pool spec, so recording here
// captures the allocated port without running the returned effect.
resolveWsl: (input) => {
resolveWslInput = input;
return Effect.die("unexpected resolveWsl");
},
} satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"],
);

return Effect.gen(function* () {
const backend = yield* DesktopWslBackend.DesktopWslBackend;

yield* backend.reconcile;

// The scan starts one above the primary's 3773; 3774 and 3775 look free
// from Windows but are already held inside the distro that will bind.
assert.deepEqual(resolveWslInput, { port: 3776, distro: "Ubuntu" });
// One wsl.exe round trip for the whole scan, not one per candidate.
assert.deepEqual(probedDistros, ["Ubuntu"]);
}).pipe(
Effect.provide(
DesktopWslBackend.layer.pipe(
Layer.provideMerge(poolLayer),
Layer.provideMerge(recordingConfigurationLayer),
Layer.provideMerge(serverExposureLayer),
Layer.provideMerge(netLayer),
Layer.provideMerge(
DesktopAppSettings.layerTest({
...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
wslBackendEnabled: true,
wslDistro: "Ubuntu",
wslOnly: false,
}),
),
Layer.provideMerge(
DesktopWslEnvironment.layerTest({
isAvailable: true,
probeListeningPorts: (distro) => {
probedDistros.push(distro);
return Option.some(new Set([3774, 3775]));
},
}),
),
),
),
);
});

it.effect("retries an unchanged WSL instance when it is idle after failed preflight", () => {
let startCount = 0;
const primary = makeStubInstance({
Expand Down
16 changes: 14 additions & 2 deletions apps/desktop/src/wsl/DesktopWslBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
// avoid colliding with the primary or with a previously-registered WSL
// instance that's still tearing down. The scan only checks loopback
// (127.0.0.1) since the WSL backend is loopback-only — the primary
// owns LAN exposure when the user opts in.
// owns LAN exposure when the user opts in — and skips ports the distro
// itself already listens on, which the Windows-side check cannot see.

import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
Expand Down Expand Up @@ -77,11 +78,15 @@ const buildLabel = (distro: string | null): string =>
// Loopback-only port scan starting one above the primary's port. The
// WSL backend is reachable via 127.0.0.1 from Windows (wslhost
// auto-forwards), so we only need to verify the IPv4 loopback can bind.
// `portsHeldInDistro` covers the other half: the backend binds inside the
// distro, whose listeners are invisible to a Windows-side bind check.
const scanForWslPort = Effect.fn("desktop.wslBackend.scanForWslPort")(function* (
startPort: number,
portsHeldInDistro: ReadonlySet<number>,
): Effect.fn.Return<number, NetService.NetError, NetService.NetService> {
const net = yield* NetService.NetService;
for (let port = startPort; port <= MAX_TCP_PORT; port += 1) {
if (portsHeldInDistro.has(port)) continue;
if (yield* net.canListenOnHost(port, "127.0.0.1")) {
return port;
}
Expand Down Expand Up @@ -136,7 +141,14 @@ export const layer = Layer.effect(
readonly distro: string | null;
}) {
const primaryConfig = yield* serverExposure.backendConfig;
const port = yield* scanForWslPort(primaryConfig.port + 1).pipe(
// reconcile pre-warms the distro before calling us, so this is one extra
// round trip against an already-running VM. A failed probe (None) leaves
// the Windows-only scan in place rather than blocking the backend.
const distroPorts = yield* wslEnvironment.probeListeningPorts(input.distro);
const port = yield* scanForWslPort(
primaryConfig.port + 1,
Option.getOrElse(distroPorts, () => new Set<number>()),
).pipe(
Effect.provideService(NetService.NetService, net),
Effect.map((value) => Option.some(value)),
Effect.catch((error) =>
Expand Down
57 changes: 57 additions & 0 deletions apps/desktop/src/wsl/DesktopWslEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
Expand All @@ -19,7 +20,9 @@ import {
parseNodeVersion,
parseResolvedPath,
parseToolchainReport,
parseWslListeningPorts,
probeWslDistros,
probeWslListeningPorts,
} from "./DesktopWslEnvironment.ts";

const encoder = new TextEncoder();
Expand Down Expand Up @@ -259,3 +262,57 @@ describe("formatMissingToolsReason", () => {
expect(reason).not.toContain("nvm");
});
});

describe("parseWslListeningPorts", () => {
const procNetTcp = ` sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
0: FEFFFF0A:0035 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 8206 1 0000000000000000 100 0 0 10 0
1: 00000000:0EBD 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 75100 1 0000000000000000 100 0 0 10 0
2: 0100007F:0EBD 0100007F:E03C 01 00000000:00000000 00:00000000 00000000 1000 0 57112 1 0000000000000000 20 4 31 10 -1
`;

const procNetTcp6 = ` sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
0: 00000000000000000000000000000000:6066 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 201935 1 0000000000000000 100 0 0 10 0
`;

it("collects listening ports from both address families and ignores live connections", () => {
const ports = parseWslListeningPorts(`${procNetTcp}${procNetTcp6}`);

expect(Option.isSome(ports)).toBe(true);
// 0EBD is the 3773 the background `t3` service holds; E03C is a client
// socket on the same port and must not be mistaken for a listener.
expect([...Option.getOrThrow(ports)].sort((a, b) => a - b)).toEqual([53, 3773, 24_678]);
});

it("reads an empty table as nothing listening", () => {
const header = procNetTcp.split("\n")[0] ?? "";

expect(parseWslListeningPorts(header)).toEqual(Option.some(new Set<number>()));
});

it("reports unknown rather than empty when no table was read", () => {
expect(parseWslListeningPorts("")).toEqual(Option.none());
expect(parseWslListeningPorts("wsl.exe: no such distribution\n")).toEqual(Option.none());
});
});

describe("probeWslListeningPorts", () => {
it.effect("gives a cold WSL VM the same ten seconds pre-warm allows", () => {
const layer = Layer.merge(
TestClock.layer(),
// Never exits, standing in for a distro still booting.
Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, makeDistroListSpawner({})),
);
return Effect.gen(function* () {
const fiber = yield* probeWslListeningPorts("Ubuntu").pipe(Effect.forkScoped);
yield* Effect.yieldNow;

yield* TestClock.adjust(Duration.seconds(9));
expect(fiber.pollUnsafe()).toBeUndefined();

yield* TestClock.adjust(Duration.seconds(1));
// Degrades to "unknown", never to "nothing is listening" -- an empty set
// would hand the scan a port the distro may already hold.
expect(yield* Fiber.join(fiber)).toEqual(Option.none());
}).pipe(Effect.provide(layer));
});
});
Loading
Loading