Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
22 changes: 17 additions & 5 deletions apps/desktop/src/backend/DesktopHomeOwner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
}),
),
);
Expand Down
54 changes: 36 additions & 18 deletions apps/desktop/src/backend/DesktopHomeOwner.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
// @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";
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({
Expand Down Expand Up @@ -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. */
Expand Down
Loading