-
Notifications
You must be signed in to change notification settings - Fork 4.1k
fix(desktop): prevent shared database ownership #6098
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| }), | ||
| ), | ||
| ); | ||
| }); |
| 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)) { | ||
| return; | ||
| } | ||
|
|
||
| if (!(yield* probeT3Server(owner.origin))) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High When 🤖 Copy this AI Prompt to have your agent fix this: |
||
| return; | ||
| } | ||
|
|
||
| return yield* new DesktopBackendDatabaseOwnedError({ | ||
| stateDir: input.stateDir, | ||
| origin: owner.origin, | ||
| pid: owner.pid, | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 High
backend/DesktopBackendDatabaseOwner.ts:93ensureDesktopBackendDatabaseAvailablecan 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 withowner.pidis alive, and the HTTP probe only verifies that some T3 server is reachable atowner.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 falseDesktopBackendDatabaseOwnedError. 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 toowner.pid).🤖 Copy this AI Prompt to have your agent fix this: