From e8d132875a1c218e582ba2ba1d377dadf567dd08 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:46:18 +0000 Subject: [PATCH] fix(desktop): app opens after an update while the old server shuts down On launch the desktop checked the T3 home's ownership lock once. After an update relaunch the previous version's backend often still holds server-owner.sqlite while it shuts down, so the new app showed "This T3 home is unavailable" and quit even though the lock frees seconds later. Bootstrap now waits for the lock to free, bounded by the backend's quit stop timeout plus a margin, and only shows the dialog for an owner still holding it after that. AI-assisted; verified with the tests and tools listed above. --- apps/desktop/src/app/DesktopApp.ts | 2 +- .../src/backend/DesktopHomeOwner.test.ts | 22 ++++++-- apps/desktop/src/backend/DesktopHomeOwner.ts | 54 ++++++++++++------- 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index d515819dd4cf..7ebeaa74a021 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -227,7 +227,7 @@ const bootstrap = Effect.gen(function* () { // A backend started while another server holds this home's lock can only exit with code 78. if ( !(settings.wslOnly && settings.wslBackendEnabled) && - (yield* DesktopHomeOwner.isHomeOwnershipLocked()) + !(yield* DesktopHomeOwner.awaitHomeOwnershipRelease()) ) { const owner = yield* DesktopHomeOwner.readLiveHomeOwner(); yield* logBootstrapWarning("bootstrap found another server owning the T3 home", { diff --git a/apps/desktop/src/backend/DesktopHomeOwner.test.ts b/apps/desktop/src/backend/DesktopHomeOwner.test.ts index 4415020374a1..f0b0df159272 100644 --- a/apps/desktop/src/backend/DesktopHomeOwner.test.ts +++ b/apps/desktop/src/backend/DesktopHomeOwner.test.ts @@ -3,10 +3,12 @@ import * as NodeSqlite from "node:sqlite"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -94,16 +96,26 @@ describe("DesktopHomeOwner.readLiveHomeOwner", () => { ); }); -describe("DesktopHomeOwner.isHomeOwnershipLocked", () => { - it.effect("reports the ownership lock only while a server holds it", () => +describe("DesktopHomeOwner.awaitHomeOwnershipRelease", () => { + it.effect("waits for a shutting-down owner to release the lock, then gives up", () => withTempHome((userdata) => Effect.gen(function* () { - assert.isFalse(yield* DesktopHomeOwner.isHomeOwnershipLocked()); + assert.isTrue(yield* DesktopHomeOwner.awaitHomeOwnershipRelease()); + const lock = new NodeSqlite.DatabaseSync(`${userdata}/server-owner.sqlite`); lock.exec("BEGIN EXCLUSIVE;"); - assert.isTrue(yield* DesktopHomeOwner.isHomeOwnershipLocked()); + const released = yield* Effect.forkChild(DesktopHomeOwner.awaitHomeOwnershipRelease()); + yield* TestClock.adjust("5 seconds"); lock.close(); - assert.isFalse(yield* DesktopHomeOwner.isHomeOwnershipLocked()); + yield* TestClock.adjust("1 second"); + assert.isTrue(yield* Fiber.join(released)); + + const held = new NodeSqlite.DatabaseSync(`${userdata}/server-owner.sqlite`); + held.exec("BEGIN EXCLUSIVE;"); + const owned = yield* Effect.forkChild(DesktopHomeOwner.awaitHomeOwnershipRelease()); + yield* TestClock.adjust("1 minute"); + assert.isFalse(yield* Fiber.join(owned)); + held.close(); }), ), ); diff --git a/apps/desktop/src/backend/DesktopHomeOwner.ts b/apps/desktop/src/backend/DesktopHomeOwner.ts index 3faa7bbb096f..9313591eb7b9 100644 --- a/apps/desktop/src/backend/DesktopHomeOwner.ts +++ b/apps/desktop/src/backend/DesktopHomeOwner.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - Probes the server's native SQLite ownership lock. import * as NodeSqlite from "node:sqlite"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -8,6 +9,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { BACKEND_QUIT_STOP_TIMEOUT } from "./DesktopBackendManager.ts"; // The fields of the server's `server-runtime.json` that identify the owner of a T3 home. const HomeOwnerRuntimeState = Schema.Struct({ @@ -35,27 +37,43 @@ const isProcessAlive = (pid: number): boolean => { * Whether a server holds the T3 home's ownership lock (an exclusive transaction on * `server-owner.sqlite`). A read-only read is refused with SQLITE_BUSY only while it is held. */ -export const isHomeOwnershipLocked = Effect.fn("DesktopHomeOwner.isHomeOwnershipLocked")( - function* () { - const path = yield* Path.Path; - const { stateDir } = yield* DesktopEnvironment.DesktopEnvironment; - const lockPath = path.join(stateDir, "server-owner.sqlite"); - return yield* Effect.sync(() => { +const isHomeOwnershipLocked = Effect.fn("DesktopHomeOwner.isHomeOwnershipLocked")(function* () { + const path = yield* Path.Path; + const { stateDir } = yield* DesktopEnvironment.DesktopEnvironment; + const lockPath = path.join(stateDir, "server-owner.sqlite"); + return yield* Effect.sync(() => { + try { + const db = new NodeSqlite.DatabaseSync(lockPath, { readOnly: true }); try { - const db = new NodeSqlite.DatabaseSync(lockPath, { readOnly: true }); - try { - db.exec("PRAGMA busy_timeout = 0"); - db.prepare("SELECT count(*) FROM sqlite_master").get(); - return false; - } finally { - db.close(); - } - } catch (cause) { - // Anything but SQLITE_BUSY (a missing file included) leaves the decision to the backend. - return cause instanceof Error && "errcode" in cause && cause.errcode === 5; + db.exec("PRAGMA busy_timeout = 0"); + db.prepare("SELECT count(*) FROM sqlite_master").get(); + return false; + } finally { + db.close(); } - }); + } catch (cause) { + // Anything but SQLITE_BUSY (a missing file included) leaves the decision to the backend. + return cause instanceof Error && "errcode" in cause && cause.errcode === 5; + } + }); +}); + +// A quitting app's backend holds the lock until it exits, which can outlast the relaunch of an update. +const HOME_OWNERSHIP_RELEASE_GRACE = Duration.sum(BACKEND_QUIT_STOP_TIMEOUT, Duration.seconds(3)); +const HOME_OWNERSHIP_RELEASE_POLL_INTERVAL = Duration.millis(250); + +/** Whether the ownership lock is free, or frees within the grace a shutting-down server needs. */ +export const awaitHomeOwnershipRelease = Effect.fn("DesktopHomeOwner.awaitHomeOwnershipRelease")( + function* () { + while (yield* isHomeOwnershipLocked()) { + yield* Effect.sleep(HOME_OWNERSHIP_RELEASE_POLL_INTERVAL); + } + return true; }, + Effect.timeoutOrElse({ + duration: HOME_OWNERSHIP_RELEASE_GRACE, + orElse: () => Effect.succeed(false), + }), ); /** The live server recorded as owning the desktop's T3 home. Only call once the ownership lock is known held: a live PID alone may be reused. */