Skip to content
Source
Sign inSign up

serverOwnership.ts

293 lines12.0 KB

1// @effect-diagnostics nodeBuiltinImport:off - The ownership lock is a native SQLite handle held for the server's lifetime.
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4import * as NodeSqlite from "node:sqlite";
5​
6import { SERVER_EXIT_CODE_STATE_DIR_OWNED } from "@t3tools/contracts";
7import * as HostProcess from "@t3tools/shared/HostProcess";
8import * as SecureRandom from "effect/crypto/SecureRandom";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Option from "effect/Option";
13import * as Runtime from "effect/Runtime";
14import * as Schema from "effect/Schema";
15​
16import {
17 BOOT_SERVICE_LAUNCHD_LABEL,
18 BOOT_SERVICE_PLIST_FILE,
19 BOOT_SERVICE_UNIT_FILE,
20 bootServiceBaseDirOf,
21} from "./cloud/bootService.ts";
22import { ServiceLauncherHostProcess } from "./cloud/serviceLauncherClient.ts";
23import {
24 SERVICE_LAUNCHER_CONTEXT_ENV,
25 SERVICE_RESTART_PENDING_FILE,
26 SERVICE_STATE_FILE,
27 compareExactServiceVersions,
28 serviceStateActiveVersion,
29} from "./cloud/serviceProtocol.ts";
30import {
31 legacyOwnerStartedBeforeRecord,
32 legacyOwnerStartTimeCommand,
33} from "./legacyServerOwner.ts";
34import * as ProcessRunner from "./processRunner.ts";
35import {
36 clearPersistedServerRuntimeState,
37 isProcessAlive,
38 persistServerRuntimeState,
39 readPersistedServerRuntimeState,
40 type PersistedServerRuntimeState,
41} from "./serverRuntimeState.ts";
42​
43export class ServerAlreadyRunningError extends Schema.TaggedError<ServerAlreadyRunningError>()(
44 "ServerAlreadyRunningError",
45 { stateDir: Schema.String },
46) {
47 // Distinct exit codes let supervisors stop restarting an already-owned server.
48 override readonly [Runtime.errorExitCode] = SERVER_EXIT_CODE_STATE_DIR_OWNED;
49​
50 override get message(): string {
51 return `A T3 Code server already owns ${this.stateDir}. Stop that server, or use a separate T3 home and pair with it, then retry. No server was stopped. server-owner.sqlite is the ownership lock; do not delete it while a server is running.`;
52 }
54​
55export class OlderBootServiceError extends Schema.TaggedError<OlderBootServiceError>()(
56 "OlderBootServiceError",
57 { baseDir: Schema.String },
58) {
59 override readonly [Runtime.errorExitCode] = SERVER_EXIT_CODE_STATE_DIR_OWNED;
60​
61 override get message(): string {
62 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.`;
63 }
65​
66export class ServerOwnershipError extends Schema.TaggedError<ServerOwnershipError>()(
67 "ServerOwnershipError",
68 { stateDir: Schema.String, cause: Schema.Defect() },
69) {
70 override get message(): string {
71 return `Could not acquire server ownership of ${this.stateDir}.`;
72 }
74​
75const isSqliteBusy = (cause: unknown) =>
76 cause instanceof Error &&
77 (("errcode" in cause && cause.errcode === 5) ||
78 ("code" in cause && cause.code === "SQLITE_BUSY"));
79​
80// Pre-lock servers publish only runtime state; require start-time proof of PID reuse before treating it as stale.
81const legacyOwnerIsLive = Effect.fn("legacyOwnerIsLive")(function* (
82 state: PersistedServerRuntimeState,
83) {
84 if (!isProcessAlive(state.pid)) return false;
85 const recordedAt = Date.parse(state.startedAt);
86 if (!Number.isFinite(recordedAt)) return true;
87 const platform = yield* HostProcess.Platform;
88 const runner = yield* ProcessRunner.ProcessRunner;
89 const result = yield* runner
90 .run({
91 ...legacyOwnerStartTimeCommand(state.pid, platform),
92 timeout: Duration.seconds(2),
93 maxOutputBytes: 16_384,
94 })
95 .pipe(Effect.option);
96 return legacyOwnerStartedBeforeRecord(
97 Option.isSome(result) && result.value.code === 0 ? result.value.stdout : undefined,
98 recordedAt,
99 platform,
100 );
101});
102​
103// Pre-lock services become invisible when another server replaces their runtime
104// record. Unreadable configuration or an unavailable manager allows startup.
105const olderBootServiceHome = Effect.fn("olderBootServiceHome")(
106 function* (stateDir: string) {
107 if (
108 (yield* ServiceLauncherHostProcess).connected &&
109 (yield* HostProcess.Environment)[SERVICE_LAUNCHER_CONTEXT_ENV] !== undefined
110 ) {
111 return undefined;
112 }
113 const platform = yield* HostProcess.Platform;
114 const uid = yield* HostProcess.UserId;
115 if (platform !== "linux" && (platform !== "darwin" || uid === undefined)) return undefined;
116​
117 const homeDir = yield* HostProcess.HomeDirectory;
118 const fs = yield* FileSystem.FileSystem;
119 const runner = yield* ProcessRunner.ProcessRunner;
120 const unitPath =
121 platform === "darwin"
122 ? NodePath.join(homeDir, "Library", "LaunchAgents", BOOT_SERVICE_PLIST_FILE)
123 : NodePath.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE);
124 const baseDir = bootServiceBaseDirOf(yield* fs.readFileString(unitPath));
125 if (
126 baseDir === undefined ||
127 (yield* fs.realPath(NodePath.join(baseDir, "userdata"))) !== (yield* fs.realPath(stateDir))
128 ) {
129 return undefined;
130 }
131 const runtimeDir = NodePath.join(baseDir, "runtime");
132 const version = serviceStateActiveVersion(
133 yield* fs.readFileString(NodePath.join(runtimeDir, SERVICE_STATE_FILE)),
134 );
135 if (version === undefined) return undefined;
136 const run = (command: string, args: ReadonlyArray<string>) =>
137 runner.run({ command, args, timeout: Duration.seconds(5), maxOutputBytes: 65_536 });
138 let loaded: boolean;
139 if (platform === "darwin") {
140 loaded =
141 (yield* run("launchctl", ["print", `gui/${uid}/${BOOT_SERVICE_LAUNCHD_LABEL}`])).code === 0;
142 if (!loaded) {
143 const disabled = yield* run("launchctl", ["print-disabled", `gui/${uid}`]);
144 // Older macOS versions print `true` instead of `disabled`.
145 if (
146 disabled.code !== 0 ||
147 disabled.stdout.includes(`"${BOOT_SERVICE_LAUNCHD_LABEL}" => disabled`) ||
148 disabled.stdout.includes(`"${BOOT_SERVICE_LAUNCHD_LABEL}" => true`)
149 )
150 return undefined;
151 }
152 } else {
153 const active = yield* run("systemctl", ["--user", "is-active", BOOT_SERVICE_UNIT_FILE]);
154 // A unit waiting out its Restart= delay is activating, with exit code 3.
155 loaded = active.code === 0 || (active.code === 3 && active.stdout.trim() === "activating");
156 if (!loaded) {
157 const enabled = yield* run("systemctl", ["--user", "is-enabled", BOOT_SERVICE_UNIT_FILE]);
158 if (enabled.code !== 0 || enabled.stdout.trim() !== "enabled") return undefined;
159 }
160 }
161 // A deferred update names the staged runtime, not the loaded service's runtime.
162 if (loaded && (yield* fs.exists(NodePath.join(runtimeDir, SERVICE_RESTART_PENDING_FILE)))) {
163 return baseDir;
164 }
165 // Stable 0.1.0 shipped the ownership lock. Earlier lock-aware prereleases
166 // are conservatively refused because older runtimes expose no capability marker.
167 return compareExactServiceVersions(version, "0.1.0") < 0 ? baseDir : undefined;
168 },
169 Effect.orElseSucceed(() => undefined),
170);
171​
172const ownershipLockPath = (stateDir: string) => NodePath.join(stateDir, "server-owner.sqlite");
173​
174/**
175 * The lock is an exclusive transaction on `server-owner.sqlite`, which never
176 * holds data and must never be unlinked: the OS releases it when the process
177 * exits, even on SIGKILL, so a crashed owner never blocks the next start.
178 */
179const openOwnershipLock = (stateDir: string) =>
180 Effect.try({
181 try: () => {
182 const lockPath = ownershipLockPath(stateDir);
183 NodeFS.mkdirSync(stateDir, { recursive: true });
184 let before = NodeFS.statSync(lockPath, { bigint: true, throwIfNoEntry: false });
185 if (before === undefined) {
186 NodeFS.closeSync(NodeFS.openSync(lockPath, "a", 0o644));
187 before = NodeFS.statSync(lockPath, { bigint: true });
188 }
189 const db = new NodeSqlite.DatabaseSync(lockPath);
190 try {
191 // An idle service launcher probes this lock for a moment; wait out that probe.
192 db.exec("PRAGMA busy_timeout = 250; BEGIN EXCLUSIVE;");
193 const identity = NodeFS.statSync(lockPath, { bigint: true });
194 return {
195 db,
196 identity,
197 replacedDuringAcquire: before.dev !== identity.dev || before.ino !== identity.ino,
198 };
199 } catch (cause) {
200 db.close();
201 throw cause;
202 }
203 },
204 catch: (cause) =>
205 isSqliteBusy(cause)
206 ? new ServerAlreadyRunningError({ stateDir })
207 : new ServerOwnershipError({ stateDir, cause }),
208 });
209​
210/** Hold only the ownership lock until the scope closes, without claiming the runtime state. */
211export const holdServerOwnershipLock = Effect.fn("holdServerOwnershipLock")(function* (
212 statePath: string,
213) {
214 yield* Effect.acquireRelease(openOwnershipLock(NodePath.dirname(statePath)), ({ db }) =>
215 Effect.sync(() => db.close()),
216 );
217});
218​
219/** Hold an exclusive lock on the state directory until the scope closes. */
220export const acquireServerOwnership = Effect.fn("acquireServerOwnership")(function* (
221 statePath: string,
222) {
223 const stateDir = NodePath.dirname(statePath);
224 const random = yield* SecureRandom.SecureRandom;
225 const ownerId = yield* random
226 .uuidV4()
227 .pipe(Effect.mapError((cause) => new ServerOwnershipError({ stateDir, cause })));
228 const lockPath = ownershipLockPath(stateDir);
229 const lock = yield* Effect.acquireRelease(openOwnershipLock(stateDir), ({ db }) =>
230 readPersistedServerRuntimeState(statePath).pipe(
231 Effect.flatMap((state) =>
232 Option.isSome(state) && state.value.ownerId === ownerId
233 ? clearPersistedServerRuntimeState(statePath)
234 : Effect.void,
235 ),
236 Effect.ignore({ log: true }),
237 Effect.ensuring(Effect.sync(() => db.close())),
238 ),
239 );
240​
241 const previous = yield* readPersistedServerRuntimeState(statePath).pipe(
242 Effect.orElseSucceed(() => Option.none<PersistedServerRuntimeState>()),
243 );
244 if (
245 Option.isSome(previous) &&
246 previous.value.ownerId === undefined &&
247 (yield* legacyOwnerIsLive(previous.value))
248 ) {
249 return yield* new ServerAlreadyRunningError({ stateDir });
250 }
251 const olderServiceHome = yield* olderBootServiceHome(stateDir);
252 if (olderServiceHome !== undefined) {
253 return yield* new OlderBootServiceError({ baseDir: olderServiceHome });
254 }
255​
256 yield* Effect.gen(function* () {
257 let lost = lock.replacedDuringAcquire;
258 let statWarningLogged = false;
259 while (!lost) {
260 yield* Effect.sleep("5 seconds");
261 // Closing another descriptor for this inode releases SQLite's POSIX locks. Stat by path only.
262 const current = yield* Effect.sync(() => {
263 try {
264 return NodeFS.statSync(lockPath, { bigint: true, throwIfNoEntry: false });
265 } catch (cause) {
266 return cause instanceof Error && "code" in cause ? String(cause.code) : "unknown";
267 }
268 });
269 if (typeof current === "string") {
270 if (!statWarningLogged) {
271 yield* Effect.logWarning(
272 `Cannot check server ownership lock at ${lockPath} (${current}).`,
273 );
274 statWarningLogged = true;
275 }
276 continue;
277 }
278 statWarningLogged = false;
279 lost =
280 current === undefined ||
281 current.dev !== lock.identity.dev ||
282 current.ino !== lock.identity.ino;
283 }
284 yield* Effect.logError(
285 `Server ownership lock at ${lockPath} was deleted or replaced. Another server may now share this T3 home. Stop any other server using this home, then restart this server. Do not delete server-owner.sqlite while a server is running.`,
286 );
287 }).pipe(Effect.forkScoped);
288​
289 return {
290 publish: (state: PersistedServerRuntimeState) =>
291 persistServerRuntimeState({ path: statePath, state: { ...state, ownerId } }),
292 };
293});