Skip to content
Draft
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
6 changes: 6 additions & 0 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
import * as DesktopClerk from "./DesktopClerk.ts";
import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as DesktopBackendDatabaseOwner from "../backend/DesktopBackendDatabaseOwner.ts";
import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopLifecycle from "./DesktopLifecycle.ts";
Expand Down Expand Up @@ -154,6 +155,11 @@ const bootstrap = Effect.gen(function* () {
return yield* new DesktopDevelopmentBackendPortRequiredError();
}

yield* DesktopBackendDatabaseOwner.ensureDesktopBackendDatabaseAvailable({
stateDir: environment.stateDir,
joinPath: environment.path.join,
});

const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort);
const backendPort = backendPortSelection.port;
yield* logBootstrapInfo(
Expand Down
127 changes: 127 additions & 0 deletions apps/desktop/src/backend/DesktopBackendDatabaseOwner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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 Ref from "effect/Ref";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";

import {
DesktopBackendDatabaseOwnedError,
ensureDesktopBackendDatabaseAvailable,
} from "./DesktopBackendDatabaseOwner.ts";

const descriptor = {
environmentId: "local-environment",
label: "Local environment",
platform: { os: "linux", arch: "x64" },
serverVersion: "0.0.34",
capabilities: {},
};

const runtimeState = '{"version":1,"pid":12345,"origin":"http://127.0.0.1:3773"}';

const withStateDir = <A, E, R>(
effect: (input: {
readonly stateDir: string;
readonly statePath: string;
readonly requestCount: Ref.Ref<number>;
}) => Effect.Effect<A, E, R>,
responseStatus = 200,
) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const stateDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-database-owner-test-",
});
const statePath = `${stateDir}/server-runtime.json`;
const requestCount = yield* Ref.make(0);
const httpClientLayer = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Ref.update(requestCount, (count) => count + 1).pipe(
Effect.as(
HttpClientResponse.fromWeb(
request,
new Response(JSON.stringify(descriptor), {
status: responseStatus,
headers: { "content-type": "application/json" },
}),
),
),
),
),
);
return yield* effect({ stateDir, statePath, requestCount }).pipe(
Effect.provide(httpClientLayer),
);
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped);

const ensureAvailable = (input: {
readonly stateDir: string;
readonly isProcessAlive: (pid: number) => boolean;
}) =>
ensureDesktopBackendDatabaseAvailable({
stateDir: input.stateDir,
joinPath: (...parts) => parts.join("/"),
isProcessAlive: input.isProcessAlive,
});

describe("DesktopBackendDatabaseOwner", () => {
it.effect("allows startup when no runtime state exists", () =>
withStateDir(({ stateDir, requestCount }) =>
Effect.gen(function* () {
yield* ensureAvailable({ stateDir, isProcessAlive: () => true });
assert.equal(yield* Ref.get(requestCount), 0);
}),
),
);

it.effect("allows startup when the recorded process is gone", () =>
withStateDir(({ stateDir, statePath, requestCount }) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
yield* fileSystem.writeFileString(statePath, runtimeState);

yield* ensureAvailable({ stateDir, isProcessAlive: () => false });
assert.equal(yield* Ref.get(requestCount), 0);
}),
),
);

it.effect("allows startup when the recorded endpoint is not a live T3 server", () =>
withStateDir(
({ stateDir, statePath, requestCount }) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
yield* fileSystem.writeFileString(statePath, runtimeState);

yield* ensureAvailable({ stateDir, isProcessAlive: () => true });
assert.equal(yield* Ref.get(requestCount), 1);
}),
404,
),
);

it.effect("blocks startup when a live T3 server owns the database", () =>
withStateDir(({ stateDir, statePath, requestCount }) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
yield* fileSystem.writeFileString(statePath, runtimeState);

const error = yield* ensureAvailable({
stateDir,
isProcessAlive: () => true,
}).pipe(Effect.flip);

assert.instanceOf(error, DesktopBackendDatabaseOwnedError);
assert.equal(error.stateDir, stateDir);
assert.equal(error.origin, "http://127.0.0.1:3773");
assert.equal(error.pid, 12_345);
assert.include(error.message, "Starting another backend with the same database is unsafe.");
assert.equal(yield* Ref.get(requestCount), 1);
}),
),
);
});
106 changes: 106 additions & 0 deletions apps/desktop/src/backend/DesktopBackendDatabaseOwner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
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 Schema from "effect/Schema";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";

const SERVER_RUNTIME_STATE_FILE_NAME = "server-runtime.json";
const SERVER_PROBE_TIMEOUT = Duration.seconds(2);
const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment";

const PersistedServerRuntimeOwner = Schema.Struct({
version: Schema.Literal(1),
pid: Schema.Int,
origin: Schema.String,
});
type PersistedServerRuntimeOwner = typeof PersistedServerRuntimeOwner.Type;

const decodePersistedServerRuntimeOwner = Schema.decodeUnknownEffect(
Schema.fromJsonString(PersistedServerRuntimeOwner),
);

export class DesktopBackendDatabaseOwnedError extends Schema.TaggedErrorClass<DesktopBackendDatabaseOwnedError>()(
"DesktopBackendDatabaseOwnedError",
{
stateDir: Schema.String,
origin: Schema.String,
pid: Schema.Int,
},
) {
override get message(): string {
return [
`A running T3 Code server at ${this.origin} (PID ${String(this.pid)}) already uses ${this.stateDir}.`,
"Starting another backend with the same database is unsafe.",
"Stop the background service before opening the desktop app, or start the desktop app with a separate T3CODE_HOME and connect to the running environment.",
].join("\n");
}
}

const readRuntimeOwner = (
statePath: string,
): Effect.Effect<Option.Option<PersistedServerRuntimeOwner>, never, FileSystem.FileSystem> =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const raw = yield* fileSystem.readFileString(statePath).pipe(Effect.option);
if (Option.isNone(raw) || raw.value.trim().length === 0) {
return Option.none();
}
return yield* decodePersistedServerRuntimeOwner(raw.value.trim()).pipe(Effect.option);
});

const defaultIsProcessAlive = (pid: number): boolean => {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error instanceof Error && "code" in error && error.code === "EPERM";
}
};

const probeT3Server = (origin: string): Effect.Effect<boolean, never, HttpClient.HttpClient> =>
Effect.gen(function* () {
const httpClient = yield* HttpClient.HttpClient;
const endpoint = yield* Effect.try(() =>
new URL(WELL_KNOWN_ENVIRONMENT_PATH, origin).toString(),
);
const request = HttpClientRequest.get(endpoint);
yield* httpClient
.execute(request)
.pipe(
Effect.flatMap(HttpClientResponse.filterStatusOk),
Effect.flatMap(HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)),
Effect.timeout(SERVER_PROBE_TIMEOUT),
);
return true;
}).pipe(Effect.orElseSucceed(() => false));

export const ensureDesktopBackendDatabaseAvailable = Effect.fn(
"desktop.backendDatabaseOwner.ensureAvailable",
)(function* (input: {
readonly stateDir: string;
readonly joinPath: (...parts: ReadonlyArray<string>) => string;
readonly isProcessAlive?: (pid: number) => boolean;
}) {
const statePath = input.joinPath(input.stateDir, SERVER_RUNTIME_STATE_FILE_NAME);
const runtimeOwner = yield* readRuntimeOwner(statePath);
if (Option.isNone(runtimeOwner)) {
return;
}

const owner = runtimeOwner.value;
if (!(input.isProcessAlive ?? defaultIsProcessAlive)(owner.pid)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High backend/DesktopBackendDatabaseOwner.ts:93

ensureDesktopBackendDatabaseAvailable can block desktop startup on a stale runtime file even when no live server owns the state directory. The PID check only verifies that some process with owner.pid is alive, and the HTTP probe only verifies that some T3 server is reachable at owner.origin — the two checks are independent and never confirm that the live process is the one serving at that origin. PID reuse by an unrelated process and port reuse by a different T3 server make both checks pass, triggering a false DesktopBackendDatabaseOwnedError. If this loose coupling is acceptable, consider documenting the rationale; otherwise, bind the checks together (for example, have the probed server report its own PID and compare it to owner.pid).

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/backend/DesktopBackendDatabaseOwner.ts around line 93:

`ensureDesktopBackendDatabaseAvailable` can block desktop startup on a stale runtime file even when no live server owns the state directory. The PID check only verifies that *some* process with `owner.pid` is alive, and the HTTP probe only verifies that *some* T3 server is reachable at `owner.origin` — the two checks are independent and never confirm that the live process is the one serving at that origin. PID reuse by an unrelated process and port reuse by a different T3 server make both checks pass, triggering a false `DesktopBackendDatabaseOwnedError`. If this loose coupling is acceptable, consider documenting the rationale; otherwise, bind the checks together (for example, have the probed server report its own PID and compare it to `owner.pid`).

return;
}

if (!(yield* probeT3Server(owner.origin))) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High backend/DesktopBackendDatabaseOwner.ts:97

When probeT3Server returns false (probe failure or 2-second timeout), ensureDesktopBackendDatabaseAvailable returns successfully and lets the desktop proceed — but the owning process may still be alive and holding the database. A starting or transiently slow T3 server can fail the probe while already owning the SQLite database, so this preflight allows a second backend to launch against the same database, reintroducing the lock/duplicate-processor conflict it is meant to prevent. Consider failing (or blocking until the probe succeeds) when the owner process is alive but the probe does not confirm a healthy server, rather than treating a failed probe as safe.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/backend/DesktopBackendDatabaseOwner.ts around line 97:

When `probeT3Server` returns `false` (probe failure or 2-second timeout), `ensureDesktopBackendDatabaseAvailable` returns successfully and lets the desktop proceed — but the owning process may still be alive and holding the database. A starting or transiently slow T3 server can fail the probe while already owning the SQLite database, so this preflight allows a second backend to launch against the same database, reintroducing the lock/duplicate-processor conflict it is meant to prevent. Consider failing (or blocking until the probe succeeds) when the owner process is alive but the probe does not confirm a healthy server, rather than treating a failed probe as safe.

return;
}

return yield* new DesktopBackendDatabaseOwnedError({
stateDir: input.stateDir,
origin: owner.origin,
pid: owner.pid,
});
});
Loading