| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { expect, it } from "@effect/vitest"; |
| 3 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 4 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 5 | import * as Duration from "effect/Duration"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Path from "effect/Path"; |
| 10 | import { HttpClient } from "effect/http"; |
| 11 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 12 | |
| 13 | import * as ProcessRunner from "../processRunner.ts"; |
| 14 | import * as BootService from "./bootService.ts"; |
| 15 | import { pinnedRuntimePaths } from "./pinnedRuntime.ts"; |
| 16 | import { |
| 17 | parseServiceState, |
| 18 | SERVICE_LAUNCHER_PROTOCOL, |
| 19 | SERVICE_RESTART_PENDING_FILE, |
| 20 | serviceStateHasPendingUpdate, |
| 21 | } from "./serviceProtocol.ts"; |
| 22 | |
| 23 | const linuxRuntime = "/home/theo/.t3/runtime/versions/1.2.3/t3"; |
| 24 | const linuxPlan = { |
| 25 | program: [linuxRuntime, "__service-launcher"], |
| 26 | baseDir: "/home/theo/.t3", |
| 27 | logPath: "/home/theo/.t3/userdata/logs/boot-service.log", |
| 28 | unitPath: "/home/theo/.config/systemd/user/t3code.service", |
| 29 | }; |
| 30 | |
| 31 | it("runs the pinned runtime's own executable as the systemd launcher", () => { |
| 32 | const unit = BootService.renderBootServiceUnit(linuxPlan); |
| 33 | |
| 34 | expect(unit).toContain(`ExecStart=${linuxRuntime} __service-launcher`); |
| 35 | expect(unit).toContain("KillMode=mixed"); |
| 36 | expect(unit).not.toContain("node"); |
| 37 | }); |
| 38 | |
| 39 | it("reads the served T3 home back out of a rendered unit or plist", () => { |
| 40 | const plan = (baseDir: string) => ({ |
| 41 | program: [`${baseDir}/runtime/versions/1.2.3/t3`, "__service-launcher"], |
| 42 | baseDir, |
| 43 | logPath: `${baseDir}/userdata/logs/boot-service.log`, |
| 44 | unitPath: "/home/theo/.config/systemd/user/t3code.service", |
| 45 | }); |
| 46 | |
| 47 | expect( |
| 48 | BootService.bootServiceBaseDirOf(BootService.renderBootServiceUnit(plan("/home/theo/.t3"))), |
| 49 | ).toBe("/home/theo/.t3"); |
| 50 | // Spaces and specifiers are quoted and escaped on the way in. |
| 51 | expect( |
| 52 | BootService.bootServiceBaseDirOf( |
| 53 | BootService.renderBootServiceUnit(plan("/home/theo/T3 Data/100%")), |
| 54 | ), |
| 55 | ).toBe("/home/theo/T3 Data/100%"); |
| 56 | expect( |
| 57 | BootService.bootServiceBaseDirOf( |
| 58 | BootService.renderBootServicePlist(plan("/Users/theo/a&b"), { |
| 59 | homeDir: "/Users/theo", |
| 60 | environmentPath: "/usr/bin", |
| 61 | }), |
| 62 | ), |
| 63 | ).toBe("/Users/theo/a&b"); |
| 64 | expect(BootService.bootServiceBaseDirOf("[Service]\nExecStart=/x\n")).toBeUndefined(); |
| 65 | }); |
| 66 | |
| 67 | it("survives the kernel OOM-killing a greedy agent child", () => { |
| 68 | const unit = BootService.renderBootServiceUnit(linuxPlan); |
| 69 | |
| 70 | expect(unit).toContain("OOMPolicy=continue"); |
| 71 | }); |
| 72 | |
| 73 | const macRuntime = "/Users/theo/.t3/runtime/versions/1.2.3/t3"; |
| 74 | const macPlan = { |
| 75 | program: [macRuntime, "__service-launcher"], |
| 76 | baseDir: "/Users/theo/.t3", |
| 77 | logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", |
| 78 | unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist", |
| 79 | }; |
| 80 | const macInstallerPath = |
| 81 | "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin"; |
| 82 | const macRenderOptions = { homeDir: "/Users/theo", environmentPath: macInstallerPath }; |
| 83 | |
| 84 | it("runs the pinned runtime's own executable as the launch agent", () => { |
| 85 | const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); |
| 86 | |
| 87 | expect(plist).toContain( |
| 88 | ` <array>\n <string>${macRuntime}</string>\n <string>__service-launcher</string>\n </array>`, |
| 89 | ); |
| 90 | expect(plist).not.toContain("node</string>"); |
| 91 | }); |
| 92 | |
| 93 | it("preserves the installer's provider search path in the launch agent", () => { |
| 94 | const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); |
| 95 | |
| 96 | expect(plist).toContain(` <key>PATH</key>\n <string>${macInstallerPath}</string>`); |
| 97 | }); |
| 98 | |
| 99 | it("restarts the launch agent on the systemd cadence", () => { |
| 100 | const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); |
| 101 | |
| 102 | expect(plist).toContain("<key>RunAtLoad</key>\n <true/>"); |
| 103 | expect(plist).toContain("<key>KeepAlive</key>\n <true/>"); |
| 104 | expect(plist).toContain("<key>ThrottleInterval</key>\n <integer>5</integer>"); |
| 105 | expect(plist).toContain("<key>ExitTimeOut</key>\n <integer>90</integer>"); |
| 106 | }); |
| 107 | |
| 108 | it("appends both stdio streams to the boot service log", () => { |
| 109 | const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); |
| 110 | |
| 111 | expect(plist).toContain( |
| 112 | "<key>StandardOutPath</key>\n <string>/Users/theo/.t3/userdata/logs/boot-service.log</string>", |
| 113 | ); |
| 114 | expect(plist).toContain( |
| 115 | "<key>StandardErrorPath</key>\n <string>/Users/theo/.t3/userdata/logs/boot-service.log</string>", |
| 116 | ); |
| 117 | }); |
| 118 | |
| 119 | it("escapes XML in host paths", () => { |
| 120 | const plist = BootService.renderBootServicePlist( |
| 121 | { ...macPlan, baseDir: "/Users/theo/T3 & <Co>" }, |
| 122 | { homeDir: "/Users/theo", environmentPath: "/Users/theo/Tools & <Scripts>:/usr/bin" }, |
| 123 | ); |
| 124 | |
| 125 | expect(plist).toContain("<string>/Users/theo/T3 &amp; &lt;Co&gt;</string>"); |
| 126 | expect(plist).toContain("<string>/Users/theo/Tools &amp; &lt;Scripts&gt;:/usr/bin</string>"); |
| 127 | }); |
| 128 | |
| 129 | const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 130 | platform: NodeJS.Platform = "linux", |
| 131 | installerPath = macInstallerPath, |
| 132 | ) { |
| 133 | const fs = yield* FileSystem.FileSystem; |
| 134 | const path = yield* Path.Path; |
| 135 | const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" }); |
| 136 | const baseDir = path.join(home, ".t3"); |
| 137 | const statePath = path.join(baseDir, "runtime", "service-state.json"); |
| 138 | // A complete pinned runtime is already present, so install only validates |
| 139 | // it and never downloads a release archive. |
| 140 | const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", platform); |
| 141 | yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true }); |
| 142 | yield* fs.writeFileString(runtime.entryPath, "#!/bin/sh\n"); |
| 143 | yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n"); |
| 144 | |
| 145 | const commands: string[] = []; |
| 146 | const timeouts = new Map<string, unknown>(); |
| 147 | const control: { |
| 148 | failCommand: string | undefined; |
| 149 | stateAfterStop?: string; |
| 150 | linger: string; |
| 151 | enabled: boolean; |
| 152 | active: boolean; |
| 153 | } = { |
| 154 | failCommand: undefined, |
| 155 | linger: "yes", |
| 156 | enabled: true, |
| 157 | active: true, |
| 158 | }; |
| 159 | const runner = ProcessRunner.ProcessRunner.of({ |
| 160 | run: Effect.fn("test.run_boot_service_command")(function* ( |
| 161 | input: ProcessRunner.ProcessRunInput, |
| 162 | ) { |
| 163 | const command = `${input.command} ${input.args.join(" ")}`; |
| 164 | commands.push(command); |
| 165 | timeouts.set(command, input.timeout); |
| 166 | const failed = command === control.failCommand; |
| 167 | if (!failed && command === "loginctl enable-linger --no-ask-password 501") |
| 168 | control.linger = "yes"; |
| 169 | if (!failed && command === "systemctl --user enable t3code.service") control.enabled = true; |
| 170 | if (!failed && command === "systemctl --user restart t3code.service") control.active = true; |
| 171 | if ( |
| 172 | control.stateAfterStop !== undefined && |
| 173 | (command === "systemctl --user stop t3code.service" || |
| 174 | command.startsWith("launchctl bootout --wait ")) |
| 175 | ) { |
| 176 | yield* fs.writeFileString(statePath, control.stateAfterStop).pipe(Effect.orDie); |
| 177 | } |
| 178 | return { |
| 179 | stdout: |
| 180 | input.args[0] === "--version" |
| 181 | ? // The runtime under test reports the version of the directory it |
| 182 | // was launched from, like the real executable. |
| 183 | `t3 v${/versions\/([^/]+)\//.exec(input.command)?.[1] ?? "1.2.3"}\n` |
| 184 | : input.command === "loginctl" && input.args[0] === "show-user" |
| 185 | ? `${control.linger}\n` |
| 186 | : input.args[1] === "is-enabled" |
| 187 | ? control.enabled |
| 188 | ? "enabled\n" |
| 189 | : "disabled\n" |
| 190 | : "", |
| 191 | stderr: "", |
| 192 | code: ChildProcessSpawner.ExitCode( |
| 193 | failed || (input.args[1] === "is-active" && !control.active) ? 1 : 0, |
| 194 | ), |
| 195 | timedOut: false, |
| 196 | stdoutTruncated: false, |
| 197 | stderrTruncated: false, |
| 198 | stdoutInvalidUtf8: false, |
| 199 | stderrInvalidUtf8: false, |
| 200 | }; |
| 201 | }), |
| 202 | }); |
| 203 | const makeService = ( |
| 204 | environmentPath: string | undefined = installerPath, |
| 205 | cliVersion = "1.2.3", |
| 206 | serviceBaseDir = baseDir, |
| 207 | ) => |
| 208 | Effect.gen(function* () { |
| 209 | // Every version the tests install is present and verified on disk, so |
| 210 | // install never downloads. |
| 211 | const paths = pinnedRuntimePaths(path, serviceBaseDir, cliVersion, platform); |
| 212 | yield* fs.makeDirectory(path.dirname(paths.entryPath), { recursive: true }); |
| 213 | yield* fs.writeFileString(paths.entryPath, "#!/bin/sh\n"); |
| 214 | yield* fs.writeFileString(paths.sentinelPath, `${cliVersion}\n`); |
| 215 | return yield* BootService.make({ |
| 216 | baseDir: serviceBaseDir, |
| 217 | logsDir: path.join(serviceBaseDir, "userdata", "logs"), |
| 218 | cliVersion, |
| 219 | host: { execPath: "/usr/bin/t3" }, |
| 220 | }); |
| 221 | }).pipe( |
| 222 | Effect.provideService(ProcessRunner.ProcessRunner, runner), |
| 223 | Effect.provide( |
| 224 | Layer.mergeAll( |
| 225 | Layer.succeed(HostProcess.Platform, platform), |
| 226 | Layer.succeed(HostProcess.UserId, 501), |
| 227 | Layer.succeed(HostProcess.ExecutablePath, "/usr/bin/t3"), |
| 228 | Layer.succeed( |
| 229 | HttpClient.HttpClient, |
| 230 | HttpClient.make(() => Effect.die("no release download expected")), |
| 231 | ), |
| 232 | ConfigProvider.layer( |
| 233 | ConfigProvider.fromEnv({ |
| 234 | env: { |
| 235 | HOME: home, |
| 236 | ...(environmentPath === undefined || environmentPath === "" |
| 237 | ? {} |
| 238 | : { PATH: environmentPath }), |
| 239 | }, |
| 240 | }), |
| 241 | ), |
| 242 | ), |
| 243 | ), |
| 244 | ); |
| 245 | const service = yield* makeService(); |
| 246 | return { service, makeService, fs, statePath, commands, timeouts, control, runtime }; |
| 247 | }); |
| 248 | |
| 249 | it.layer(NodeServices.layer)("boot service install", (it) => { |
| 250 | it.effect( |
| 251 | "fails before installing files or validating a runtime when lingering needs an administrator", |
| 252 | () => |
| 253 | Effect.gen(function* () { |
| 254 | const { service, fs, statePath, commands, control, runtime } = yield* makeHarness(); |
| 255 | const before = yield* service.status; |
| 256 | control.linger = "no"; |
| 257 | control.failCommand = "loginctl enable-linger --no-ask-password 501"; |
| 258 | yield* fs.remove(runtime.sentinelPath); |
| 259 | |
| 260 | const error = yield* service.install().pipe(Effect.flip); |
| 261 | |
| 262 | expect(error).toMatchObject({ |
| 263 | _tag: "BootServicePrerequisiteError", |
| 264 | problem: "linger-disabled", |
| 265 | }); |
| 266 | expect(error.message).toContain('sudo loginctl enable-linger "$(id -un)"'); |
| 267 | expect(error.message).toContain("last login session ends"); |
| 268 | expect(yield* fs.exists(before.unitPath)).toBe(false); |
| 269 | expect(yield* fs.exists(statePath)).toBe(false); |
| 270 | expect(commands.some((command) => command.includes("--version"))).toBe(false); |
| 271 | expect( |
| 272 | commands.some( |
| 273 | (command) => command.includes("daemon-reload") || command.includes("restart"), |
| 274 | ), |
| 275 | ).toBe(false); |
| 276 | expect(yield* fs.readFileString(before.logPath)).toContain("[linger-disabled]"); |
| 277 | }), |
| 278 | ); |
| 279 | |
| 280 | it.effect( |
| 281 | "detects a partial install and preserves the running service when repair lacks permission", |
| 282 | () => |
| 283 | Effect.gen(function* () { |
| 284 | const { service, fs, statePath, commands, control } = yield* makeHarness(); |
| 285 | const plan = yield* service.install(); |
| 286 | const before = yield* fs.readFileString(statePath); |
| 287 | const unit = yield* fs.readFileString(plan.unitPath); |
| 288 | control.linger = "no"; |
| 289 | control.failCommand = "loginctl enable-linger --no-ask-password 501"; |
| 290 | |
| 291 | expect(yield* service.status).toMatchObject({ |
| 292 | current: false, |
| 293 | problems: ["linger-disabled"], |
| 294 | }); |
| 295 | commands.length = 0; |
| 296 | expect((yield* service.install().pipe(Effect.flip))._tag).toBe( |
| 297 | "BootServicePrerequisiteError", |
| 298 | ); |
| 299 | expect(yield* fs.readFileString(statePath)).toBe(before); |
| 300 | expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); |
| 301 | expect(commands).not.toContain("systemctl --user stop t3code.service"); |
| 302 | }), |
| 303 | ); |
| 304 | |
| 305 | it.effect("enables lingering before installing and repairs stopped or disabled services", () => |
| 306 | Effect.gen(function* () { |
| 307 | const { service, commands, control } = yield* makeHarness(); |
| 308 | control.linger = "no"; |
| 309 | yield* service.install(); |
| 310 | expect(control.linger).toBe("yes"); |
| 311 | expect(commands.indexOf("loginctl enable-linger --no-ask-password 501")).toBeLessThan( |
| 312 | commands.indexOf("systemctl --user daemon-reload"), |
| 313 | ); |
| 314 | |
| 315 | control.enabled = false; |
| 316 | control.active = false; |
| 317 | expect(yield* service.status).toMatchObject({ |
| 318 | current: false, |
| 319 | problems: ["service-disabled", "service-stopped"], |
| 320 | }); |
| 321 | yield* service.install(); |
| 322 | expect((yield* service.status).current).toBe(true); |
| 323 | }), |
| 324 | ); |
| 325 | |
| 326 | it.effect.each([ |
| 327 | { command: "systemctl --user show-environment", problem: "user-manager-unavailable" }, |
| 328 | { command: "loginctl show-user 501 --property=Linger --value", problem: "linger-unavailable" }, |
| 329 | ])("reports failed prerequisite probes without installing: $command", ({ command, problem }) => |
| 330 | Effect.gen(function* () { |
| 331 | const { service, fs, statePath, control } = yield* makeHarness(); |
| 332 | control.failCommand = command; |
| 333 | expect(yield* service.install().pipe(Effect.flip)).toMatchObject({ |
| 334 | _tag: "BootServicePrerequisiteError", |
| 335 | problem, |
| 336 | }); |
| 337 | expect(yield* fs.exists(statePath)).toBe(false); |
| 338 | }), |
| 339 | ); |
| 340 | |
| 341 | it.effect("installs, reports current state, and uninstalls", () => |
| 342 | Effect.gen(function* () { |
| 343 | const { service, fs, statePath, timeouts, runtime } = yield* makeHarness(); |
| 344 | const plan = yield* service.install(); |
| 345 | |
| 346 | expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ |
| 347 | protocol: SERVICE_LAUNCHER_PROTOCOL, |
| 348 | activeVersion: "1.2.3", |
| 349 | }); |
| 350 | expect(plan.program).toEqual([runtime.entryPath, "__service-launcher"]); |
| 351 | expect(yield* fs.readFileString(plan.unitPath)).toContain( |
| 352 | `ExecStart=${runtime.entryPath} __service-launcher`, |
| 353 | ); |
| 354 | expect(yield* service.status).toMatchObject({ |
| 355 | current: true, |
| 356 | installedVersion: "1.2.3", |
| 357 | }); |
| 358 | const pendingState = JSON.stringify({ |
| 359 | protocol: SERVICE_LAUNCHER_PROTOCOL, |
| 360 | activeVersion: "1.2.3", |
| 361 | update: { |
| 362 | id: "u", |
| 363 | fromVersion: "1.2.3", |
| 364 | targetVersion: "1.2.4", |
| 365 | dbPath: "/tmp/state.sqlite", |
| 366 | status: "pending", |
| 367 | }, |
| 368 | }); |
| 369 | yield* fs.writeFileString(statePath, pendingState); |
| 370 | expect((yield* service.status).current).toBe(false); |
| 371 | expect(yield* service.uninstall).toBe(true); |
| 372 | expect((yield* service.status).installed).toBe(false); |
| 373 | // The stop can block up to systemd's 90s TimeoutStopSec; the runner's |
| 374 | // 60s default would cancel it mid-shutdown. |
| 375 | expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual( |
| 376 | Duration.seconds(120), |
| 377 | ); |
| 378 | }), |
| 379 | ); |
| 380 | |
| 381 | it.effect.each(["linux", "darwin"] as const)( |
| 382 | "reports the installed version across launcher protocols on %s", |
| 383 | (platform) => |
| 384 | Effect.gen(function* () { |
| 385 | const { service, fs, statePath } = yield* makeHarness(platform); |
| 386 | yield* service.install(); |
| 387 | |
| 388 | for (const protocol of [SERVICE_LAUNCHER_PROTOCOL - 1, SERVICE_LAUNCHER_PROTOCOL + 1]) { |
| 389 | yield* fs.writeFileString( |
| 390 | statePath, |
| 391 | `{"protocol":${protocol},"activeVersion":"1.2.4-nightly.1","update":{"status":"unknown"}}`, |
| 392 | ); |
| 393 | expect(yield* service.status).toMatchObject({ |
| 394 | current: false, |
| 395 | installedVersion: "1.2.4-nightly.1", |
| 396 | }); |
| 397 | } |
| 398 | }), |
| 399 | ); |
| 400 | |
| 401 | it.effect("reports an unknown version for invalid service state", () => |
| 402 | Effect.gen(function* () { |
| 403 | const { service, fs, statePath } = yield* makeHarness(); |
| 404 | yield* service.install(); |
| 405 | |
| 406 | for (const stateText of [ |
| 407 | "{", |
| 408 | '{"activeVersion":"latest"}', |
| 409 | '{"activeVersion":"1.2"}', |
| 410 | '{"activeVersion":123}', |
| 411 | ]) { |
| 412 | yield* fs.writeFileString(statePath, stateText); |
| 413 | const status = yield* service.status; |
| 414 | expect(status.current).toBe(false); |
| 415 | expect(status.installedVersion).toBeUndefined(); |
| 416 | } |
| 417 | }), |
| 418 | ); |
| 419 | |
| 420 | it.effect.each(["linux", "darwin"] as const)( |
| 421 | "preserves a newer version that finishes updating during stop on %s", |
| 422 | (platform) => |
| 423 | Effect.gen(function* () { |
| 424 | const { service, fs, statePath, commands, control } = yield* makeHarness(platform); |
| 425 | const plan = yield* service.install(); |
| 426 | const unit = yield* fs.readFileString(plan.unitPath); |
| 427 | control.stateAfterStop = `{"protocol":${SERVICE_LAUNCHER_PROTOCOL + 1},"activeVersion":"1.2.4"}`; |
| 428 | commands.length = 0; |
| 429 | |
| 430 | const error = yield* service.install().pipe(Effect.flip); |
| 431 | |
| 432 | expect(error).toMatchObject({ |
| 433 | _tag: "BootServiceDowngradeRefusedError", |
| 434 | installedVersion: "1.2.4", |
| 435 | targetVersion: "1.2.3", |
| 436 | }); |
| 437 | expect(yield* fs.readFileString(statePath)).toBe(control.stateAfterStop); |
| 438 | expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); |
| 439 | expect( |
| 440 | commands.filter( |
| 441 | (command) => |
| 442 | command.startsWith(platform === "linux" ? "systemctl " : "launchctl ") && |
| 443 | !command.includes("show-environment"), |
| 444 | ), |
| 445 | ).toEqual( |
| 446 | platform === "linux" |
| 447 | ? ["systemctl --user stop t3code.service", "systemctl --user restart t3code.service"] |
| 448 | : [ |
| 449 | "launchctl bootout --wait gui/501/com.t3tools.t3code.service", |
| 450 | `launchctl bootstrap gui/501 ${plan.unitPath}`, |
| 451 | ], |
| 452 | ); |
| 453 | }), |
| 454 | ); |
| 455 | |
| 456 | it.effect("allows an explicit downgrade", () => |
| 457 | Effect.gen(function* () { |
| 458 | const { service, fs, statePath } = yield* makeHarness(); |
| 459 | yield* service.install(); |
| 460 | yield* fs.writeFileString( |
| 461 | statePath, |
| 462 | `{"protocol":${SERVICE_LAUNCHER_PROTOCOL},"activeVersion":"1.2.4"}`, |
| 463 | ); |
| 464 | |
| 465 | yield* service.install({ allowDowngrade: true }); |
| 466 | |
| 467 | expect(parseServiceState(yield* fs.readFileString(statePath))?.activeVersion).toBe("1.2.3"); |
| 468 | expect((yield* service.status).current).toBe(true); |
| 469 | }), |
| 470 | ); |
| 471 | |
| 472 | it.effect("repairs versions with equal SemVer precedence without an override", () => |
| 473 | Effect.gen(function* () { |
| 474 | const { service, fs, statePath } = yield* makeHarness(); |
| 475 | yield* service.install(); |
| 476 | yield* fs.writeFileString( |
| 477 | statePath, |
| 478 | `{"protocol":${SERVICE_LAUNCHER_PROTOCOL},"activeVersion":"1.2.3+previous-build"}`, |
| 479 | ); |
| 480 | |
| 481 | yield* service.install(); |
| 482 | |
| 483 | expect((yield* service.status).current).toBe(true); |
| 484 | }), |
| 485 | ); |
| 486 | |
| 487 | it.effect("install with start=false rewrites the files and marks a restart pending", () => |
| 488 | Effect.gen(function* () { |
| 489 | const { service, fs, statePath, commands, makeService } = yield* makeHarness(); |
| 490 | yield* service.install(); |
| 491 | commands.length = 0; |
| 492 | |
| 493 | const newer = yield* makeService(undefined, "1.2.4"); |
| 494 | const plan = yield* newer.install({ start: false }); |
| 495 | |
| 496 | expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ |
| 497 | protocol: SERVICE_LAUNCHER_PROTOCOL, |
| 498 | activeVersion: "1.2.4", |
| 499 | }); |
| 500 | expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.4/t3"); |
| 501 | expect( |
| 502 | commands.filter( |
| 503 | (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), |
| 504 | ), |
| 505 | ).toEqual([]); |
| 506 | // The files say 1.2.4 but the process is still 1.2.3: not current, and |
| 507 | // the reason is named so `t3 service status` can point at restart. |
| 508 | const status = yield* newer.status; |
| 509 | expect(status.current).toBe(false); |
| 510 | expect(status.problems).toContain("restart-pending"); |
| 511 | |
| 512 | commands.length = 0; |
| 513 | expect(yield* newer.restart).toBe(true); |
| 514 | expect((yield* newer.status).problems).not.toContain("restart-pending"); |
| 515 | expect((yield* newer.status).current).toBe(true); |
| 516 | }), |
| 517 | ); |
| 518 | |
| 519 | it.effect("install with start=false keeps the marker when a later write fails", () => |
| 520 | Effect.gen(function* () { |
| 521 | const { service, fs, statePath, makeService } = yield* makeHarness(); |
| 522 | const path = yield* Path.Path; |
| 523 | yield* service.install(); |
| 524 | const newer = yield* makeService(undefined, "1.2.4"); |
| 525 | // A non-empty directory in the unit's place: it still counts as an |
| 526 | // installed unit, and the rename that writes the new unit fails. |
| 527 | const unitPath = (yield* service.status).unitPath; |
| 528 | yield* fs.remove(unitPath); |
| 529 | yield* fs.makeDirectory(unitPath); |
| 530 | yield* fs.writeFileString(path.join(unitPath, "occupied"), ""); |
| 531 | |
| 532 | const error = yield* newer.install({ start: false }).pipe(Effect.flip); |
| 533 | expect(error._tag).toBe("BootServiceInstallError"); |
| 534 | expect( |
| 535 | yield* fs.exists(path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE)), |
| 536 | ).toBe(true); |
| 537 | }), |
| 538 | ); |
| 539 | |
| 540 | it.effect("install with start=false refuses while a remote update is pending", () => |
| 541 | Effect.gen(function* () { |
| 542 | const { service, fs, statePath } = yield* makeHarness(); |
| 543 | yield* service.install(); |
| 544 | const pendingState = JSON.stringify({ |
| 545 | protocol: SERVICE_LAUNCHER_PROTOCOL, |
| 546 | activeVersion: "1.2.3", |
| 547 | update: { |
| 548 | id: "u", |
| 549 | fromVersion: "1.2.3", |
| 550 | targetVersion: "1.2.4", |
| 551 | dbPath: "/tmp/state.sqlite", |
| 552 | status: "pending", |
| 553 | }, |
| 554 | }); |
| 555 | yield* fs.writeFileString(statePath, pendingState); |
| 556 | |
| 557 | const error = yield* service.install({ start: false }).pipe(Effect.flip); |
| 558 | expect(error._tag).toBe("BootServiceUpdatePendingError"); |
| 559 | expect(yield* fs.readFileString(statePath)).toBe(pendingState); |
| 560 | }), |
| 561 | ); |
| 562 | |
| 563 | it.effect("restart stops and starts an installed service, and is a no-op otherwise", () => |
| 564 | Effect.gen(function* () { |
| 565 | const { service, commands } = yield* makeHarness(); |
| 566 | expect(yield* service.restart).toBe(false); |
| 567 | yield* service.install(); |
| 568 | commands.length = 0; |
| 569 | |
| 570 | expect(yield* service.restart).toBe(true); |
| 571 | expect( |
| 572 | commands.filter( |
| 573 | (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), |
| 574 | ), |
| 575 | ).toEqual([ |
| 576 | "systemctl --user stop t3code.service", |
| 577 | "systemctl --user daemon-reload", |
| 578 | "systemctl --user enable t3code.service", |
| 579 | "systemctl --user restart t3code.service", |
| 580 | ]); |
| 581 | }), |
| 582 | ); |
| 583 | |
| 584 | it.effect("restart leaves a service that serves another T3 home alone", () => |
| 585 | Effect.gen(function* () { |
| 586 | const { service, fs, commands, makeService } = yield* makeHarness(); |
| 587 | yield* service.install(); |
| 588 | commands.length = 0; |
| 589 | const path = yield* Path.Path; |
| 590 | const otherHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-other-home-" }); |
| 591 | |
| 592 | const other = yield* makeService(undefined, "1.2.3", path.join(otherHome, ".t3")); |
| 593 | expect(yield* other.restart).toBe(false); |
| 594 | expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([]); |
| 595 | }), |
| 596 | ); |
| 597 | |
| 598 | it.effect("restart brings the service back when activation fails", () => |
| 599 | Effect.gen(function* () { |
| 600 | const { service, commands, control } = yield* makeHarness(); |
| 601 | yield* service.install(); |
| 602 | commands.length = 0; |
| 603 | control.failCommand = "systemctl --user daemon-reload"; |
| 604 | |
| 605 | const error = yield* service.restart.pipe(Effect.flip); |
| 606 | expect(error._tag).toBe("BootServiceCommandError"); |
| 607 | expect( |
| 608 | commands.filter( |
| 609 | (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), |
| 610 | ), |
| 611 | ).toEqual([ |
| 612 | "systemctl --user stop t3code.service", |
| 613 | "systemctl --user daemon-reload", |
| 614 | "systemctl --user restart t3code.service", |
| 615 | ]); |
| 616 | }), |
| 617 | ); |
| 618 | |
| 619 | it.effect("restarts an installed service when repair fails", () => |
| 620 | Effect.gen(function* () { |
| 621 | const { service, commands, control } = yield* makeHarness(); |
| 622 | yield* service.install(); |
| 623 | commands.length = 0; |
| 624 | control.failCommand = "systemctl --user daemon-reload"; |
| 625 | |
| 626 | const error = yield* service.install().pipe(Effect.flip); |
| 627 | expect(error._tag).toBe("BootServiceCommandError"); |
| 628 | expect( |
| 629 | commands.filter( |
| 630 | (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), |
| 631 | ), |
| 632 | ).toEqual([ |
| 633 | "systemctl --user stop t3code.service", |
| 634 | "systemctl --user daemon-reload", |
| 635 | "systemctl --user restart t3code.service", |
| 636 | ]); |
| 637 | }), |
| 638 | ); |
| 639 | |
| 640 | it.effect("restarts without overwriting a pending remote update", () => |
| 641 | Effect.gen(function* () { |
| 642 | const { service, fs, statePath, commands } = yield* makeHarness(); |
| 643 | yield* service.install(); |
| 644 | const pendingState = JSON.stringify({ |
| 645 | protocol: SERVICE_LAUNCHER_PROTOCOL - 1, |
| 646 | activeVersion: "1.2.3", |
| 647 | update: { |
| 648 | id: "remote-update", |
| 649 | fromVersion: "1.2.3", |
| 650 | targetVersion: "1.2.4", |
| 651 | status: "pending", |
| 652 | }, |
| 653 | }); |
| 654 | yield* fs.writeFileString(statePath, pendingState); |
| 655 | for (const allowDowngrade of [false, true]) { |
| 656 | commands.length = 0; |
| 657 | |
| 658 | expect((yield* service.install({ allowDowngrade }).pipe(Effect.flip))._tag).toBe( |
| 659 | "BootServiceUpdatePendingError", |
| 660 | ); |
| 661 | expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); |
| 662 | expect( |
| 663 | commands.filter( |
| 664 | (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), |
| 665 | ), |
| 666 | ).toEqual([ |
| 667 | "systemctl --user stop t3code.service", |
| 668 | "systemctl --user restart t3code.service", |
| 669 | ]); |
| 670 | } |
| 671 | }), |
| 672 | ); |
| 673 | |
| 674 | it.effect("fails closed on Windows", () => |
| 675 | Effect.gen(function* () { |
| 676 | const { service } = yield* makeHarness("win32"); |
| 677 | expect((yield* service.status).supported).toBe(false); |
| 678 | expect((yield* service.install().pipe(Effect.flip))._tag).toBe("BootServiceUnsupportedError"); |
| 679 | }), |
| 680 | ); |
| 681 | |
| 682 | it.effect("installs, reports current state, and uninstalls on macOS", () => |
| 683 | Effect.gen(function* () { |
| 684 | const { service, fs, statePath, commands, timeouts, runtime } = yield* makeHarness("darwin"); |
| 685 | const path = yield* Path.Path; |
| 686 | const plan = yield* service.install(); |
| 687 | |
| 688 | expect( |
| 689 | plan.unitPath.endsWith( |
| 690 | path.join("Library", "LaunchAgents", "com.t3tools.t3code.service.plist"), |
| 691 | ), |
| 692 | ).toBe(true); |
| 693 | expect(yield* fs.readFileString(plan.unitPath)).toContain( |
| 694 | ` <key>PATH</key>\n <string>${macInstallerPath}:/usr/local/bin:/usr/sbin:/sbin</string>`, |
| 695 | ); |
| 696 | expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ |
| 697 | protocol: SERVICE_LAUNCHER_PROTOCOL, |
| 698 | activeVersion: "1.2.3", |
| 699 | }); |
| 700 | expect(yield* fs.readFileString(plan.unitPath)).toContain( |
| 701 | ` <string>${runtime.entryPath}</string>\n <string>__service-launcher</string>`, |
| 702 | ); |
| 703 | expect(yield* service.status).toMatchObject({ |
| 704 | current: true, |
| 705 | installedVersion: "1.2.3", |
| 706 | }); |
| 707 | expect(yield* service.uninstall).toBe(true); |
| 708 | expect((yield* service.status).installed).toBe(false); |
| 709 | expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false); |
| 710 | // A bootout can block up to the plist's 90s ExitTimeOut; the runner's |
| 711 | // 60s default would cancel it and let bootstrap race a loaded job. |
| 712 | expect(timeouts.get("launchctl bootout --wait gui/501/com.t3tools.t3code.service")).toEqual( |
| 713 | Duration.seconds(120), |
| 714 | ); |
| 715 | }), |
| 716 | ); |
| 717 | |
| 718 | it.effect("restarts the launch agent when repair fails", () => |
| 719 | Effect.gen(function* () { |
| 720 | const { service, commands, control } = yield* makeHarness("darwin"); |
| 721 | yield* service.install(); |
| 722 | const plistPath = (yield* service.status).unitPath; |
| 723 | commands.length = 0; |
| 724 | control.failCommand = `launchctl bootstrap gui/501 ${plistPath}`; |
| 725 | |
| 726 | const error = yield* service.install().pipe(Effect.flip); |
| 727 | expect(error._tag).toBe("BootServiceCommandError"); |
| 728 | expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ |
| 729 | "launchctl bootout --wait gui/501/com.t3tools.t3code.service", |
| 730 | "launchctl enable gui/501/com.t3tools.t3code.service", |
| 731 | `launchctl bootstrap gui/501 ${plistPath}`, |
| 732 | `launchctl bootstrap gui/501 ${plistPath}`, |
| 733 | ]); |
| 734 | }), |
| 735 | ); |
| 736 | |
| 737 | it.effect("reconstructs a launch agent search path when the installer has no PATH", () => |
| 738 | Effect.gen(function* () { |
| 739 | const { service, fs } = yield* makeHarness("darwin", ""); |
| 740 | const plan = yield* service.install(); |
| 741 | |
| 742 | expect(yield* fs.readFileString(plan.unitPath)).toContain( |
| 743 | " <key>PATH</key>\n <string>/usr/bin:/opt/homebrew/bin:/usr/local/bin:/bin:/usr/sbin:/sbin</string>", |
| 744 | ); |
| 745 | expect((yield* service.status).current).toBe(true); |
| 746 | }), |
| 747 | ); |
| 748 | |
| 749 | it.effect("adds missing provider directories to a minimal installer PATH", () => |
| 750 | Effect.gen(function* () { |
| 751 | const { service, fs } = yield* makeHarness("darwin", "/usr/bin:/bin"); |
| 752 | const plan = yield* service.install(); |
| 753 | |
| 754 | expect(yield* fs.readFileString(plan.unitPath)).toContain( |
| 755 | " <key>PATH</key>\n <string>/usr/bin:/bin:/opt/homebrew/bin:/usr/local/bin:/usr/sbin:/sbin</string>", |
| 756 | ); |
| 757 | expect((yield* service.status).current).toBe(true); |
| 758 | }), |
| 759 | ); |
| 760 | |
| 761 | it.effect("keeps an installed launch agent current when the process PATH changes", () => |
| 762 | Effect.gen(function* () { |
| 763 | const { service, makeService } = yield* makeHarness("darwin"); |
| 764 | yield* service.install(); |
| 765 | |
| 766 | const restartedService = yield* makeService("/usr/local/bin:/usr/bin:/bin"); |
| 767 | expect((yield* restartedService.status).current).toBe(true); |
| 768 | }), |
| 769 | ); |
| 770 | |
| 771 | it.effect("drops PATH directories that cannot be represented in a launch agent plist", () => |
| 772 | Effect.gen(function* () { |
| 773 | const { service, fs } = yield* makeHarness( |
| 774 | "darwin", |
| 775 | "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin", |
| 776 | ); |
| 777 | const plan = yield* service.install(); |
| 778 | const plist = yield* fs.readFileString(plan.unitPath); |
| 779 | |
| 780 | expect(plist).toContain( |
| 781 | " <key>PATH</key>\n <string>/opt/homebrew/bin:/usr/bin:/usr/local/bin:/bin:/usr/sbin:/sbin</string>", |
| 782 | ); |
| 783 | expect(plist).not.toContain("\u0001"); |
| 784 | expect((yield* service.status).current).toBe(true); |
| 785 | }), |
| 786 | ); |
| 787 | |
| 788 | it.effect("ignores a bootout for an agent that is not loaded", () => |
| 789 | Effect.gen(function* () { |
| 790 | const { service, control } = yield* makeHarness("darwin"); |
| 791 | yield* service.install(); |
| 792 | control.failCommand = "launchctl bootout --wait gui/501/com.t3tools.t3code.service"; |
| 793 | |
| 794 | yield* service.install(); |
| 795 | expect((yield* service.status).current).toBe(true); |
| 796 | }), |
| 797 | ); |
| 798 | |
| 799 | it.effect("restarts without overwriting a pending remote update on macOS", () => |
| 800 | Effect.gen(function* () { |
| 801 | const { service, fs, statePath, commands } = yield* makeHarness("darwin"); |
| 802 | yield* service.install(); |
| 803 | const plistPath = (yield* service.status).unitPath; |
| 804 | const pendingState = JSON.stringify({ |
| 805 | protocol: SERVICE_LAUNCHER_PROTOCOL - 1, |
| 806 | activeVersion: "1.2.3", |
| 807 | update: { |
| 808 | id: "remote-update", |
| 809 | fromVersion: "1.2.3", |
| 810 | targetVersion: "1.2.4", |
| 811 | status: "pending", |
| 812 | }, |
| 813 | }); |
| 814 | yield* fs.writeFileString(statePath, pendingState); |
| 815 | for (const allowDowngrade of [false, true]) { |
| 816 | commands.length = 0; |
| 817 | |
| 818 | expect((yield* service.install({ allowDowngrade }).pipe(Effect.flip))._tag).toBe( |
| 819 | "BootServiceUpdatePendingError", |
| 820 | ); |
| 821 | expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); |
| 822 | expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ |
| 823 | "launchctl bootout --wait gui/501/com.t3tools.t3code.service", |
| 824 | `launchctl bootstrap gui/501 ${plistPath}`, |
| 825 | ]); |
| 826 | } |
| 827 | }), |
| 828 | ); |
| 829 | }); |