1// @effect-diagnostics nodeBuiltinImport:off - Injects a real file replacement during the native SQLite transaction.
2import * as NodeFS from "node:fs";
3import * as NodeSqlite from "node:sqlite";
4
5import { assert, describe, it } from "@effect/vitest";
6import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import * as HostProcess from "@t3tools/shared/HostProcess";
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import * as Logger from "effect/Logger";
13import * as Option from "effect/Option";
14import * as Path from "effect/Path";
15import * as Runtime from "effect/Runtime";
16import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
17import * as TestClock from "effect/testing/TestClock";
18import { vi } from "vite-plus/test";
19
20import { renderBootServicePlist, renderBootServiceUnit } from "./cloud/bootService.ts";
21import { ServiceLauncherHostProcess } from "./cloud/serviceLauncherClient.ts";
22import { SERVICE_LAUNCHER_CONTEXT_ENV } from "./cloud/serviceProtocol.ts";
23import * as ProcessRunner from "./processRunner.ts";
24import * as ServerOwnership from "./serverOwnership.ts";
25import * as ServerRuntimeState from "./serverRuntimeState.ts";
26
27const startBesideBootService = Effect.fn("test.startBesideBootService")(function* (input: {
28 readonly platform: "darwin" | "linux";
29 readonly outputs: Readonly<Record<string, { readonly code: number; readonly stdout?: string }>>;
30 readonly unitServesOtherHome?: boolean;
31 readonly restartPending?: boolean;
32 readonly activeVersion?: string;
33 readonly npmRuntime?: boolean;
34 readonly missingServiceState?: boolean;
35 readonly environment?: NodeJS.ProcessEnv;
36 readonly connected?: boolean;
37}) {
38 const fs = yield* FileSystem.FileSystem;
39 const path = yield* Path.Path;
40 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-owner-boot-service-" });
41 const t3Home = path.join(root, ".t3");
42 const serviceHome = input.unitServesOtherHome ? path.join(root, "other") : t3Home;
43 for (const home of [t3Home, serviceHome]) {
44 yield* fs.makeDirectory(path.join(home, "userdata"), { recursive: true });
45 }
46 yield* fs.makeDirectory(path.join(serviceHome, "runtime"));
47 const activeVersion = input.activeVersion ?? "0.0.42";
48 if (!input.missingServiceState) {
49 yield* fs.writeFileString(
50 path.join(serviceHome, "runtime", "service-state.json"),
51 JSON.stringify({ protocol: input.npmRuntime ? 2 : 3, activeVersion }),
52 );
53 }
54 if (input.npmRuntime) {
55 const versionDir = path.join(serviceHome, "runtime", "versions", activeVersion);
56 const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");
57 yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });
58 yield* fs.writeFileString(entryPath, "");
59 }
60
61 if (input.restartPending) {
62 yield* fs.writeFileString(
63 path.join(serviceHome, "runtime", ".restart-pending"),
64 `${activeVersion}\n`,
65 );
66 }
67 const plan = {
68 program: input.npmRuntime
69 ? ["/usr/bin/node", path.join(serviceHome, "runtime", "service-launcher.mjs")]
70 : [],
71 baseDir: serviceHome,
72 logPath: "",
73 unitPath: "",
74 };
75 const [unitPath, unit] =
76 input.platform === "darwin"
77 ? [
78 path.join(root, "Library", "LaunchAgents", "com.t3tools.t3code.service.plist"),
79 renderBootServicePlist(plan, { homeDir: root, environmentPath: "" }),
80 ]
81 : [
82 path.join(root, ".config", "systemd", "user", "t3code.service"),
83 renderBootServiceUnit(plan),
84 ];
85 yield* fs.makeDirectory(path.dirname(unitPath), { recursive: true });
86 yield* fs.writeFileString(unitPath, unit);
87
88 const probes: string[] = [];
89 const runner = ProcessRunner.ProcessRunner.of({
90 run: (run) =>
91 Effect.gen(function* () {
92 const probe = `${path.basename(run.command)} ${run.args.find((arg) => !arg.startsWith("--"))}`;
93 probes.push(probe);
94 const output = input.outputs[probe];
95 return yield* output === undefined
96 ? Effect.fail(
97 new ProcessRunner.ProcessSpawnError({
98 command: run.command,
99 argumentCount: run.args.length,
100 cause: new Error("not found"),
101 }),
102 )
103 : Effect.succeed({
104 stdout: output.stdout ?? "",
105 stderr: "",
106 code: ChildProcessSpawner.ExitCode(output.code),
107 timedOut: false,
108 stdoutTruncated: false,
109 stderrTruncated: false,
110 stdoutInvalidUtf8: false,
111 stderrInvalidUtf8: false,
112 });
113 }),
114 });
115 const result = yield* Effect.scoped(
116 ServerOwnership.acquireServerOwnership(path.join(t3Home, "userdata", "server-runtime.json")),
117 ).pipe(
118 Effect.provideService(ProcessRunner.ProcessRunner, runner),
119 Effect.provideService(HostProcess.Platform, input.platform),
120 Effect.provideService(HostProcess.UserId, 501),
121 Effect.provideService(HostProcess.HomeDirectory, root),
122 Effect.provideService(HostProcess.Environment, input.environment ?? {}),
123 Effect.provideService(ServiceLauncherHostProcess, {
124 connected: input.connected ?? false,
125 send: () => false,
126 on: () => undefined,
127 off: () => undefined,
128 }),
129 Effect.result,
130 );
131 assert.includeMembers(
132 ["launchctl print", "launchctl print-disabled", "systemctl is-active", "systemctl is-enabled"],
133 probes,
134 );
135 return { result, t3Home };
136});
137
138describe("server ownership", () => {
139 // An empty home, so no boot service unit installed on this machine is read.
140 const layerOwnership = Layer.mergeAll(
141 ProcessRunner.layer,
142 Layer.effect(
143 HostProcess.HomeDirectory,
144 Effect.gen(function* () {
145 return yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped();
146 }),
147 ),
148 Layer.succeed(HostProcess.Environment, {}),
149 ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)));
150 const makeState = ServerRuntimeState.makePersistedServerRuntimeState({
151 config: { host: undefined, devUrl: undefined },
152 port: 3773,
153 });
154
155 it.effect("lets only one of two racing starts own a state directory", () =>
156 Effect.gen(function* () {
157 const fs = yield* FileSystem.FileSystem;
158 const path = yield* Path.Path;
159 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-owner-race-" });
160 const statePath = path.join(root, "server-runtime.json");
161 const attempts = yield* Effect.all(
162 [
163 ServerOwnership.acquireServerOwnership(statePath).pipe(Effect.result),
164 ServerOwnership.acquireServerOwnership(statePath).pipe(Effect.result),
165 ],
166 { concurrency: "unbounded" },
167 );
168 assert.equal(attempts.filter((result) => result._tag === "Success").length, 1);
169 const refused = attempts.find((result) => result._tag === "Failure");
170 assert.equal(refused?.failure._tag, "ServerAlreadyRunningError");
171 }).pipe(Effect.provide(layerOwnership)),
172 );
173
174 it.effect("refuses a second owner without touching its record, then allows a restart", () =>
175 Effect.gen(function* () {
176 const fs = yield* FileSystem.FileSystem;
177 const path = yield* Path.Path;
178 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-owner-" });
179 const statePath = path.join(root, "server-runtime.json");
180 const state = yield* makeState;
181 yield* Effect.scoped(
182 Effect.gen(function* () {
183 const owner = yield* ServerOwnership.acquireServerOwnership(statePath);
184 yield* owner.publish(state);
185 const before = yield* fs.readFileString(statePath);
186 const refused = yield* ServerOwnership.acquireServerOwnership(statePath).pipe(
187 Effect.flip,
188 );
189 assert.equal(refused._tag, "ServerAlreadyRunningError");
190 assert.include(refused.message, "do not delete it while a server is running");
191 assert.equal(yield* fs.readFileString(statePath), before);
192 }),
193 );
194 assert.isFalse(yield* fs.exists(statePath));
195 yield* Effect.scoped(ServerOwnership.acquireServerOwnership(statePath));
196 }).pipe(Effect.provide(layerOwnership)),
197 );
198
199 describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("lock file checks", () => {
200 it.effect.each([
201 "unchanged",
202 "unlink",
203 "replace",
204 "during acquisition",
205 "first acquisition",
206 // Root bypasses directory permissions, so chmod cannot force EACCES.
207 ...(process.getuid?.() === 0 ? [] : (["stat error"] as const)),
208 ] as const)("monitors ownership after %s", (operation) => {
209 const messages: unknown[] = [];
210 const warnings: unknown[] = [];
211 const logger = Logger.make(({ message, logLevel }) =>
212 (logLevel === "Warn" ? warnings : messages).push(message),
213 );
214 return Effect.gen(function* () {
215 const fs = yield* FileSystem.FileSystem;
216 const path = yield* Path.Path;
217 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-owner-lost-" });
218 const statePath = path.join(root, "server-runtime.json");
219 const lockPath = path.join(root, "server-owner.sqlite");
220 const replacement = path.join(root, "replacement.sqlite");
221 if (operation === "during acquisition" || operation === "first acquisition") {
222 if (operation === "during acquisition") yield* fs.writeFileString(lockPath, "");
223 yield* fs.writeFileString(replacement, "");
224 const exec = NodeSqlite.DatabaseSync.prototype.exec;
225 yield* Effect.acquireRelease(
226 Effect.sync(() =>
227 vi.spyOn(NodeSqlite.DatabaseSync.prototype, "exec").mockImplementationOnce(function (
228 this: NodeSqlite.DatabaseSync,
229 sql: string,
230 ) {
231 exec.call(this, sql);
232 NodeFS.renameSync(replacement, lockPath);
233 }),
234 ),
235 (spy) => Effect.sync(() => spy.mockRestore()),
236 );
237 }
238 yield* ServerOwnership.acquireServerOwnership(statePath);
239 if (operation === "unlink") {
240 yield* fs.remove(lockPath);
241 } else if (operation === "replace") {
242 yield* fs.writeFileString(replacement, "");
243 yield* fs.rename(replacement, lockPath);
244 }
245 if (operation === "stat error") {
246 yield* fs.chmod(root, 0);
247 yield* TestClock.adjust("15 seconds").pipe(
248 Effect.ensuring(fs.chmod(root, 0o700).pipe(Effect.orDie)),
249 );
250 assert.deepEqual(warnings, [
251 [`Cannot check server ownership lock at ${lockPath} (EACCES).`],
252 ]);
253 yield* TestClock.adjust("5 seconds");
254 assert.deepEqual(messages, []);
255 yield* fs.remove(lockPath);
256 }
257 yield* TestClock.adjust("5 seconds");
258 const expected =
259 operation === "unchanged"
260 ? []
261 : [
262 [
263 `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.`,
264 ],
265 ];
266 assert.deepEqual(messages, expected);
267 if (operation !== "unchanged") {
268 yield* fs.remove(`${lockPath}-journal`, { force: true });
269 yield* Effect.scoped(ServerOwnership.acquireServerOwnership(statePath));
270 }
271 yield* TestClock.adjust("15 seconds");
272 assert.deepEqual(messages, expected);
273 assert.lengthOf(warnings, operation === "stat error" ? 1 : 0);
274 }).pipe(
275 Effect.provide(
276 Layer.merge(layerOwnership, Logger.layer([logger], { mergeWithExisting: false })),
277 ),
278 );
279 });
280 });
281
282 it.effect("refuses beside a live pre-lock server and ignores a stale record", () =>
283 Effect.gen(function* () {
284 const fs = yield* FileSystem.FileSystem;
285 const path = yield* Path.Path;
286 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-owner-legacy-" });
287 const statePath = path.join(root, "server-runtime.json");
288 const state = yield* makeState;
289 yield* ServerRuntimeState.persistServerRuntimeState({
290 path: statePath,
291 // Any record time after this process started proves the PID was not reused.
292 state: { ...state, startedAt: "2100-01-01T00:00:00.000Z" },
293 });
294 const refused = yield* Effect.scoped(ServerOwnership.acquireServerOwnership(statePath)).pipe(
295 Effect.flip,
296 );
297 assert.equal(refused._tag, "ServerAlreadyRunningError");
298 yield* ServerRuntimeState.persistServerRuntimeState({
299 path: statePath,
300 state: { ...state, pid: 0 },
301 });
302 yield* Effect.scoped(ServerOwnership.acquireServerOwnership(statePath));
303 }).pipe(Effect.provide(layerOwnership)),
304 );
305
306 it.effect("never removes a runtime record another server published", () =>
307 Effect.gen(function* () {
308 const fs = yield* FileSystem.FileSystem;
309 const path = yield* Path.Path;
310 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-owner-cleanup-" });
311 const statePath = path.join(root, "server-runtime.json");
312 const state = yield* makeState;
313 yield* Effect.scoped(
314 Effect.gen(function* () {
315 const owner = yield* ServerOwnership.acquireServerOwnership(statePath);
316 yield* owner.publish(state);
317 yield* ServerRuntimeState.persistServerRuntimeState({
318 path: statePath,
319 state: { ...state, ownerId: "other-owner" },
320 });
321 }),
322 );
323 const remaining = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath);
324 assert.equal(Option.getOrThrow(remaining).ownerId, "other-owner");
325 }).pipe(Effect.provide(layerOwnership)),
326 );
327
328 describe("beside an installed boot service", () => {
329 it.effect("refuses an old active or scheduled service even without its runtime record", () =>
330 Effect.gen(function* () {
331 for (const fixture of [
332 {
333 platform: "darwin",
334 outputs: { "launchctl print": { code: 0 } },
335 },
336 {
337 platform: "darwin",
338 outputs: {
339 "launchctl print": { code: 113 },
340 "launchctl print-disabled": {
341 code: 0,
342 stdout: '"com.t3tools.t3code.service" => false',
343 },
344 },
345 },
346 {
347 platform: "linux",
348 outputs: {
349 // Restart= keeps an otherwise disabled service alive during its restart delay.
350 "systemctl is-active": { code: 3, stdout: "activating\n" },
351 "systemctl is-enabled": { code: 1, stdout: "disabled\n" },
352 },
353 },
354 {
355 platform: "linux",
356 outputs: {
357 "systemctl is-active": { code: 3, stdout: "inactive\n" },
358 "systemctl is-enabled": { code: 0, stdout: "enabled\n" },
359 },
360 },
361 {
362 platform: "darwin",
363 outputs: { "launchctl print": { code: 0 } },
364 environment: { [SERVICE_LAUNCHER_CONTEXT_ENV]: "{}" },
365 },
366 ] as const) {
367 const { result, t3Home } = yield* startBesideBootService(fixture);
368 assert.equal(result._tag, "Failure");
369 if (result._tag !== "Failure") return;
370 assert.equal(result.failure._tag, "OlderBootServiceError");
371 assert.equal(Runtime.getErrorExitCode(result.failure), 78);
372 assert.include(result.failure.message, t3Home);
373 assert.include(result.failure.message, "t3 update");
374 assert.include(result.failure.message, "t3 service restart");
375 assert.include(result.failure.message, "No server was stopped");
376 }
377 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
378 );
379
380 it.effect(
381 "refuses an active npm-installed service with no standalone executable or runtime record",
382 () =>
383 Effect.gen(function* () {
384 const { result } = yield* startBesideBootService({
385 platform: "linux",
386 activeVersion: "0.0.40",
387 npmRuntime: true,
388 outputs: {
389 "systemctl is-active": { code: 0, stdout: "active\n" },
390 },
391 });
392 assert.equal(result._tag, "Failure");
393 if (result._tag !== "Failure") return;
394 assert.equal(result.failure._tag, "OlderBootServiceError");
395 assert.equal(Runtime.getErrorExitCode(result.failure), 78);
396 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
397 );
398
399 it.effect("refuses while the loaded service has not restarted into its lock-aware update", () =>
400 Effect.gen(function* () {
401 const { result } = yield* startBesideBootService({
402 platform: "darwin",
403 restartPending: true,
404 activeVersion: "0.1.0",
405 outputs: {
406 "launchctl print": { code: 0 },
407 },
408 });
409 assert.equal(result._tag === "Failure" && result.failure._tag, "OlderBootServiceError");
410 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
411 );
412
413 it.effect("allows lock-aware stable services that are loaded or stopped and enabled", () =>
414 Effect.gen(function* () {
415 for (const fixture of [
416 {
417 platform: "darwin",
418 outputs: {
419 "launchctl print": { code: 0 },
420 },
421 },
422 {
423 platform: "linux",
424 outputs: {
425 "systemctl is-active": { code: 3, stdout: "inactive\n" },
426 "systemctl is-enabled": { code: 0, stdout: "enabled\n" },
427 },
428 },
429 ] as const) {
430 const { result } = yield* startBesideBootService({ ...fixture, activeVersion: "0.1.0" });
431 assert.equal(result._tag, "Success");
432 }
433 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
434 );
435
436 it.effect("allows services that cannot compete for this home", () =>
437 Effect.gen(function* () {
438 const outputs = {
439 "launchctl print": { code: 0 },
440 };
441 for (const fixture of [
442 { platform: "darwin", outputs, unitServesOtherHome: true },
443 {
444 platform: "darwin",
445 outputs,
446 environment: { [SERVICE_LAUNCHER_CONTEXT_ENV]: "{}" },
447 connected: true,
448 },
449 {
450 platform: "darwin",
451 outputs: {
452 "launchctl print": { code: 113 },
453 "launchctl print-disabled": {
454 code: 0,
455 stdout: '"com.t3tools.t3code.service" => disabled',
456 },
457 },
458 },
459 {
460 platform: "linux",
461 outputs: {
462 "systemctl is-active": { code: 3, stdout: "inactive\n" },
463 "systemctl is-enabled": { code: 1, stdout: "disabled\n" },
464 },
465 },
466 ] as const) {
467 const { result } = yield* startBesideBootService(fixture);
468 assert.equal(result._tag, "Success");
469 }
470 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
471 );
472
473 it.effect("allows startup when the service configuration or manager is unavailable", () =>
474 Effect.gen(function* () {
475 for (const fixture of [
476 { outputs: {} },
477 {
478 outputs: {
479 "launchctl print": { code: 113 },
480 "launchctl print-disabled": { code: 1 },
481 },
482 },
483 { outputs: { "launchctl print": { code: 0 } }, missingServiceState: true },
484 ]) {
485 const { result } = yield* startBesideBootService({ platform: "darwin", ...fixture });
486 assert.equal(result._tag, "Success");
487 }
488 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
489 );
490 });
491});