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/backend/DesktopBackendPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export const layer = Layer.effect(
electronDialog
.showErrorBox(
"This T3 home is unavailable",
"Another T3 Code server, such as the background service, is using this data directory. Stop that server and reopen T3 Code, or set a separate T3CODE_HOME and pair with the running server.",
"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)),
});
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/wsl/DesktopWslBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export const layer = Layer.effect(
Ref.set(
preflightErrorRef,
Option.some(
"Another T3 Code server is using this distro's T3 home. Stop it, or pair with it instead.",
"Another T3 Code server is using this distro's T3 home, or an older background service is set up for it. Stop that server or pair with it, or try `t3 update` in the distro. If the older launcher cannot update, run `t3 service uninstall` and optionally reinstall with `t3 service install` from a current CLI in the distro. Use `npx t3@latest` if `t3` is missing or outdated, and pass `--base-dir` with the service's T3 home to each command.",
),
),
),
Expand Down
6 changes: 3 additions & 3 deletions apps/server/src/cloud/bootService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ import {
} from "./serviceProtocol.ts";

const BOOT_SERVICE_NAME = "t3code";
const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`;
export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`;
// `.service` suffix keeps the label distinct from the desktop app's bundle id
// (com.t3tools.t3code), so launchd and TCC records never collide.
const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service";
const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`;
export const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service";
export const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`;
const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT";
export const BOOT_SERVICE_LOG_FILE = "boot-service.log";

Expand Down
99 changes: 99 additions & 0 deletions apps/server/src/serverOwnership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,25 @@ import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Crypto from "effect/Crypto";
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 Runtime from "effect/Runtime";
import * as Schema from "effect/Schema";

import {
BOOT_SERVICE_LAUNCHD_LABEL,
BOOT_SERVICE_PLIST_FILE,
BOOT_SERVICE_UNIT_FILE,
bootServiceBaseDirOf,
} from "./cloud/bootService.ts";
import { ServiceLauncherHostProcess } from "./cloud/serviceLauncherClient.ts";
import {
SERVICE_LAUNCHER_CONTEXT_ENV,
SERVICE_RESTART_PENDING_FILE,
SERVICE_STATE_FILE,
compareExactServiceVersions,
serviceStateActiveVersion,
} from "./cloud/serviceProtocol.ts";
import * as ProcessRunner from "./processRunner.ts";
import {
clearPersistedServerRuntimeState,
Expand All @@ -34,6 +49,17 @@ export class ServerAlreadyRunningError extends Schema.TaggedError<ServerAlreadyR
}
}

export class OlderBootServiceError extends Schema.TaggedError<OlderBootServiceError>()(
"OlderBootServiceError",
{ baseDir: Schema.String },
) {
override readonly [Runtime.errorExitCode] = SERVER_EXIT_CODE_STATE_DIR_OWNED;

override get message(): string {
return `A T3 Code background service set up for ${this.baseDir} may still run a release without the ownership lock. On that machine, try \`t3 update\` and let it restart (or run \`t3 service restart\` if the update is already installed). If the older launcher cannot update, run \`t3 service uninstall\`, then reinstall with \`t3 service install\` from a current CLI. You can also leave the service uninstalled. Pass \`--base-dir ${this.baseDir}\` to each command, and use \`npx t3@latest\` if \`t3\` is not installed or is outdated. Then retry. No server was stopped.`;
}
}

export class ServerOwnershipError extends Schema.TaggedError<ServerOwnershipError>()(
"ServerOwnershipError",
{ stateDir: Schema.String, cause: Schema.Defect() },
Expand Down Expand Up @@ -83,6 +109,75 @@ const legacyOwnerIsLive = Effect.fn("legacyOwnerIsLive")(function* (
return !Number.isFinite(startedAt) || startedAt <= recordedAt + 1_000;
});

// Pre-lock services become invisible when another server replaces their runtime
// record. Unreadable configuration or an unavailable manager allows startup.
const olderBootServiceHome = Effect.fn("olderBootServiceHome")(
function* (stateDir: string) {
if (
(yield* ServiceLauncherHostProcess).connected &&
(yield* HostProcess.Environment)[SERVICE_LAUNCHER_CONTEXT_ENV] !== undefined
) {
return undefined;
}
const platform = yield* HostProcess.Platform;
const uid = yield* HostProcess.UserId;
if (platform !== "linux" && (platform !== "darwin" || uid === undefined)) return undefined;

const homeDir = yield* HostProcess.HomeDirectory;
const fs = yield* FileSystem.FileSystem;
const runner = yield* ProcessRunner.ProcessRunner;
const unitPath =
platform === "darwin"
? NodePath.join(homeDir, "Library", "LaunchAgents", BOOT_SERVICE_PLIST_FILE)
: NodePath.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE);
const baseDir = bootServiceBaseDirOf(yield* fs.readFileString(unitPath));
if (
baseDir === undefined ||
(yield* fs.realPath(NodePath.join(baseDir, "userdata"))) !== (yield* fs.realPath(stateDir))
) {
return undefined;
}
const runtimeDir = NodePath.join(baseDir, "runtime");
const version = serviceStateActiveVersion(
yield* fs.readFileString(NodePath.join(runtimeDir, SERVICE_STATE_FILE)),
);
if (version === undefined) return undefined;
const run = (command: string, args: ReadonlyArray<string>) =>
runner.run({ command, args, timeout: Duration.seconds(5), maxOutputBytes: 65_536 });
let loaded: boolean;
if (platform === "darwin") {
loaded =
(yield* run("launchctl", ["print", `gui/${uid}/${BOOT_SERVICE_LAUNCHD_LABEL}`])).code === 0;
if (!loaded) {
const disabled = yield* run("launchctl", ["print-disabled", `gui/${uid}`]);
// Older macOS versions print `true` instead of `disabled`.
if (
disabled.code !== 0 ||
disabled.stdout.includes(`"${BOOT_SERVICE_LAUNCHD_LABEL}" => disabled`) ||
disabled.stdout.includes(`"${BOOT_SERVICE_LAUNCHD_LABEL}" => true`)
)
return undefined;
}
} else {
const active = yield* run("systemctl", ["--user", "is-active", BOOT_SERVICE_UNIT_FILE]);
// A unit waiting out its Restart= delay is activating, with exit code 3.
loaded = active.code === 0 || (active.code === 3 && active.stdout.trim() === "activating");
if (!loaded) {
const enabled = yield* run("systemctl", ["--user", "is-enabled", BOOT_SERVICE_UNIT_FILE]);
if (enabled.code !== 0 || enabled.stdout.trim() !== "enabled") return undefined;
}
}
// A deferred update names the staged runtime, not the loaded service's runtime.
if (loaded && (yield* fs.exists(NodePath.join(runtimeDir, SERVICE_RESTART_PENDING_FILE)))) {
return baseDir;
}
// Stable 0.1.0 shipped the ownership lock. Earlier lock-aware prereleases
// are conservatively refused because older runtimes expose no capability marker.
return compareExactServiceVersions(version, "0.1.0") < 0 ? baseDir : undefined;
},
Effect.orElseSucceed(() => undefined),
);

/**
* Hold an exclusive lock on the state directory until the scope closes. The
* lock is an exclusive transaction on `server-owner.sqlite`, which never holds
Expand Down Expand Up @@ -137,6 +232,10 @@ export const acquireServerOwnership = Effect.fn("acquireServerOwnership")(functi
) {
return yield* new ServerAlreadyRunningError({ stateDir });
}
const olderServiceHome = yield* olderBootServiceHome(stateDir);
if (olderServiceHome !== undefined) {
return yield* new OlderBootServiceError({ baseDir: olderServiceHome });
}

return {
publish: (state: PersistedServerRuntimeState) =>
Expand Down
Loading
Loading