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 Crypto from "effect/Crypto";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Option from "effect/Option";
12import * as Runtime from "effect/Runtime";
13import * as Schema from "effect/Schema";
14
15import * as ProcessRunner from "./processRunner.ts";
16import {
17 clearPersistedServerRuntimeState,
18 isProcessAlive,
19 persistServerRuntimeState,
20 readPersistedServerRuntimeState,
21 type PersistedServerRuntimeState,
22} from "./serverRuntimeState.ts";
23
24export class ServerAlreadyRunningError extends Schema.TaggedError<ServerAlreadyRunningError>()(
25 "ServerAlreadyRunningError",
26 { stateDir: Schema.String },
27) {
28 // Distinct process exit code so a supervisor can tell "owned by another
29 // server" apart from a crash and stop restarting.
30 override readonly [Runtime.errorExitCode] = SERVER_EXIT_CODE_STATE_DIR_OWNED;
31
32 override get message(): string {
33 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.`;
34 }
35}
36
37export class ServerOwnershipError extends Schema.TaggedError<ServerOwnershipError>()(
38 "ServerOwnershipError",
39 { stateDir: Schema.String, cause: Schema.Defect() },
40) {
41 override get message(): string {
42 return `Could not acquire server ownership of ${this.stateDir}.`;
43 }
44}
45
46const isSqliteBusy = (cause: unknown) =>
47 cause instanceof Error &&
48 (("errcode" in cause && cause.errcode === 5) ||
49 ("code" in cause && cause.code === "SQLITE_BUSY"));
50
51/**
52 * Servers from before this lock only publish `server-runtime.json`. Treat
53 * their record as stale only when the process start time proves PID reuse.
54 */
55const legacyOwnerIsLive = Effect.fn("legacyOwnerIsLive")(function* (
56 state: PersistedServerRuntimeState,
57) {
58 if (!isProcessAlive(state.pid)) return false;
59 const recordedAt = Date.parse(state.startedAt);
60 if (!Number.isFinite(recordedAt)) return true;
61 const windows = (yield* HostProcess.Platform) === "win32";
62 const runner = yield* ProcessRunner.ProcessRunner;
63 const result = yield* runner
64 .run({
65 command: windows ? "powershell.exe" : "ps",
66 args: windows
67 ? [
68 "-NoProfile",
69 "-NonInteractive",
70 "-Command",
71 `(Get-Process -Id ${state.pid} -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')`,
72 ]
73 : ["-p", String(state.pid), "-o", "lstart="],
74 env: { LC_ALL: "C", TZ: "UTC" },
75 timeout: Duration.seconds(2),
76 maxOutputBytes: 16_384,
77 })
78 .pipe(Effect.option);
79 if (Option.isNone(result) || result.value.code !== 0) return true;
80 const output = result.value.stdout.trim();
81 const startedAt = Date.parse(windows ? output : `${output} UTC`);
82 // ps reports whole seconds. Unknown identity stays conservative.
83 return !Number.isFinite(startedAt) || startedAt <= recordedAt + 1_000;
84});
85
86/**
87 * Hold an exclusive lock on the state directory until the scope closes. The
88 * lock is an exclusive transaction on `server-owner.sqlite`, which never holds
89 * data and must never be unlinked: the OS releases it when the process exits,
90 * even on SIGKILL, so a crashed owner never blocks the next start.
91 */
92export const acquireServerOwnership = Effect.fn("acquireServerOwnership")(function* (
93 statePath: string,
94) {
95 const stateDir = NodePath.dirname(statePath);
96 const crypto = yield* Crypto.Crypto;
97 const ownerId = yield* crypto
98 .randomUUIDv4()
99 .pipe(Effect.mapError((cause) => new ServerOwnershipError({ stateDir, cause })));
100 yield* Effect.acquireRelease(
101 Effect.try({
102 try: () => {
103 NodeFS.mkdirSync(stateDir, { recursive: true });
104 const db = new NodeSqlite.DatabaseSync(NodePath.join(stateDir, "server-owner.sqlite"));
105 try {
106 db.exec("PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE;");
107 } catch (cause) {
108 db.close();
109 throw cause;
110 }
111 return db;
112 },
113 catch: (cause) =>
114 isSqliteBusy(cause)
115 ? new ServerAlreadyRunningError({ stateDir })
116 : new ServerOwnershipError({ stateDir, cause }),
117 }),
118 (db) =>
119 readPersistedServerRuntimeState(statePath).pipe(
120 // Only remove discovery this owner published.
121 Effect.flatMap((state) =>
122 Option.isSome(state) && state.value.ownerId === ownerId
123 ? clearPersistedServerRuntimeState(statePath)
124 : Effect.void,
125 ),
126 Effect.ignore({ log: true }),
127 Effect.ensuring(Effect.sync(() => db.close())),
128 ),
129 );
130
131 // An unreadable record cannot name a live owner; this server replaces it.
132 const previous = yield* readPersistedServerRuntimeState(statePath).pipe(
133 Effect.orElseSucceed(() => Option.none<PersistedServerRuntimeState>()),
134 );
135 if (
136 Option.isSome(previous) &&
137 previous.value.ownerId === undefined &&
138 (yield* legacyOwnerIsLive(previous.value))
139 ) {
140 return yield* new ServerAlreadyRunningError({ stateDir });
141 }
142
143 return {
144 publish: (state: PersistedServerRuntimeState) =>
145 persistServerRuntimeState({ path: statePath, state: { ...state, ownerId } }),
146 };
147});