diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index d515819dd4cf..9697f1aec099 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -224,10 +224,12 @@ const bootstrap = Effect.gen(function* () { return yield* new DesktopDevelopmentBackendPortRequiredError(); } - // A backend started while another server holds this home's lock can only exit with code 78. + // A backend started while another server holds this home's lock can only exit + // with code 78, but a previous server keeps the lock through its shutdown + // drain, so give a draining owner a short grace to release first. 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..2c5cc4c4690f 100644 --- a/apps/desktop/src/backend/DesktopHomeOwner.test.ts +++ b/apps/desktop/src/backend/DesktopHomeOwner.test.ts @@ -2,11 +2,14 @@ import * as NodeSqlite from "node:sqlite"; import * as NodeServices from "@effect/platform-node/NodeServices"; 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 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"; @@ -108,3 +111,37 @@ describe("DesktopHomeOwner.isHomeOwnershipLocked", () => { ), ); }); + +describe("DesktopHomeOwner.awaitHomeOwnershipRelease", () => { + it.effect("resolves as soon as a draining owner releases the lock", () => + withTempHome((userdata) => + Effect.gen(function* () { + assert.isTrue(yield* DesktopHomeOwner.awaitHomeOwnershipRelease()); + + const lock = new NodeSqlite.DatabaseSync(`${userdata}/server-owner.sqlite`); + lock.exec("BEGIN EXCLUSIVE;"); + const awaiting = yield* Effect.forkChild(DesktopHomeOwner.awaitHomeOwnershipRelease()); + yield* TestClock.adjust(Duration.seconds(5)); + assert.isUndefined(awaiting.pollUnsafe()); + + // A previous server releases the lock when its shutdown drain finishes. + lock.close(); + yield* TestClock.adjust(Duration.seconds(1)); + assert.isTrue(yield* Fiber.join(awaiting)); + }), + ), + ); + + it.effect("reports a home still owned after the release grace", () => + withTempHome((userdata) => + Effect.gen(function* () { + const lock = new NodeSqlite.DatabaseSync(`${userdata}/server-owner.sqlite`); + lock.exec("BEGIN EXCLUSIVE;"); + const awaiting = yield* Effect.forkChild(DesktopHomeOwner.awaitHomeOwnershipRelease()); + yield* TestClock.adjust(Duration.minutes(1)); + assert.isFalse(yield* Fiber.join(awaiting)); + lock.close(); + }), + ), + ); +}); diff --git a/apps/desktop/src/backend/DesktopHomeOwner.ts b/apps/desktop/src/backend/DesktopHomeOwner.ts index 3faa7bbb096f..db07a9252229 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"; @@ -58,6 +59,31 @@ export const isHomeOwnershipLocked = Effect.fn("DesktopHomeOwner.isHomeOwnership }, ); +const HOME_OWNERSHIP_RELEASE_GRACE = Duration.seconds(15); +const HOME_OWNERSHIP_RELEASE_POLL_INTERVAL = Duration.millis(250); + +/** + * Whether the ownership lock frees within a grace window. The previous owner + * holds the lock through its shutdown drain, so a launch that checks during + * the drain must wait rather than read the home as permanently owned. + */ +export const awaitHomeOwnershipRelease = Effect.fn("DesktopHomeOwner.awaitHomeOwnershipRelease")( + function* (options?: { readonly grace?: Duration.Input; readonly interval?: Duration.Input }) { + const released = Effect.gen(function* () { + while (yield* isHomeOwnershipLocked()) { + yield* Effect.sleep(options?.interval ?? HOME_OWNERSHIP_RELEASE_POLL_INTERVAL); + } + return true; + }); + return yield* released.pipe( + Effect.timeoutOrElse({ + duration: options?.grace ?? 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. */ export const readLiveHomeOwner = Effect.fn("DesktopHomeOwner.readLiveHomeOwner")(function* () { const fs = yield* FileSystem.FileSystem;