diff --git a/.gitignore b/.gitignore index 6478eb300..a31c1e1b9 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ squashfs-root/ .gstack/ dist-electron/ .electron-runtime/ +.claude/ diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index b43d80b8f..7e77495e9 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -5,7 +5,6 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import * as NetService from "@threadlines/shared/Net"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; @@ -22,28 +21,16 @@ import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopStatusIndicator from "../window/DesktopStatusIndicator.ts"; - -const DEFAULT_DESKTOP_BACKEND_PORT = 3773; -const MAX_TCP_PORT = 65_535; -const DESKTOP_LOCAL_ONLY_PORT_PROBE_HOSTS = ["127.0.0.1"] as const; -const DESKTOP_NETWORK_ACCESSIBLE_PORT_PROBE_HOSTS = ["0.0.0.0"] as const; +import { + DEFAULT_DESKTOP_BACKEND_PORT, + desktopBackendPortProbeHosts, + resolveDesktopBackendPort, +} from "../backend/backendPort.ts"; const makeDesktopRunId = randomUUIDv4.pipe( Effect.map((value) => value.replaceAll("-", "").slice(0, 12)), ); -class DesktopBackendPortUnavailableError extends Data.TaggedError( - "DesktopBackendPortUnavailableError", -)<{ - readonly startPort: number; - readonly maxPort: number; - readonly hosts: readonly string[]; -}> { - override get message() { - return `No desktop backend port is available on hosts ${this.hosts.join(", ")} between ${this.startPort} and ${this.maxPort}.`; - } -} - class DesktopDevelopmentBackendPortRequiredError extends Data.TaggedError( "DesktopDevelopmentBackendPortRequiredError", )<{}> { @@ -58,43 +45,6 @@ const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } = const { logInfo: logStartupInfo, logError: logStartupError } = DesktopObservability.makeComponentLogger("desktop-startup"); -const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* (input: { - readonly configuredPort: Option.Option; - readonly probeHosts: readonly string[]; -}) { - if (Option.isSome(input.configuredPort)) { - return { - port: input.configuredPort.value, - selectedByScan: false, - } as const; - } - - const net = yield* NetService.NetService; - for (let port = DEFAULT_DESKTOP_BACKEND_PORT; port <= MAX_TCP_PORT; port += 1) { - let availableOnEveryHost = true; - - for (const host of input.probeHosts) { - if (!(yield* net.canListenOnHost(port, host))) { - availableOnEveryHost = false; - break; - } - } - - if (availableOnEveryHost) { - return { - port, - selectedByScan: true, - } as const; - } - } - - return yield* new DesktopBackendPortUnavailableError({ - startPort: DEFAULT_DESKTOP_BACKEND_PORT, - maxPort: MAX_TCP_PORT, - hosts: input.probeHosts, - }); -}); - const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupError")(function* ( stage: string, error: unknown, @@ -145,10 +95,7 @@ const bootstrap = Effect.gen(function* () { } const settings = yield* desktopSettings.get; - const probeHosts = - settings.serverExposureMode === "network-accessible" - ? DESKTOP_NETWORK_ACCESSIBLE_PORT_PROBE_HOSTS - : DESKTOP_LOCAL_ONLY_PORT_PROBE_HOSTS; + const probeHosts = desktopBackendPortProbeHosts(settings.serverExposureMode); const backendPortSelection = yield* resolveDesktopBackendPort({ configuredPort: environment.configuredBackendPort, probeHosts, @@ -169,7 +116,10 @@ const bootstrap = Effect.gen(function* () { mode: settings.serverExposureMode, }); } - const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); + const serverExposureState = yield* serverExposure.configureFromSettings({ + port: backendPort, + selectedByScan: backendPortSelection.selectedByScan, + }); const backendConfig = yield* serverExposure.backendConfig; yield* logBootstrapInfo("bootstrap resolved backend endpoint", { baseUrl: backendConfig.httpBaseUrl.href, diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts new file mode 100644 index 000000000..42f17ddd1 --- /dev/null +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -0,0 +1,85 @@ +import { assert, describe, it } from "@effect/vitest"; +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 TestClock from "effect/testing/TestClock"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import { + DESKTOP_SHUTDOWN_FAILSAFE_DURATION, + DesktopShutdown, + layerShutdown, + requestDesktopShutdownAndWaitWithFailsafe, +} from "./DesktopLifecycle.ts"; + +const makeElectronAppLayer = (exitCodes: Array) => + Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata read"), + name: Effect.succeed("Threadlines"), + whenReady: Effect.void, + quit: Effect.void, + exit: (code) => + Effect.sync(() => { + exitCodes.push(code); + }), + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + setDockBadge: () => Effect.void, + bounceDock: () => Effect.die("unexpected dock bounce"), + cancelDockBounce: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + on: () => Effect.void, + } satisfies ElectronApp.ElectronAppShape); + +const runHarness = ( + body: ( + exitCodes: ReadonlyArray, + ) => Effect.Effect, +): Effect.Effect => { + const exitCodes: Array = []; + return body(exitCodes).pipe( + Effect.provide( + Layer.mergeAll(layerShutdown, makeElectronAppLayer(exitCodes), TestClock.layer()), + ), + ); +}; + +describe("requestDesktopShutdownAndWaitWithFailsafe", () => { + it.effect("forces the process down when shutdown never completes", () => + runHarness((exitCodes) => + Effect.gen(function* () { + const fiber = yield* Effect.forkChild(requestDesktopShutdownAndWaitWithFailsafe()); + yield* Effect.yieldNow; + + yield* TestClock.adjust(Duration.seconds(14)); + assert.deepEqual([...exitCodes], []); + + yield* TestClock.adjust(Duration.seconds(1)); + yield* Fiber.join(fiber); + assert.deepEqual([...exitCodes], [1]); + }), + ), + ); + + it.effect("stays out of the way when shutdown completes in time", () => + runHarness((exitCodes) => + Effect.gen(function* () { + const shutdown = yield* DesktopShutdown; + const fiber = yield* Effect.forkChild(requestDesktopShutdownAndWaitWithFailsafe()); + yield* Effect.yieldNow; + + yield* shutdown.markComplete; + yield* Fiber.join(fiber); + + yield* TestClock.adjust(DESKTOP_SHUTDOWN_FAILSAFE_DURATION); + assert.deepEqual([...exitCodes], []); + }), + ), + ); +}); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 71177e61b..50213572a 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -2,6 +2,7 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; @@ -89,6 +90,14 @@ function addScopedListener>( ).pipe(Effect.asVoid); } +/** + * How long a quit may wait for the backend to stop before we stop asking + * nicely. A shutdown that never completes leaves a windowless process holding + * the single-instance lock, which makes every later launch look like a dead + * app, so the failsafe has to win eventually. + */ +export const DESKTOP_SHUTDOWN_FAILSAFE_DURATION = Duration.seconds(15); + const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( function* (): Effect.fn.Return { const shutdown = yield* DesktopShutdown; @@ -97,6 +106,26 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo }, ); +/** + * Requests shutdown and waits for it, but never forever: on timeout the process + * is forced down so it cannot linger holding the single-instance lock. A + * shutdown that completes in time is untouched. + */ +export const requestDesktopShutdownAndWaitWithFailsafe = Effect.fn( + "desktop.lifecycle.requestShutdownAndWaitWithFailsafe", +)(function* (): Effect.fn.Return { + const electronApp = yield* ElectronApp.ElectronApp; + yield* requestDesktopShutdownAndWait().pipe( + Effect.timeoutOrElse({ + duration: DESKTOP_SHUTDOWN_FAILSAFE_DURATION, + orElse: () => + logLifecycleError("shutdown did not complete before the failsafe; forcing exit", { + failsafeMs: Duration.toMillis(DESKTOP_SHUTDOWN_FAILSAFE_DURATION), + }).pipe(Effect.andThen(electronApp.exit(1))), + }), + ); +}); + function handleBeforeQuit( event: Electron.Event, runEffect: (effect: Effect.Effect) => Promise, @@ -120,7 +149,7 @@ function handleBeforeQuit( const state = yield* DesktopState.DesktopState; yield* Ref.set(state.quitting, true); yield* logLifecycleInfo("before-quit received"); - yield* requestDesktopShutdownAndWait(); + yield* requestDesktopShutdownAndWaitWithFailsafe(); }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), ).finally(() => { markQuitAllowed(); @@ -145,7 +174,7 @@ function quitFromSignal( const wasQuitting = yield* Ref.getAndSet(state.quitting, true); if (wasQuitting) return; yield* logLifecycleInfo("process signal received", { signal }); - yield* requestDesktopShutdownAndWait(); + yield* requestDesktopShutdownAndWaitWithFailsafe(); yield* electronApp.quit; }).pipe(Effect.withSpan("desktop.lifecycle.processSignal")), ); @@ -162,7 +191,7 @@ export const layer = Layer.succeed( yield* Effect.gen(function* () { yield* Effect.yieldNow; yield* Ref.set(state.quitting, true); - yield* requestDesktopShutdownAndWait(); + yield* requestDesktopShutdownAndWaitWithFailsafe(); if (environment.isDevelopment) { yield* electronApp.exit(75); return; @@ -208,6 +237,15 @@ export const layer = Layer.succeed( yield* electronApp.on("activate", () => { void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate"))); }); + // Only ever fires in the process holding the single-instance lock: a + // second launch hands its argv over and exits, and we surface the window + // it was asking for. `activate` is backend-ready-aware, so a launch + // during startup no-ops and the window arrives at readiness. + yield* electronApp.on("second-instance", () => { + void runEffect( + desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.secondInstance")), + ); + }); yield* electronApp.on("window-all-closed", () => { void runEffect( Effect.gen(function* () { diff --git a/apps/desktop/src/app/desktopUserData.ts b/apps/desktop/src/app/desktopUserData.ts index a526d684f..5fb8f6c3c 100644 --- a/apps/desktop/src/app/desktopUserData.ts +++ b/apps/desktop/src/app/desktopUserData.ts @@ -45,7 +45,13 @@ const trimNonEmpty = (value: string | undefined): string | undefined => { return trimmed !== undefined && trimmed.length > 0 ? trimmed : undefined; }; -const firstEnvAlias = ( +/** + * Returns the first trimmed non-empty value among `names`, mirroring + * `DesktopConfig`'s `THREADLINES_` → `BADCODE_` → `T3CODE_` alias order. Shared + * with the other module-load-time desktop gates so alias handling stays in one + * place. + */ +export const readDesktopEnvAlias = ( env: Readonly>, names: ReadonlyArray, ): string | undefined => { @@ -58,7 +64,17 @@ const firstEnvAlias = ( return undefined; }; -const isParsableUrl = (value: string): boolean => URL.canParse(value); +/** + * Development is defined by Vite handing the main process a usable dev-server + * URL. Shared so every module-load-time gate splits the dev and release lanes + * on exactly the same signal. + */ +export const isDesktopDevelopmentEnv = ( + env: Readonly>, +): boolean => { + const devServerUrl = trimNonEmpty(env["VITE_DEV_SERVER_URL"]); + return devServerUrl !== undefined && URL.canParse(devServerUrl); +}; /** * Reads the userData-relevant configuration straight from an environment @@ -69,17 +85,16 @@ const isParsableUrl = (value: string): boolean => URL.canParse(value); export const readDesktopUserDataConfigFromEnv = ( env: Readonly>, ): DesktopUserDataConfig => { - const devServerUrl = trimNonEmpty(env["VITE_DEV_SERVER_URL"]); return { - isDevelopment: devServerUrl !== undefined && isParsableUrl(devServerUrl), + isDevelopment: isDesktopDevelopmentEnv(env), windowsAppDataDirectory: trimNonEmpty(env["APPDATA"]), xdgConfigHome: trimNonEmpty(env["XDG_CONFIG_HOME"]), - appDataDirectoryOverride: firstEnvAlias(env, [ + appDataDirectoryOverride: readDesktopEnvAlias(env, [ "THREADLINES_DESKTOP_APP_DATA_DIR", "BADCODE_DESKTOP_APP_DATA_DIR", "T3CODE_DESKTOP_APP_DATA_DIR", ]), - userDataDirNameOverride: firstEnvAlias(env, [ + userDataDirNameOverride: readDesktopEnvAlias(env, [ "THREADLINES_DESKTOP_USER_DATA_DIR_NAME", "BADCODE_DESKTOP_USER_DATA_DIR_NAME", "T3CODE_DESKTOP_USER_DATA_DIR_NAME", diff --git a/apps/desktop/src/app/singleInstanceGate.test.ts b/apps/desktop/src/app/singleInstanceGate.test.ts new file mode 100644 index 000000000..989359f4f --- /dev/null +++ b/apps/desktop/src/app/singleInstanceGate.test.ts @@ -0,0 +1,160 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + readSingleInstanceGateConfigFromEnv, + resolvePrimaryReadinessProbeUrl, + resolveServerRuntimeStatePath, + shouldRequestSingleInstanceLock, +} from "./singleInstanceGate.ts"; + +const gateFor = (env: Record): boolean => + shouldRequestSingleInstanceLock(readSingleInstanceGateConfigFromEnv(env)); + +describe("readSingleInstanceGateConfigFromEnv", () => { + it("only treats a parsable dev-server URL as development", () => { + assert.equal(readSingleInstanceGateConfigFromEnv({}).isDevelopment, false); + assert.equal( + readSingleInstanceGateConfigFromEnv({ VITE_DEV_SERVER_URL: " " }).isDevelopment, + false, + ); + assert.equal( + readSingleInstanceGateConfigFromEnv({ VITE_DEV_SERVER_URL: "not a url" }).isDevelopment, + false, + ); + assert.equal( + readSingleInstanceGateConfigFromEnv({ VITE_DEV_SERVER_URL: "http://localhost:5173" }) + .isDevelopment, + true, + ); + }); + + it("accepts only affirmative kill-switch values", () => { + for (const value of ["1", "true", "TRUE", "yes", "on", " on "]) { + assert.equal( + readSingleInstanceGateConfigFromEnv({ + THREADLINES_DISABLE_SINGLE_INSTANCE_LOCK: value, + }).disabledByEnv, + true, + `expected ${JSON.stringify(value)} to disable the lock`, + ); + } + for (const value of ["0", "false", "no", "off", "", " ", "maybe"]) { + assert.equal( + readSingleInstanceGateConfigFromEnv({ + THREADLINES_DISABLE_SINGLE_INSTANCE_LOCK: value, + }).disabledByEnv, + false, + `expected ${JSON.stringify(value)} to leave the lock enabled`, + ); + } + }); + + it("follows the THREADLINES → BADCODE → T3CODE alias order", () => { + assert.equal( + readSingleInstanceGateConfigFromEnv({ + THREADLINES_DISABLE_SINGLE_INSTANCE_LOCK: "0", + BADCODE_DISABLE_SINGLE_INSTANCE_LOCK: "1", + }).disabledByEnv, + false, + ); + assert.equal( + readSingleInstanceGateConfigFromEnv({ + BADCODE_DISABLE_SINGLE_INSTANCE_LOCK: "1", + T3CODE_DISABLE_SINGLE_INSTANCE_LOCK: "0", + }).disabledByEnv, + true, + ); + assert.equal( + readSingleInstanceGateConfigFromEnv({ + T3CODE_DISABLE_SINGLE_INSTANCE_LOCK: "yes", + }).disabledByEnv, + true, + ); + }); +}); + +describe("shouldRequestSingleInstanceLock", () => { + it("locks release launches and leaves development launches alone", () => { + assert.equal(gateFor({}), true); + assert.equal(gateFor({ VITE_DEV_SERVER_URL: "http://localhost:5173" }), false); + }); + + it("stays off when the kill switch is set, in either lane", () => { + assert.equal(gateFor({ THREADLINES_DISABLE_SINGLE_INSTANCE_LOCK: "1" }), false); + assert.equal( + gateFor({ + VITE_DEV_SERVER_URL: "http://localhost:5173", + THREADLINES_DISABLE_SINGLE_INSTANCE_LOCK: "1", + }), + false, + ); + }); +}); + +describe("resolveServerRuntimeStatePath", () => { + const posixPath = { join: (...segments: ReadonlyArray) => segments.join("/") }; + + it("defaults to the release state directory under the home directory", () => { + assert.equal( + resolveServerRuntimeStatePath({ env: {}, homeDirectory: "/home/will", path: posixPath }), + "/home/will/.threadlines/userdata/server-runtime.json", + ); + }); + + it("follows THREADLINES_HOME and the dev lane", () => { + assert.equal( + resolveServerRuntimeStatePath({ + env: { THREADLINES_HOME: "/tmp/tl-home" }, + homeDirectory: "/home/will", + path: posixPath, + }), + "/tmp/tl-home/userdata/server-runtime.json", + ); + assert.equal( + resolveServerRuntimeStatePath({ + env: { VITE_DEV_SERVER_URL: "http://localhost:5173" }, + homeDirectory: "/home/will", + path: posixPath, + }), + "/home/will/.threadlines/dev/server-runtime.json", + ); + }); +}); + +describe("resolvePrimaryReadinessProbeUrl", () => { + const validState = JSON.stringify({ + version: 1, + pid: 1234, + port: 3774, + origin: "http://127.0.0.1:3774", + startedAt: "2026-08-14T00:00:00.000Z", + }); + + it("builds the readiness URL from a valid runtime-state file", () => { + assert.equal( + resolvePrimaryReadinessProbeUrl(validState), + "http://127.0.0.1:3774/.well-known/threadlines/environment", + ); + }); + + it("rejects a missing, malformed, or untrusted state file", () => { + assert.equal(resolvePrimaryReadinessProbeUrl(undefined), undefined); + assert.equal(resolvePrimaryReadinessProbeUrl(""), undefined); + assert.equal(resolvePrimaryReadinessProbeUrl("not json"), undefined); + assert.equal(resolvePrimaryReadinessProbeUrl('"just a string"'), undefined); + assert.equal( + resolvePrimaryReadinessProbeUrl(JSON.stringify({ version: 2, origin: "http://x:1" })), + undefined, + "an unknown schema version must not be probed", + ); + assert.equal( + resolvePrimaryReadinessProbeUrl(JSON.stringify({ version: 1, origin: "not a url" })), + undefined, + ); + assert.equal( + resolvePrimaryReadinessProbeUrl(JSON.stringify({ version: 1, origin: "file:///etc" })), + undefined, + "only http(s) origins may be probed", + ); + }); +}); diff --git a/apps/desktop/src/app/singleInstanceGate.ts b/apps/desktop/src/app/singleInstanceGate.ts new file mode 100644 index 000000000..b5894f895 --- /dev/null +++ b/apps/desktop/src/app/singleInstanceGate.ts @@ -0,0 +1,110 @@ +/** + * Decides whether this launch should take Electron's single-instance lock. + * + * A second release launch does not fail cleanly: it port-scans past the running + * backend, then runs a full duplicate stack against the same userData directory + * and the same SQLite state, corrupting projections and fighting the first + * process over provider sessions. The lock turns that into a no-op launch that + * raises the existing window instead. + * + * Development is deliberately exempt. Dev runs use their own userData directory + * and are routinely started next to a release install, so locking them would + * break the normal workflow rather than protect anything. + * + * Like `desktopUserData.ts`, this module is dependency-free and synchronous: + * `main.ts` has to consult it during module evaluation, long before the Effect + * runtime and `DesktopConfig` exist. + */ + +import { isDesktopDevelopmentEnv, readDesktopEnvAlias } from "./desktopUserData.ts"; + +export interface SingleInstanceGateConfig { + /** Vite handed this process a dev-server URL, so it is a development run. */ + readonly isDevelopment: boolean; + /** `THREADLINES_DISABLE_SINGLE_INSTANCE_LOCK` — operator escape hatch. */ + readonly disabledByEnv: boolean; +} + +const ENABLED_FLAG_VALUES = new Set(["1", "true", "yes", "on"]); + +/** + * Reads the gate inputs straight from an environment record, mirroring + * `DesktopConfig` semantics (trimmed values, `THREADLINES_` → `BADCODE_` → + * `T3CODE_` alias order) for use before the Effect runtime exists. + */ +export const readSingleInstanceGateConfigFromEnv = ( + env: Readonly>, +): SingleInstanceGateConfig => { + const disableFlag = readDesktopEnvAlias(env, [ + "THREADLINES_DISABLE_SINGLE_INSTANCE_LOCK", + "BADCODE_DISABLE_SINGLE_INSTANCE_LOCK", + "T3CODE_DISABLE_SINGLE_INSTANCE_LOCK", + ]); + return { + isDevelopment: isDesktopDevelopmentEnv(env), + disabledByEnv: disableFlag !== undefined && ENABLED_FLAG_VALUES.has(disableFlag.toLowerCase()), + }; +}; + +export const shouldRequestSingleInstanceLock = (config: SingleInstanceGateConfig): boolean => + !config.isDevelopment && !config.disabledByEnv; + +export interface SingleInstancePathApi { + readonly join: (...segments: ReadonlyArray) => string; +} + +const SERVER_RUNTIME_STATE_FILE_NAME = "server-runtime.json"; + +/** + * Where the running server advertises itself: `server-runtime.json` inside the + * server state directory. Mirrors the resolution in `DesktopEnvironment` + * (`THREADLINES_HOME` or `~/.threadlines`, then `dev`/`userdata` by lane) and + * the server's own `config.serverRuntimeStatePath`, because a denied secondary + * has to find the file before the Effect runtime exists. + */ +export const resolveServerRuntimeStatePath = (input: { + readonly env: Readonly>; + readonly homeDirectory: string; + readonly path: SingleInstancePathApi; +}): string => { + const baseDir = + readDesktopEnvAlias(input.env, ["THREADLINES_HOME", "BADCODE_HOME", "T3CODE_HOME"]) ?? + input.path.join(input.homeDirectory, ".threadlines"); + const stateDirName = isDesktopDevelopmentEnv(input.env) ? "dev" : "userdata"; + return input.path.join(baseDir, stateDirName, SERVER_RUNTIME_STATE_FILE_NAME); +}; + +/** + * The unauthenticated readiness URL of the primary's backend, or undefined when + * the runtime-state file is absent or unusable. The server writes the file when + * it starts listening and removes it on clean shutdown, so a parsable origin is + * a claim worth probing, not proof of life — a crash leaves the file behind. + */ +export const resolvePrimaryReadinessProbeUrl = ( + rawRuntimeState: string | undefined, +): string | undefined => { + if (rawRuntimeState === undefined) { + return undefined; + } + + let parsed: unknown; + try { + parsed = JSON.parse(rawRuntimeState); + } catch { + return undefined; + } + if (typeof parsed !== "object" || parsed === null) { + return undefined; + } + + const { origin, version } = parsed as { origin?: unknown; version?: unknown }; + if (version !== 1 || typeof origin !== "string" || !URL.canParse(origin)) { + return undefined; + } + const originUrl = new URL(origin); + if (originUrl.protocol !== "http:" && originUrl.protocol !== "https:") { + return undefined; + } + + return new URL("/.well-known/threadlines/environment", originUrl).href; +}; diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 12ef6be88..15198e027 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -1,10 +1,15 @@ +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as NetService from "@threadlines/shared/Net"; + +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; @@ -21,12 +26,14 @@ const encodePersistedServerObservabilitySettingsDocument = Schema.encodeEffect( Schema.fromJsonString(PersistedServerObservabilitySettingsDocument), ); +/** An explicitly configured port: the backend must never move it. */ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { getState: Effect.die("unexpected getState"), backendConfig: Effect.succeed({ port: 4888, bindHost: "0.0.0.0", httpBaseUrl: new URL("http://127.0.0.1:4888"), + portSelectedByScan: false, tailscaleServeEnabled: true, tailscaleServePort: 8443, }), @@ -36,6 +43,23 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp getAdvertisedEndpoints: Effect.succeed([]), } satisfies DesktopServerExposure.DesktopServerExposureShape); +const makeNetLayer = (occupiedPorts: readonly number[] = []) => { + const occupied = new Set(occupiedPorts); + return Layer.succeed(NetService.NetService, { + canListenOnHost: (port: number) => Effect.succeed(!occupied.has(port)), + isPortAvailableOnLoopback: () => Effect.die("unexpected isPortAvailableOnLoopback"), + reserveLoopbackPort: () => Effect.die("unexpected reserveLoopbackPort"), + findAvailablePort: () => Effect.die("unexpected findAvailablePort"), + } satisfies NetService.NetServiceShape); +}; + +// The exposure service constructs a spawner and an HTTP client for endpoint +// discovery, which these tests never reach. +const unusedSpawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected spawn")), +); + function makeEnvironmentLayer( baseDir: string, options?: { @@ -78,6 +102,7 @@ const withHarness = ( | FileSystem.FileSystem | DesktopBackendConfiguration.DesktopBackendConfiguration >, + options?: { readonly occupiedPorts?: readonly number[] }, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -89,6 +114,52 @@ const withHarness = ( Effect.provide( DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(makeNetLayer(options?.occupiedPorts)), + Layer.provideMerge(makeEnvironmentLayer(baseDir)), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + +/** + * The real exposure service, so a re-selected port has to travel through the + * advertised state and the httpBaseUrl the window loads. + */ +const withLiveExposureHarness = ( + input: { readonly occupiedPorts: readonly number[] }, + effect: Effect.Effect< + A, + E, + | R + | FileSystem.FileSystem + | DesktopBackendConfiguration.DesktopBackendConfiguration + | DesktopServerExposure.DesktopServerExposure + >, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-port-test-", + }); + + const exposureLayer = DesktopServerExposure.layer.pipe( + Layer.provideMerge( + Layer.succeed(DesktopServerExposure.DesktopNetworkInterfacesService, { + read: Effect.succeed({}), + }), + ), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(unusedSpawnerLayer), + Layer.provideMerge(DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ); + + return yield* effect.pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(exposureLayer), + Layer.provideMerge(makeNetLayer(input.occupiedPorts)), Layer.provideMerge(makeEnvironmentLayer(baseDir)), ), ), @@ -176,6 +247,55 @@ describe("DesktopBackendConfiguration", () => { ), ); + it.effect("keeps the selected backend port while it is still bindable", () => + withLiveExposureHarness( + { occupiedPorts: [] }, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + yield* serverExposure.configureFromSettings({ port: 3775, selectedByScan: true }); + + const config = yield* configuration.resolve; + + assert.equal(config.bootstrap.port, 3775); + assert.equal(config.httpBaseUrl.href, "http://127.0.0.1:3775/"); + assert.equal((yield* serverExposure.backendConfig).port, 3775); + }), + ), + ); + + it.effect("re-selects a scanned backend port that was taken while the backend was down", () => + withLiveExposureHarness( + { occupiedPorts: [3773, 3774] }, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + yield* serverExposure.configureFromSettings({ port: 3774, selectedByScan: true }); + + const config = yield* configuration.resolve; + + assert.equal(config.bootstrap.port, 3775); + assert.equal(config.httpBaseUrl.href, "http://127.0.0.1:3775/"); + const backendConfig = yield* serverExposure.backendConfig; + assert.equal(backendConfig.port, 3775); + assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:3775/"); + }), + ), + ); + + it.effect("never moves an explicitly configured backend port, even when it is taken", () => + withHarness( + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolve; + + assert.equal(config.bootstrap.port, 4888); + assert.equal(config.httpBaseUrl.href, "http://127.0.0.1:4888/"); + }), + { occupiedPorts: [4888] }, + ), + ); + it.effect("captures backend output in development so child process logs can be persisted", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -191,6 +311,8 @@ describe("DesktopBackendConfiguration", () => { Effect.provide( DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(makeNetLayer()), Layer.provideMerge( makeEnvironmentLayer(baseDir, { isPackaged: false, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index e55d0743e..2ce0c9743 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -7,10 +7,18 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as NetService from "@threadlines/shared/Net"; + +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +import { + canBindDesktopBackendPort, + desktopBackendPortProbeHosts, + scanForDesktopBackendPort, +} from "./backendPort.ts"; export interface DesktopBackendConfigurationShape { readonly resolve: Effect.Effect; @@ -72,9 +80,8 @@ const DESKTOP_BACKEND_ENV_NAMES = [ const backendChildEnvPatch = (): Record => Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); -const { logWarning: logBackendConfigurationWarning } = DesktopObservability.makeComponentLogger( - "desktop-backend-configuration", -); +const { logInfo: logBackendConfigurationInfo, logWarning: logBackendConfigurationWarning } = + DesktopObservability.makeComponentLogger("desktop-backend-configuration"); const readPersistedBackendObservabilitySettings: Effect.Effect< BackendObservabilitySettings, @@ -122,6 +129,58 @@ const getOrCreateBootstrapToken = Effect.fn("desktop.backendConfiguration.bootst }, ); +/** + * Re-checks the stored backend port before every (re)start. + * + * The bootstrap scan probes a port and binds it moments later, so a second + * instance starting at the same time can win the port in between. Without this + * the restart loop would retry the same dead port forever, because the port was + * chosen once and then treated as settled. + * + * Only a scanned port may be moved. An explicitly configured port is the user's + * instruction, so it is left alone and its bind failure stays visible. + */ +const ensureBackendPortIsBindable = Effect.fn( + "desktop.backendConfiguration.ensureBackendPortIsBindable", +)(function* (): Effect.fn.Return< + void, + never, + | DesktopAppSettings.DesktopAppSettings + | DesktopServerExposure.DesktopServerExposure + | NetService.NetService +> { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const current = yield* serverExposure.backendConfig; + if (!current.portSelectedByScan) { + return; + } + + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const settings = yield* desktopSettings.get; + const probeHosts = desktopBackendPortProbeHosts(settings.serverExposureMode); + if (yield* canBindDesktopBackendPort({ port: current.port, probeHosts })) { + return; + } + + const rescanned = yield* scanForDesktopBackendPort({ probeHosts }).pipe(Effect.option); + if (Option.isNone(rescanned)) { + yield* logBackendConfigurationWarning( + "backend port is no longer available and no replacement port could be found", + { port: current.port }, + ); + return; + } + + yield* serverExposure.configureFromSettings({ port: rescanned.value, selectedByScan: true }); + yield* logBackendConfigurationInfo( + "selected a new backend port after the previous one was taken", + { + previousPort: current.port, + port: rescanned.value, + }, + ); +}); + const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolveStartConfig")( function* (input: { readonly bootstrapToken: string; @@ -176,10 +235,17 @@ export const layer = Layer.effect( const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const net = yield* NetService.NetService; const tokenRef = yield* Ref.make(Option.none()); return DesktopBackendConfiguration.of({ resolve: Effect.gen(function* () { + yield* ensureBackendPortIsBindable().pipe( + Effect.provideService(DesktopAppSettings.DesktopAppSettings, desktopSettings), + Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), + Effect.provideService(NetService.NetService, net), + ); const bootstrapToken = yield* getOrCreateBootstrapToken(tokenRef); const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 0f3e9eaeb..7624256d5 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -135,7 +135,10 @@ describe("DesktopServerExposure", () => { yield* settings.setServerExposureMode("network-accessible"); - const state = yield* serverExposure.configureFromSettings({ port: 4173 }); + const state = yield* serverExposure.configureFromSettings({ + port: 4173, + selectedByScan: true, + }); assert.equal(state.mode, "local-only"); assert.equal(state.endpointUrl, null); assert.equal((yield* settings.get).serverExposureMode, "network-accessible"); @@ -152,7 +155,7 @@ describe("DesktopServerExposure", () => { emptyNetworkInterfaces, Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.configureFromSettings({ port: 4173, selectedByScan: true }); const error = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); assert.ok(error._tag === "DesktopServerExposureNoNetworkAddressError"); @@ -169,7 +172,7 @@ describe("DesktopServerExposure", () => { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* settings.load; - yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.configureFromSettings({ port: 4173, selectedByScan: true }); const change = yield* serverExposure.setMode("network-accessible"); assert.equal(change.requiresRelaunch, true); @@ -199,7 +202,7 @@ describe("DesktopServerExposure", () => { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* settings.load; - yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.configureFromSettings({ port: 4173, selectedByScan: true }); const changed = yield* serverExposure.setTailscaleServeEnabled({ enabled: true, @@ -227,7 +230,7 @@ describe("DesktopServerExposure", () => { { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces }, Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.configureFromSettings({ port: 4173, selectedByScan: true }); yield* serverExposure.setMode("network-accessible"); const endpoints = yield* serverExposure.getAdvertisedEndpoints; @@ -244,7 +247,7 @@ describe("DesktopServerExposure", () => { lanNetworkInterfaces, Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.configureFromSettings({ port: 4173, selectedByScan: true }); const change = yield* serverExposure.setMode("network-accessible"); assert.equal(change.state.advertisedHost, "10.0.0.7"); @@ -268,7 +271,7 @@ describe("DesktopServerExposure", () => { lanNetworkInterfaces, Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 3773 }); + yield* serverExposure.configureFromSettings({ port: 3773, selectedByScan: true }); yield* serverExposure.setMode("network-accessible"); const endpoints = yield* serverExposure.getAdvertisedEndpoints; diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index f6d6721bb..25c68fc29 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -247,6 +247,12 @@ export interface DesktopServerExposureBackendConfig { readonly port: number; readonly bindHost: string; readonly httpBaseUrl: URL; + /** + * True when the port was chosen by scanning for a free one rather than being + * configured explicitly. Only a scanned port may be moved on a later backend + * restart; an explicitly configured port stays put so its failure is visible. + */ + readonly portSelectedByScan: boolean; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; } @@ -261,6 +267,7 @@ export interface DesktopServerExposureShape { readonly backendConfig: Effect.Effect; readonly configureFromSettings: (input: { readonly port: number; + readonly selectedByScan: boolean; }) => Effect.Effect; readonly setMode: ( mode: DesktopServerExposureMode, @@ -290,6 +297,7 @@ interface RuntimeState { readonly requestedMode: DesktopServerExposureMode; readonly mode: DesktopServerExposureMode; readonly port: number; + readonly portSelectedByScan: boolean; readonly bindHost: string; readonly localHttpUrl: string; readonly localWsUrl: string; @@ -315,6 +323,7 @@ const initialRuntimeState = (): RuntimeState => networkInterfaces: {}, }), port: 0, + selectedByScan: false, }); const toContractState = (state: RuntimeState): DesktopServerExposureState => ({ @@ -329,6 +338,7 @@ const toBackendConfig = (state: RuntimeState): DesktopServerExposureBackendConfi port: state.port, bindHost: state.bindHost, httpBaseUrl: state.httpBaseUrl, + portSelectedByScan: state.portSelectedByScan, tailscaleServeEnabled: state.tailscaleServeEnabled, tailscaleServePort: state.tailscaleServePort, }); @@ -347,11 +357,13 @@ function runtimeStateFromResolvedExposure(input: { readonly settings: DesktopSettings; readonly exposure: ResolvedDesktopServerExposure; readonly port: number; + readonly selectedByScan: boolean; }): RuntimeState { return { requestedMode: input.requestedMode, mode: input.exposure.mode, port: input.port, + portSelectedByScan: input.selectedByScan, bindHost: input.exposure.bindHost, localHttpUrl: input.exposure.localHttpUrl, localWsUrl: input.exposure.localWsUrl, @@ -367,6 +379,7 @@ function resolveRuntimeState(input: { readonly requestedMode: DesktopServerExposureMode; readonly settings: DesktopSettings; readonly port: number; + readonly selectedByScan: boolean; readonly networkInterfaces: DesktopNetworkInterfaces; readonly advertisedHostOverride: Option.Option; }): ResolvedRuntimeState { @@ -394,6 +407,7 @@ function resolveRuntimeState(input: { settings: input.settings, exposure, port: input.port, + selectedByScan: input.selectedByScan, }), unavailable, }; @@ -418,14 +432,21 @@ const make = Effect.gen(function* () { const backendConfig = Ref.get(stateRef).pipe(Effect.map(toBackendConfig)); const configureFromSettings = Effect.fn("desktop.serverExposure.configureFromSettings")( - function* ({ port }: { readonly port: number }) { - yield* Effect.annotateCurrentSpan({ port }); + function* ({ + port, + selectedByScan, + }: { + readonly port: number; + readonly selectedByScan: boolean; + }) { + yield* Effect.annotateCurrentSpan({ port, selectedByScan }); const settings = yield* desktopSettings.get; const currentNetworkInterfaces = yield* readNetworkInterfaces; const resolved = resolveRuntimeState({ requestedMode: settings.serverExposureMode, settings, port, + selectedByScan, networkInterfaces: currentNetworkInterfaces, advertisedHostOverride: config.desktopLanHostOverride, }); @@ -449,6 +470,7 @@ const make = Effect.gen(function* () { requestedMode: mode, settings: nextSettings, port: previous.port, + selectedByScan: previous.portSelectedByScan, networkInterfaces: currentNetworkInterfaces, advertisedHostOverride: config.desktopLanHostOverride, }); diff --git a/apps/desktop/src/backend/backendPort.ts b/apps/desktop/src/backend/backendPort.ts new file mode 100644 index 000000000..2cb644319 --- /dev/null +++ b/apps/desktop/src/backend/backendPort.ts @@ -0,0 +1,93 @@ +import type { DesktopServerExposureMode } from "@threadlines/contracts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as NetService from "@threadlines/shared/Net"; + +export const DEFAULT_DESKTOP_BACKEND_PORT = 3773; +const MAX_TCP_PORT = 65_535; +const DESKTOP_LOCAL_ONLY_PORT_PROBE_HOSTS = ["127.0.0.1"] as const; +const DESKTOP_NETWORK_ACCESSIBLE_PORT_PROBE_HOSTS = ["0.0.0.0"] as const; + +export class DesktopBackendPortUnavailableError extends Data.TaggedError( + "DesktopBackendPortUnavailableError", +)<{ + readonly startPort: number; + readonly maxPort: number; + readonly hosts: readonly string[]; +}> { + override get message() { + return `No desktop backend port is available on hosts ${this.hosts.join(", ")} between ${this.startPort} and ${this.maxPort}.`; + } +} + +export interface DesktopBackendPortSelection { + readonly port: number; + /** + * True when the port came from the sequential scan rather than from an + * explicitly configured port. Only scan-selected ports may be moved later. + */ + readonly selectedByScan: boolean; +} + +/** + * The hosts a candidate backend port has to be bindable on. + * + * Network-accessible mode binds the wildcard address, which conflicts with a + * process holding the same port on any single interface, so it has to be probed + * as the wildcard rather than as loopback. + */ +export const desktopBackendPortProbeHosts = (mode: DesktopServerExposureMode): readonly string[] => + mode === "network-accessible" + ? DESKTOP_NETWORK_ACCESSIBLE_PORT_PROBE_HOSTS + : DESKTOP_LOCAL_ONLY_PORT_PROBE_HOSTS; + +export const canBindDesktopBackendPort = Effect.fn("desktop.backendPort.canBind")( + function* (input: { readonly port: number; readonly probeHosts: readonly string[] }) { + const net = yield* NetService.NetService; + for (const host of input.probeHosts) { + if (!(yield* net.canListenOnHost(input.port, host))) { + return false; + } + } + return true; + }, +); + +export const scanForDesktopBackendPort = Effect.fn("desktop.backendPort.scan")(function* (input: { + readonly probeHosts: readonly string[]; +}) { + for (let port = DEFAULT_DESKTOP_BACKEND_PORT; port <= MAX_TCP_PORT; port += 1) { + if (yield* canBindDesktopBackendPort({ port, probeHosts: input.probeHosts })) { + return port; + } + } + + return yield* new DesktopBackendPortUnavailableError({ + startPort: DEFAULT_DESKTOP_BACKEND_PORT, + maxPort: MAX_TCP_PORT, + hosts: input.probeHosts, + }); +}); + +export const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* (input: { + readonly configuredPort: Option.Option; + readonly probeHosts: readonly string[]; +}): Effect.fn.Return< + DesktopBackendPortSelection, + DesktopBackendPortUnavailableError, + NetService.NetService +> { + if (Option.isSome(input.configuredPort)) { + return { + port: input.configuredPort.value, + selectedByScan: false, + }; + } + + return { + port: yield* scanForDesktopBackendPort({ probeHosts: input.probeHosts }), + selectedByScan: true, + }; +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e81848e16..558c1b804 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -61,6 +61,12 @@ import { resolveDesktopUserDataLocation, resolveDesktopUserDataPath, } from "./app/desktopUserData.ts"; +import { + readSingleInstanceGateConfigFromEnv, + resolvePrimaryReadinessProbeUrl, + resolveServerRuntimeStatePath, + shouldRequestSingleInstanceLock, +} from "./app/singleInstanceGate.ts"; // userData must be final before Electron's "ready" event: Chromium spawns its // sandboxed helper processes (GPU, network service) at ready with sandbox @@ -80,6 +86,76 @@ Electron.app.setPath( }), ); +// Ordering matters: Electron scopes the single-instance lock to the current +// userData path and creates that directory as it acquires the lock, so the +// request has to come after the setPath above. Asking first would both lock the +// wrong path and pre-create a directory the legacy-directory probe reads. +const isPrimaryInstance = shouldRequestSingleInstanceLock( + readSingleInstanceGateConfigFromEnv(process.env), +) + ? Electron.app.requestSingleInstanceLock() + : true; + +// How long a denied launch waits for the primary's backend to answer before +// concluding the primary is wedged rather than merely busy. +const PRIMARY_READINESS_PROBE_TIMEOUT_MS = 2500; + +/** + * A denied secondary launch's whole job. Losing the lock already told the + * primary to raise its window, so when that primary is demonstrably healthy the + * right behavior is the silent hand-off every single-instance app does. The + * dialog is reserved for the case it was designed for — a primary that holds + * the lock but does not answer — because `showErrorBox` blocks this process + * until dismissed, and a dialog nobody notices would otherwise leave an idle + * Threadlines lingering in Task Manager. + */ +async function exitAfterSecondaryInstanceHandoff(): Promise { + const probeUrl = resolvePrimaryReadinessProbeUrl( + (() => { + try { + return NodeFS.readFileSync( + resolveServerRuntimeStatePath({ + env: process.env, + homeDirectory: NodeOS.homedir(), + path: NodePath, + }), + "utf8", + ); + } catch { + return undefined; + } + })(), + ); + + let primaryIsHealthy = false; + if (probeUrl !== undefined) { + try { + const response = await fetch(probeUrl, { + signal: AbortSignal.timeout(PRIMARY_READINESS_PROBE_TIMEOUT_MS), + }); + primaryIsHealthy = response.ok; + } catch { + primaryIsHealthy = false; + } + } + + if (primaryIsHealthy) { + Electron.app.exit(0); + return; + } + + // Safe before "ready": showErrorBox is the one dialog Electron allows early. + Electron.dialog.showErrorBox( + "Threadlines is already running", + "Another Threadlines process is already running on this computer but is not responding. Quit Threadlines from Task Manager (Windows) or Activity Monitor (Mac) and try again.", + ); + Electron.app.exit(1); +} + +if (!isPrimaryInstance) { + void exitAfterSecondaryInstanceHandoff(); +} + const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( @@ -195,4 +271,10 @@ const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe( ), ); -DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain); +// Every layer above is a lazy description; this is the only line that builds +// them. A secondary instance must never reach it, or it would spawn a second +// backend, probe for a port, and open the shared SQLite state behind the +// running app's back. +if (isPrimaryInstance) { + DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain); +} diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index b99b5c5d3..6830123e2 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -40,6 +40,7 @@ function makeFakeBrowserWindow(input?: { readonly bounds?: Electron.Rectangle; readonly normalBounds?: Electron.Rectangle; readonly isMaximized?: boolean; + readonly currentUrl?: string; }) { const windowHandlers = new Map void>>(); const webContentsHandlers = new Map void>>(); @@ -49,6 +50,7 @@ function makeFakeBrowserWindow(input?: { const webContents = { copyImageAt: vi.fn(), focus: vi.fn(), + getURL: vi.fn(() => input?.currentUrl ?? ""), isLoadingMainFrame: vi.fn(() => false), on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { webContentsHandlers.set(eventName, [...(webContentsHandlers.get(eventName) ?? []), listener]); @@ -130,6 +132,7 @@ const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopSe port: 3773, bindHost: "127.0.0.1", httpBaseUrl: new URL("http://127.0.0.1:3773"), + portSelectedByScan: true, tailscaleServeEnabled: false, tailscaleServePort: 443, }), @@ -276,6 +279,48 @@ describe("defaultMainWindowSize", () => { }); }); +describe("shouldRepointMainWindow", () => { + it("leaves a development window on the dev server", () => { + assert.isFalse( + DesktopWindow.shouldRepointMainWindow({ + isDevelopment: true, + currentUrl: "http://127.0.0.1:5733/", + backendHttpBaseUrl: new URL("http://127.0.0.1:3774"), + }), + ); + }); + + it("does not reload when the backend came back on the same port", () => { + assert.isFalse( + DesktopWindow.shouldRepointMainWindow({ + isDevelopment: false, + currentUrl: "http://127.0.0.1:3773/threads/abc", + backendHttpBaseUrl: new URL("http://127.0.0.1:3773"), + }), + ); + }); + + it("reloads a window stranded on the backend's previous port", () => { + assert.isTrue( + DesktopWindow.shouldRepointMainWindow({ + isDevelopment: false, + currentUrl: "http://127.0.0.1:3773/threads/abc", + backendHttpBaseUrl: new URL("http://127.0.0.1:3774"), + }), + ); + }); + + it("leaves a window that has not committed a URL yet alone", () => { + assert.isFalse( + DesktopWindow.shouldRepointMainWindow({ + isDevelopment: false, + currentUrl: "", + backendHttpBaseUrl: new URL("http://127.0.0.1:3774"), + }), + ); + }); +}); + describe("DesktopWindow", () => { it.effect("does not open a development window until the backend is ready", () => Effect.gen(function* () { diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 6a4b640e9..70b47fbdd 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -209,6 +209,34 @@ export function defaultMainWindowSize(workArea: { width: number; height: number }; } +/** + * Whether an already-open window is pointing at a backend that has moved. + * + * A backend restart normally reuses its port and the window must not flash, so + * this is false unless the origin actually changed. Development windows load + * the Vite dev server rather than the backend, so they are never re-pointed. + * + * Pure, and exported, because the cost of getting it wrong is either a stranded + * window or a reload on every readiness ping. + */ +export function shouldRepointMainWindow(input: { + readonly isDevelopment: boolean; + readonly currentUrl: string; + readonly backendHttpBaseUrl: URL; +}): boolean { + if (input.isDevelopment) { + return false; + } + + try { + // A window that has not committed a URL yet reports an empty string, and it + // was created with the current backend URL anyway. + return new URL(input.currentUrl).origin !== input.backendHttpBaseUrl.origin; + } catch { + return false; + } +} + function normalizeRestoredDimension(value: unknown, minimum: number): number | null { if (typeof value !== "number" || !Number.isFinite(value)) { return null; @@ -705,6 +733,32 @@ const make = Effect.gen(function* () { yield* createMain; }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); + // The backend can come back on a different port when its previous one was + // taken while it was down, which leaves an open window loaded from an origin + // that no longer answers. + const repointMainWindowIfBackendMoved = Effect.gen(function* () { + const existingWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isNone(existingWindow)) return; + + const window = existingWindow.value; + if (window.isDestroyed()) return; + + const backendConfig = yield* serverExposure.backendConfig; + const shouldRepoint = shouldRepointMainWindow({ + isDevelopment: environment.isDevelopment, + currentUrl: window.webContents.getURL(), + backendHttpBaseUrl: backendConfig.httpBaseUrl, + }); + if (!shouldRepoint) return; + + yield* logWindowInfo("reloading main window at relocated backend", { + url: backendConfig.httpBaseUrl.href, + }); + yield* Effect.sync(() => { + void window.loadURL(backendConfig.httpBaseUrl.href); + }); + }).pipe(Effect.withSpan("desktop.window.repointMainWindowIfBackendMoved")); + return DesktopWindow.of({ createMain, ensureMain, @@ -721,6 +775,7 @@ const make = Effect.gen(function* () { handleBackendReady: Effect.gen(function* () { yield* Ref.set(state.backendReady, true); yield* logWindowInfo("backend ready", { source: "http" }); + yield* repointMainWindowIfBackendMoved; yield* createMainIfBackendReady; }).pipe(Effect.withSpan("desktop.window.handleBackendReady")), dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action, payload) {