Repository navigation
fix(desktop): check T3 home ownership before starting the local backend #34512
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
Changes from all commits
90e642a
3443b02
6aab59a
c33cf20
fa15d25
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 |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; | |
| import * as Exit from "effect/Exit"; | ||
| import * as Layer from "effect/Layer"; | ||
| import * as Option from "effect/Option"; | ||
| import * as Path from "effect/Path"; | ||
| import * as Schema from "effect/Schema"; | ||
| import * as Scope from "effect/Scope"; | ||
| import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
|
|
@@ -15,6 +16,8 @@ import { ChildProcessSpawner } from "effect/process"; | |
|
|
||
| import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; | ||
| import * as DesktopBackendManager from "./DesktopBackendManager.ts"; | ||
| import * as DesktopHomeOwner from "./DesktopHomeOwner.ts"; | ||
| import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | ||
| import * as DesktopObservability from "../app/DesktopObservability.ts"; | ||
| import * as DesktopState from "../app/DesktopState.ts"; | ||
| import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | ||
|
|
@@ -112,6 +115,9 @@ export const layer = Layer.effect( | |
| // Parent instance scopes to the pool so shutdown runs stop finalizers before the OS kills backends. | ||
| const layerScope = yield* Scope.Scope; | ||
| const factoryContext = yield* Effect.context<BackendInstanceFactoryRequirements>(); | ||
| const homeOwnerContext = yield* Effect.context< | ||
| FileSystem.FileSystem | Path.Path | DesktopEnvironment.DesktopEnvironment | ||
| >(); | ||
|
|
||
| // Persist fatal Windows fallbacks; transient transport failures must preserve the user's WSL preference. | ||
| const handlePrimaryPreflightFailure = Effect.fn("desktop.backendPool.primaryPreflightFailed")( | ||
|
|
@@ -174,12 +180,16 @@ export const layer = Layer.effect( | |
| onShutdown: () => desktopWindow.handleBackendNotReady, | ||
| onPreflightFailed: handlePrimaryPreflightFailure, | ||
| onStateDirOwned: () => | ||
| electronDialog | ||
| .showErrorBox( | ||
| "This T3 home is unavailable", | ||
| "Another T3 Code server, such as the background service, is using this data directory, or an older background service is set up for it. Stop that server or try `t3 update`. If the older launcher cannot update, run `t3 service uninstall` and optionally reinstall with `t3 service install` from a current CLI. Use `npx t3@latest` if `t3` is missing or outdated, and pass `--base-dir` with the service's T3 home to each command. Then reopen T3 Code. You can also set a separate T3CODE_HOME and pair with the running server.", | ||
| ) | ||
| .pipe(Effect.andThen(electronApp.quit)), | ||
| DesktopHomeOwner.readLiveHomeOwner().pipe( | ||
|
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.
|
||
| Effect.flatMap((owner) => | ||
| electronDialog.showErrorBox( | ||
| "This T3 home is unavailable", | ||
| DesktopHomeOwner.homeOwnedMessage(owner), | ||
| ), | ||
| ), | ||
| Effect.provideContext(homeOwnerContext), | ||
| Effect.andThen(electronApp.quit), | ||
| ), | ||
| }); | ||
|
|
||
| const instancesRef = yield* SynchronizedRef.make< | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| 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 FileSystem from "effect/FileSystem"; | ||
| import * as Layer from "effect/Layer"; | ||
| import * as Option from "effect/Option"; | ||
| import * as Path from "effect/Path"; | ||
|
|
||
| import * as DesktopConfig from "../app/DesktopConfig.ts"; | ||
| import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | ||
| import * as DesktopHomeOwner from "./DesktopHomeOwner.ts"; | ||
|
|
||
| const withTempHome = <A, E>( | ||
| run: ( | ||
| userdata: string, | ||
| ) => Effect.Effect< | ||
| A, | ||
| E, | ||
| DesktopEnvironment.DesktopEnvironment | FileSystem.FileSystem | Path.Path | ||
| >, | ||
| ) => | ||
| Effect.gen(function* () { | ||
| const fs = yield* FileSystem.FileSystem; | ||
| const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-desktop-home-owner-" }); | ||
| yield* fs.makeDirectory(`${home}/userdata`, { recursive: true }); | ||
| return yield* run(`${home}/userdata`).pipe( | ||
| Effect.provide( | ||
| DesktopEnvironment.layer({ | ||
| dirname: "/repo/apps/desktop/src", | ||
| homeDirectory: home, | ||
| platform: "linux", | ||
| processArch: "x64", | ||
| appVersion: "0.0.0", | ||
| appPath: "/repo", | ||
| isPackaged: true, | ||
| resourcesPath: "/missing/resources", | ||
| runningUnderArm64Translation: false, | ||
| }).pipe( | ||
| Layer.provide( | ||
| Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: home })), | ||
| ), | ||
| ), | ||
| ), | ||
| ); | ||
| }).pipe(Effect.provide(NodeServices.layer)); | ||
|
|
||
| const readOwnerWithRuntimeFile = (contents: string | undefined) => | ||
| withTempHome((userdata) => | ||
| Effect.gen(function* () { | ||
| if (contents !== undefined) { | ||
| yield* (yield* FileSystem.FileSystem).writeFileString( | ||
| `${userdata}/server-runtime.json`, | ||
| contents, | ||
| ); | ||
| } | ||
| return yield* DesktopHomeOwner.readLiveHomeOwner(); | ||
| }), | ||
| ); | ||
|
|
||
| describe("DesktopHomeOwner.readLiveHomeOwner", () => { | ||
| it.effect("reports a live server recorded in server-runtime.json", () => | ||
| Effect.gen(function* () { | ||
| const owner = yield* readOwnerWithRuntimeFile( | ||
| JSON.stringify({ | ||
| version: 1, | ||
| pid: process.pid, | ||
| port: 3790, | ||
| origin: "http://127.0.0.1:3790", | ||
| startedAt: "2026-01-01T00:00:00.000Z", | ||
| serverVersion: "1.2.3", | ||
| }), | ||
| ); | ||
| assert.deepStrictEqual( | ||
| owner, | ||
| Option.some({ pid: process.pid, port: 3790, serverVersion: "1.2.3" }), | ||
| ); | ||
| assert.include( | ||
| DesktopHomeOwner.homeOwnedMessage(owner), | ||
| `PID ${process.pid}, port 3790, version 1.2.3`, | ||
| ); | ||
| }), | ||
| ); | ||
|
|
||
| it.effect("ignores a missing, malformed or dead owner record", () => | ||
| Effect.gen(function* () { | ||
| assert.isTrue(Option.isNone(yield* readOwnerWithRuntimeFile(undefined))); | ||
| assert.isTrue(Option.isNone(yield* readOwnerWithRuntimeFile("{not json"))); | ||
| assert.isTrue( | ||
| Option.isNone(yield* readOwnerWithRuntimeFile(JSON.stringify({ pid: 0, port: 3790 }))), | ||
| ); | ||
| }), | ||
| ); | ||
| }); | ||
|
|
||
| describe("DesktopHomeOwner.isHomeOwnershipLocked", () => { | ||
| it.effect("reports the ownership lock only while a server holds it", () => | ||
| withTempHome((userdata) => | ||
| Effect.gen(function* () { | ||
| assert.isFalse(yield* DesktopHomeOwner.isHomeOwnershipLocked()); | ||
| const lock = new NodeSqlite.DatabaseSync(`${userdata}/server-owner.sqlite`); | ||
| lock.exec("BEGIN EXCLUSIVE;"); | ||
| assert.isTrue(yield* DesktopHomeOwner.isHomeOwnershipLocked()); | ||
| lock.close(); | ||
| assert.isFalse(yield* DesktopHomeOwner.isHomeOwnershipLocked()); | ||
| }), | ||
| ), | ||
| ); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| // @effect-diagnostics nodeBuiltinImport:off - Probes the server's native SQLite ownership lock. | ||
| import * as NodeSqlite from "node:sqlite"; | ||
|
|
||
| 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"; | ||
|
|
||
| // The fields of the server's `server-runtime.json` that identify the owner of a T3 home. | ||
| const HomeOwnerRuntimeState = Schema.Struct({ | ||
| pid: Schema.Int, | ||
| port: Schema.Int, | ||
| serviceManaged: Schema.optional(Schema.Boolean), | ||
| serverVersion: Schema.optional(Schema.String), | ||
| }); | ||
| export type HomeOwner = typeof HomeOwnerRuntimeState.Type; | ||
|
|
||
| const decodeHomeOwner = Schema.decodeUnknownOption(Schema.fromJsonString(HomeOwnerRuntimeState)); | ||
|
|
||
| /** Signal 0 only probes existence; EPERM still means alive. */ | ||
| const isProcessAlive = (pid: number): boolean => { | ||
| if (!Number.isInteger(pid) || pid <= 0) return false; | ||
| try { | ||
| process.kill(pid, 0); | ||
| return true; | ||
| } catch (error) { | ||
| return error instanceof Error && "code" in error && error.code === "EPERM"; | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * 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(() => { | ||
| 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; | ||
| } | ||
| }); | ||
| }, | ||
| ); | ||
|
|
||
| /** 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; | ||
| const path = yield* Path.Path; | ||
| const { stateDir } = yield* DesktopEnvironment.DesktopEnvironment; | ||
| const raw = yield* fs | ||
| .readFileString(path.join(stateDir, "server-runtime.json")) | ||
| .pipe(Effect.option); | ||
| return Option.flatMap(raw, decodeHomeOwner).pipe( | ||
| Option.filter((owner) => isProcessAlive(owner.pid)), | ||
| ); | ||
| }); | ||
|
|
||
| export const homeOwnedMessage = (owner: Option.Option<HomeOwner>): string => | ||
| Option.match(owner, { | ||
| onNone: () => | ||
| "Another T3 Code server, such as the background service, is using this data directory, or an older background service is set up for it. Stop that server or try `t3 update`. If the older launcher cannot update, run `t3 service uninstall` and optionally reinstall with `t3 service install` from a current CLI. Use `npx t3@latest` if `t3` is missing or outdated, and pass `--base-dir` with the service's T3 home to each command. Then reopen T3 Code. You can also set a separate T3CODE_HOME and pair with the running server.", | ||
| onSome: (owner) => { | ||
| const kind = owner.serviceManaged | ||
| ? "The T3 Code background service" | ||
| : "Another T3 Code server (for example `t3 serve`, or one started over SSH)"; | ||
| const version = owner.serverVersion ? `, version ${owner.serverVersion}` : ""; | ||
| const stop = owner.serviceManaged | ||
| ? "Stop it with `t3 service uninstall`, passing `--base-dir` with this T3 home" | ||
| : "If a T3 Code app on another machine started it over SSH, that app restarts it: remove or disconnect that SSH environment there, or quit that app. Then stop the server"; | ||
| return `${kind} is using this T3 home (PID ${owner.pid}, port ${owner.port}${version}). ${stop}, and reopen T3 Code. You can also set a separate T3CODE_HOME and pair with the running server.`; | ||
| }, | ||
| }); |
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.
Add a regression test for the pre-spawn refusal.
The helper tests prove lock detection, but they do not prove that bootstrap checks ownership before selecting a port or starting the backend. The existing
DesktopApptest covers shutdown, not this branch. Add a focused bootstrap case with a held lock. Assert that the app displays the refusal and quits without probing ports or starting the backend. This catches the original doomed-spawn regression. (raw.githubusercontent.com)As per coding guidelines, “Logic, data, state, and protocol bugs get a failing test first.”
Source: Coding guidelines