From fe825add0e17fb7bc151f1c62a2872be0c4cc98c Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:36:05 -0400 Subject: [PATCH 1/2] Refuse duplicate desktop instances in the release lane Two release-profile desktop processes share one Electron profile, one ~/.threadlines/userdata state directory, one SQLite database, and the same provider threads. A second launch used to port-scan past the running backend and build a full duplicate stack against that shared state: Codex rejected the second writer mid-turn, the startup reaper marked the first instance's live sessions stopped, and both servers fought over server-runtime.json. The window also only appears once the backend is ready, so a slow cold start invites the second click that triggers all of this. Request Electron's single-instance lock immediately after userData is final (the lock is scoped to that path), before any layer is built. A denied launch probes the primary's readiness endpoint via the server-runtime.json origin: a healthy primary means a silent exit while the primary raises its window (second-instance handler); an unresponsive primary gets a blocking error dialog pointing at Task Manager, the one case where waiting on the user is right. Quits now carry a 15s failsafe that force-exits when shutdown hangs, so a wedged teardown can no longer leave an invisible process holding the lock, the port, and the provider threads. Development launches are exempt (they have their own userData and run next to release installs by design), and THREADLINES_DISABLE_SINGLE_INSTANCE_LOCK=1 skips the lock entirely. --- .gitignore | 1 + apps/desktop/src/app/DesktopLifecycle.test.ts | 85 ++++++++++ apps/desktop/src/app/DesktopLifecycle.ts | 44 ++++- apps/desktop/src/app/desktopUserData.ts | 27 ++- .../src/app/singleInstanceGate.test.ts | 160 ++++++++++++++++++ apps/desktop/src/app/singleInstanceGate.ts | 110 ++++++++++++ apps/desktop/src/main.ts | 84 ++++++++- 7 files changed, 501 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/app/DesktopLifecycle.test.ts create mode 100644 apps/desktop/src/app/singleInstanceGate.test.ts create mode 100644 apps/desktop/src/app/singleInstanceGate.ts 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/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/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); +} From b703265f16408437ff246e3bee464bcc5dc7aa96 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:36:18 -0400 Subject: [PATCH 2/2] Re-verify the backend port on every restart The backend port was probed once at bootstrap and then treated as settled: if the port was taken while the backend was down (or two instances raced the same probe window), the restart loop retried the dead port forever at max backoff. Observed live with two isolated profiles launched in the same instant, both selecting 3774. Port-scan logic moves to backend/backendPort.ts, shared by bootstrap and restart. DesktopBackendConfiguration.resolve now re-probes the stored port before every start and, when a scan-selected port was taken, rescans and rewrites the exposure state so the port, its provenance, and the advertised URLs stay in agreement. Explicitly configured ports are never moved; their bind failures stay visible. When the backend does come back elsewhere, an open main window is re-pointed at the new origin instead of stranding on the dead one. --- apps/desktop/src/app/DesktopApp.ts | 70 ++-------- .../DesktopBackendConfiguration.test.ts | 122 ++++++++++++++++++ .../backend/DesktopBackendConfiguration.ts | 72 ++++++++++- .../src/backend/DesktopServerExposure.test.ts | 17 ++- .../src/backend/DesktopServerExposure.ts | 26 +++- apps/desktop/src/backend/backendPort.ts | 93 +++++++++++++ apps/desktop/src/window/DesktopWindow.test.ts | 45 +++++++ apps/desktop/src/window/DesktopWindow.ts | 55 ++++++++ 8 files changed, 428 insertions(+), 72 deletions(-) create mode 100644 apps/desktop/src/backend/backendPort.ts 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/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/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) {