| author | |
| committer | |
| log | 2aa5f095fc3bb65c00cc4efce66a5473e2d4554a |
| tree | 5312f6b584510b0b5996b706a840481b3ea79f39 |
| parent | 7441b36926eb111a193183f98c06aced4f4c3acf |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>11 files changed, 531 insertions(+), 78 deletions(-)
apps/server/src/cli/connect.ts+5-1| ... | @@ -5,6 +5,7 @@ import { | ... | @@ -5,6 +5,7 @@ import { |
| 5 | type RelayClientInstallProgressStage, | 5 | type RelayClientInstallProgressStage, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { RelayOkResponse } from "@t3tools/contracts/relay"; | 7 | import { RelayOkResponse } from "@t3tools/contracts/relay"; |
| 8 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 8 | import * as RelayClient from "@t3tools/shared/relayClient"; | 9 | import * as RelayClient from "@t3tools/shared/relayClient"; |
| 9 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | 10 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; |
| 10 | import * as Cause from "effect/Cause"; | 11 | import * as Cause from "effect/Cause"; |
| ... | @@ -695,8 +696,11 @@ export const connectCommand = Command.make("connect", { | ... | @@ -695,8 +696,11 @@ export const connectCommand = Command.make("connect", { |
| 695 | // fail the command, just tell the user what happened and move on. | 696 | // fail the command, just tell the user what happened and move on. |
| 696 | const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding); | 697 | const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding); |
| 697 | if (background) { | 698 | if (background) { |
| 699 | const platform = yield* HostProcessPlatform; | ||
| 698 | yield* Console.log( | 700 | yield* Console.log( |
| 699 | "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out.", | 701 | platform === "darwin" |
| 702 | ? "\n✓ Background service ready\n\nT3 Code will stay reachable while you are logged in to this Mac." | ||
| 703 | : "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out.", | ||
| 700 | ); | 704 | ); |
| 701 | return; | 705 | return; |
| 702 | } | 706 | } |
apps/server/src/cli/service.test.ts+2-2| ... | @@ -29,9 +29,9 @@ it("gives a direct repair command for a stale service", () => { | ... | @@ -29,9 +29,9 @@ it("gives a direct repair command for a stale service", () => { |
| 29 | ); | 29 | ); |
| 30 | }); | 30 | }); |
| 31 | 31 | ||
| 32 | it("explains service availability without systemd", () => { | 32 | it("explains where the service is supported", () => { |
| 33 | assert.include( | 33 | assert.include( |
| 34 | formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"), | 34 | formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"), |
| 35 | "Supported on: Linux with systemd", | 35 | "Supported on: Linux with systemd, macOS with launchd", |
| 36 | ); | 36 | ); |
| 37 | }); | 37 | }); |
apps/server/src/cli/service.ts+10-3| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 1 | import * as Console from "effect/Console"; | 2 | import * as Console from "effect/Console"; |
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| ... | @@ -48,7 +49,7 @@ export function formatServiceStatus( | ... | @@ -48,7 +49,7 @@ export function formatServiceStatus( |
| 48 | cliVersion: string, | 49 | cliVersion: string, |
| 49 | ): string { | 50 | ): string { |
| 50 | if (!status.supported) { | 51 | if (!status.supported) { |
| 51 | return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd"; | 52 | return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd, macOS with launchd"; |
| 52 | } | 53 | } |
| 53 | if (!status.installed) { | 54 | if (!status.installed) { |
| 54 | return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`."; | 55 | return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`."; |
| ... | @@ -152,12 +153,18 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { | ... | @@ -152,12 +153,18 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { |
| 152 | yield* Console.log("T3 Code is already set up to run in the background on this machine."); | 153 | yield* Console.log("T3 Code is already set up to run in the background on this machine."); |
| 153 | return true; | 154 | return true; |
| 154 | } | 155 | } |
| 156 | // A LaunchAgent starts at login and dies at logout; there is no | ||
| 157 | // enable-linger equivalent on macOS. Do not promise more than that. | ||
| 158 | const platform = yield* HostProcessPlatform; | ||
| 155 | const wanted = yield* Prompt.run( | 159 | const wanted = yield* Prompt.run( |
| 156 | Prompt.confirm({ | 160 | Prompt.confirm({ |
| 157 | message: installed | 161 | message: installed |
| 158 | ? "The installed T3 Code service needs an update or repair. Update it now?" | 162 | ? "The installed T3 Code service needs an update or repair. Update it now?" |
| 159 | : "Run T3 Code in the background whenever this machine boots? " + | 163 | : platform === "darwin" |
| 160 | "It stays reachable through T3 Connect even after you log out.", | 164 | ? "Run T3 Code in the background whenever you log in to this Mac? " + |
| 165 | "It stays reachable through T3 Connect while you are logged in." | ||
| 166 | : "Run T3 Code in the background whenever this machine boots? " + | ||
| 167 | "It stays reachable through T3 Connect even after you log out.", | ||
| 161 | initial: true, | 168 | initial: true, |
| 162 | }), | 169 | }), |
| 163 | ); | 170 | ); |
apps/server/src/cloud/bootService.test.ts+143-4| ... | @@ -4,8 +4,10 @@ import { | ... | @@ -4,8 +4,10 @@ import { |
| 4 | HostProcessArguments, | 4 | HostProcessArguments, |
| 5 | HostProcessExecutablePath, | 5 | HostProcessExecutablePath, |
| 6 | HostProcessPlatform, | 6 | HostProcessPlatform, |
| 7 | HostProcessUserId, | ||
| 7 | } from "@t3tools/shared/hostProcess"; | 8 | } from "@t3tools/shared/hostProcess"; |
| 8 | import * as ConfigProvider from "effect/ConfigProvider"; | 9 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 10 | import * as Duration from "effect/Duration"; | ||
| 9 | import * as Effect from "effect/Effect"; | 11 | import * as Effect from "effect/Effect"; |
| 10 | import * as FileSystem from "effect/FileSystem"; | 12 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Layer from "effect/Layer"; | 13 | import * as Layer from "effect/Layer"; |
| ... | @@ -47,6 +49,51 @@ it("survives the kernel OOM-killing a greedy agent child", () => { | ... | @@ -47,6 +49,51 @@ it("survives the kernel OOM-killing a greedy agent child", () => { |
| 47 | expect(unit).toContain("OOMPolicy=continue"); | 49 | expect(unit).toContain("OOMPolicy=continue"); |
| 48 | }); | 50 | }); |
| 49 | 51 | ||
| 52 | const macPlan = { | ||
| 53 | nodePath: "/opt/homebrew/bin/node", | ||
| 54 | launcherPath: "/Users/theo/.t3/runtime/service-launcher.mjs", | ||
| 55 | baseDir: "/Users/theo/.t3", | ||
| 56 | logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", | ||
| 57 | unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist", | ||
| 58 | }; | ||
| 59 | |||
| 60 | it("keeps launchd pinned to the stable launcher rather than a versioned server", () => { | ||
| 61 | const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" }); | ||
| 62 | |||
| 63 | expect(plist).toContain("<string>/opt/homebrew/bin/node</string>"); | ||
| 64 | expect(plist).toContain("<string>/Users/theo/.t3/runtime/service-launcher.mjs</string>"); | ||
| 65 | expect(plist).not.toContain("versions/1.2.3"); | ||
| 66 | }); | ||
| 67 | |||
| 68 | it("restarts the launch agent on the systemd cadence", () => { | ||
| 69 | const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" }); | ||
| 70 | |||
| 71 | expect(plist).toContain("<key>RunAtLoad</key>\n <true/>"); | ||
| 72 | expect(plist).toContain("<key>KeepAlive</key>\n <true/>"); | ||
| 73 | expect(plist).toContain("<key>ThrottleInterval</key>\n <integer>5</integer>"); | ||
| 74 | expect(plist).toContain("<key>ExitTimeOut</key>\n <integer>90</integer>"); | ||
| 75 | }); | ||
| 76 | |||
| 77 | it("appends both stdio streams to the boot service log", () => { | ||
| 78 | const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" }); | ||
| 79 | |||
| 80 | expect(plist).toContain( | ||
| 81 | "<key>StandardOutPath</key>\n <string>/Users/theo/.t3/userdata/logs/boot-service.log</string>", | ||
| 82 | ); | ||
| 83 | expect(plist).toContain( | ||
| 84 | "<key>StandardErrorPath</key>\n <string>/Users/theo/.t3/userdata/logs/boot-service.log</string>", | ||
| 85 | ); | ||
| 86 | }); | ||
| 87 | |||
| 88 | it("escapes XML in host paths", () => { | ||
| 89 | const plist = BootService.renderBootServicePlist( | ||
| 90 | { ...macPlan, baseDir: "/Users/theo/T3 & <Co>" }, | ||
| 91 | { homeDir: "/Users/theo" }, | ||
| 92 | ); | ||
| 93 | |||
| 94 | expect(plist).toContain("<string>/Users/theo/T3 &amp; &lt;Co&gt;</string>"); | ||
| 95 | }); | ||
| 96 | |||
| 50 | const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( | 97 | const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 51 | platform: NodeJS.Platform = "linux", | 98 | platform: NodeJS.Platform = "linux", |
| 52 | usePinnedLauncher = false, | 99 | usePinnedLauncher = false, |
| ... | @@ -68,12 +115,14 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( | ... | @@ -68,12 +115,14 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 68 | yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n"); | 115 | yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n"); |
| 69 | 116 | ||
| 70 | const commands: string[] = []; | 117 | const commands: string[] = []; |
| 118 | const timeouts = new Map<string, unknown>(); | ||
| 71 | const control: { failCommand: string | undefined } = { failCommand: undefined }; | 119 | const control: { failCommand: string | undefined } = { failCommand: undefined }; |
| 72 | const runner = ProcessRunner.ProcessRunner.of({ | 120 | const runner = ProcessRunner.ProcessRunner.of({ |
| 73 | run: (input) => | 121 | run: (input) => |
| 74 | Effect.sync(() => { | 122 | Effect.sync(() => { |
| 75 | const command = `${input.command} ${input.args.join(" ")}`; | 123 | const command = `${input.command} ${input.args.join(" ")}`; |
| 76 | commands.push(command); | 124 | commands.push(command); |
| 125 | timeouts.set(command, input.timeout); | ||
| 77 | return { | 126 | return { |
| 78 | stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "", | 127 | stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "", |
| 79 | stderr: "", | 128 | stderr: "", |
| ... | @@ -99,19 +148,20 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( | ... | @@ -99,19 +148,20 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 99 | Effect.provide( | 148 | Effect.provide( |
| 100 | Layer.mergeAll( | 149 | Layer.mergeAll( |
| 101 | Layer.succeed(HostProcessPlatform, platform), | 150 | Layer.succeed(HostProcessPlatform, platform), |
| 151 | Layer.succeed(HostProcessUserId, 501), | ||
| 102 | Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"), | 152 | Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"), |
| 103 | Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]), | 153 | Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]), |
| 104 | ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })), | 154 | ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })), |
| 105 | ), | 155 | ), |
| 106 | ), | 156 | ), |
| 107 | ); | 157 | ); |
| 108 | return { service, fs, statePath, commands, control }; | 158 | return { service, fs, statePath, commands, timeouts, control }; |
| 109 | }); | 159 | }); |
| 110 | 160 | ||
| 111 | it.layer(NodeServices.layer)("boot service install", (it) => { | 161 | it.layer(NodeServices.layer)("boot service install", (it) => { |
| 112 | it.effect("installs, reports current state, and uninstalls", () => | 162 | it.effect("installs, reports current state, and uninstalls", () => |
| 113 | Effect.gen(function* () { | 163 | Effect.gen(function* () { |
| 114 | const { service, fs, statePath, commands } = yield* makeHarness(); | 164 | const { service, fs, statePath, commands, timeouts } = yield* makeHarness(); |
| 115 | const plan = yield* service.install; | 165 | const plan = yield* service.install; |
| 116 | 166 | ||
| 117 | expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ | 167 | expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ |
| ... | @@ -137,6 +187,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { | ... | @@ -137,6 +187,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 137 | expect(yield* service.uninstall).toBe(true); | 187 | expect(yield* service.uninstall).toBe(true); |
| 138 | expect((yield* service.status).installed).toBe(false); | 188 | expect((yield* service.status).installed).toBe(false); |
| 139 | expect(commands.some((command) => command.startsWith("npm "))).toBe(false); | 189 | expect(commands.some((command) => command.startsWith("npm "))).toBe(false); |
| 190 | // The stop can block up to systemd's 90s TimeoutStopSec; the runner's | ||
| 191 | // 60s default would cancel it mid-shutdown. | ||
| 192 | expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual( | ||
| 193 | Duration.seconds(120), | ||
| 194 | ); | ||
| 140 | }), | 195 | }), |
| 141 | ); | 196 | ); |
| 142 | 197 | ||
| ... | @@ -195,11 +250,95 @@ it.layer(NodeServices.layer)("boot service install", (it) => { | ... | @@ -195,11 +250,95 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 195 | }), | 250 | }), |
| 196 | ); | 251 | ); |
| 197 | 252 | ||
| 198 | it.effect("fails closed off Linux", () => | 253 | it.effect("fails closed on Windows", () => |
| 199 | Effect.gen(function* () { | 254 | Effect.gen(function* () { |
| 200 | const { service } = yield* makeHarness("darwin"); | 255 | const { service } = yield* makeHarness("win32"); |
| 201 | expect((yield* service.status).supported).toBe(false); | 256 | expect((yield* service.status).supported).toBe(false); |
| 202 | expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUnsupportedError"); | 257 | expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUnsupportedError"); |
| 203 | }), | 258 | }), |
| 204 | ); | 259 | ); |
| 260 | |||
| 261 | it.effect("installs, reports current state, and uninstalls on macOS", () => | ||
| 262 | Effect.gen(function* () { | ||
| 263 | const { service, fs, statePath, commands, timeouts } = yield* makeHarness("darwin"); | ||
| 264 | const plan = yield* service.install; | ||
| 265 | |||
| 266 | expect(plan.unitPath.endsWith("Library/LaunchAgents/com.t3tools.t3code.service.plist")).toBe( | ||
| 267 | true, | ||
| 268 | ); | ||
| 269 | expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ | ||
| 270 | protocol: SERVICE_LAUNCHER_PROTOCOL, | ||
| 271 | activeVersion: "1.2.3", | ||
| 272 | }); | ||
| 273 | expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n"); | ||
| 274 | expect((yield* service.status).current).toBe(true); | ||
| 275 | expect(yield* service.uninstall).toBe(true); | ||
| 276 | expect((yield* service.status).installed).toBe(false); | ||
| 277 | expect(commands.some((command) => command.startsWith("npm "))).toBe(false); | ||
| 278 | expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false); | ||
| 279 | // A bootout can block up to the plist's 90s ExitTimeOut; the runner's | ||
| 280 | // 60s default would cancel it and let bootstrap race a loaded job. | ||
| 281 | expect(timeouts.get("launchctl bootout --wait gui/501/com.t3tools.t3code.service")).toEqual( | ||
| 282 | Duration.seconds(120), | ||
| 283 | ); | ||
| 284 | }), | ||
| 285 | ); | ||
| 286 | |||
| 287 | it.effect("restarts the launch agent when repair fails", () => | ||
| 288 | Effect.gen(function* () { | ||
| 289 | const { service, commands, control } = yield* makeHarness("darwin"); | ||
| 290 | yield* service.install; | ||
| 291 | const plistPath = (yield* service.status).unitPath; | ||
| 292 | commands.length = 0; | ||
| 293 | control.failCommand = `launchctl bootstrap gui/501 ${plistPath}`; | ||
| 294 | |||
| 295 | const error = yield* service.install.pipe(Effect.flip); | ||
| 296 | expect(error._tag).toBe("BootServiceCommandError"); | ||
| 297 | expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ | ||
| 298 | "launchctl bootout --wait gui/501/com.t3tools.t3code.service", | ||
| 299 | "launchctl enable gui/501/com.t3tools.t3code.service", | ||
| 300 | `launchctl bootstrap gui/501 ${plistPath}`, | ||
| 301 | `launchctl bootstrap gui/501 ${plistPath}`, | ||
| 302 | ]); | ||
| 303 | }), | ||
| 304 | ); | ||
| 305 | |||
| 306 | it.effect("ignores a bootout for an agent that is not loaded", () => | ||
| 307 | Effect.gen(function* () { | ||
| 308 | const { service, control } = yield* makeHarness("darwin"); | ||
| 309 | yield* service.install; | ||
| 310 | control.failCommand = "launchctl bootout --wait gui/501/com.t3tools.t3code.service"; | ||
| 311 | |||
| 312 | yield* service.install; | ||
| 313 | expect((yield* service.status).current).toBe(true); | ||
| 314 | }), | ||
| 315 | ); | ||
| 316 | |||
| 317 | it.effect("restarts without overwriting a pending remote update on macOS", () => | ||
| 318 | Effect.gen(function* () { | ||
| 319 | const { service, fs, statePath, commands } = yield* makeHarness("darwin"); | ||
| 320 | yield* service.install; | ||
| 321 | const plistPath = (yield* service.status).unitPath; | ||
| 322 | // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document. | ||
| 323 | const pendingState = JSON.stringify({ | ||
| 324 | protocol: SERVICE_LAUNCHER_PROTOCOL - 1, | ||
| 325 | activeVersion: "1.2.3", | ||
| 326 | update: { | ||
| 327 | id: "remote-update", | ||
| 328 | fromVersion: "1.2.3", | ||
| 329 | targetVersion: "1.2.4", | ||
| 330 | status: "pending", | ||
| 331 | }, | ||
| 332 | }); | ||
| 333 | yield* fs.writeFileString(statePath, pendingState); | ||
| 334 | commands.length = 0; | ||
| 335 | |||
| 336 | expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUpdatePendingError"); | ||
| 337 | expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); | ||
| 338 | expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ | ||
| 339 | "launchctl bootout --wait gui/501/com.t3tools.t3code.service", | ||
| 340 | `launchctl bootstrap gui/501 ${plistPath}`, | ||
| 341 | ]); | ||
| 342 | }), | ||
| 343 | ); | ||
| 205 | }); | 344 | }); |
apps/server/src/cloud/bootService.ts+314-47| ... | @@ -1,4 +1,8 @@ | ... | @@ -1,4 +1,8 @@ |
| 1 | import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 1 | import { |
| 2 | HostProcessExecutablePath, | ||
| 3 | HostProcessPlatform, | ||
| 4 | HostProcessUserId, | ||
| 5 | } from "@t3tools/shared/hostProcess"; | ||
| 2 | import * as Config from "effect/Config"; | 6 | import * as Config from "effect/Config"; |
| 3 | import * as Context from "effect/Context"; | 7 | import * as Context from "effect/Context"; |
| 4 | import * as DateTime from "effect/DateTime"; | 8 | import * as DateTime from "effect/DateTime"; |
| ... | @@ -27,6 +31,10 @@ import { | ... | @@ -27,6 +31,10 @@ import { |
| 27 | 31 | ||
| 28 | const BOOT_SERVICE_NAME = "t3code"; | 32 | const BOOT_SERVICE_NAME = "t3code"; |
| 29 | export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; | 33 | export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; |
| 34 | // `.service` suffix keeps the label distinct from the desktop app's bundle id | ||
| 35 | // (com.t3tools.t3code), so launchd and TCC records never collide. | ||
| 36 | export const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service"; | ||
| 37 | export const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`; | ||
| 30 | export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; | 38 | export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; |
| 31 | 39 | ||
| 32 | /** systemd expands `%` specifiers, including in unquoted append-log paths. */ | 40 | /** systemd expands `%` specifiers, including in unquoted append-log paths. */ |
| ... | @@ -83,12 +91,280 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { | ... | @@ -83,12 +91,280 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { |
| 83 | ].join("\n"); | 91 | ].join("\n"); |
| 84 | } | 92 | } |
| 85 | 93 | ||
| 94 | /** Plist values are emitted as XML text nodes; only these three need escaping. */ | ||
| 95 | export function escapeXmlText(value: string): string { | ||
| 96 | return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;"); | ||
| 97 | } | ||
| 98 | |||
| 99 | /** Pure renderer: launch agents cannot rely on the user's shell or PATH. */ | ||
| 100 | export function renderBootServicePlist( | ||
| 101 | plan: BootServicePlan, | ||
| 102 | options: { readonly homeDir: string }, | ||
| 103 | ): string { | ||
| 104 | // KeepAlive + ThrottleInterval mirror Restart=always + RestartSec=5. launchd | ||
| 105 | // has no StartLimitBurst analog; a hard crash loop respawns every 5s forever. | ||
| 106 | // ExitTimeOut 90 matches systemd's default TimeoutStopSec. A plain stop | ||
| 107 | // completes within the launcher's 5s child grace, but a stop that queues | ||
| 108 | // behind an in-flight update transition can take much longer; launchd's | ||
| 109 | // system-defined default (5s on current macOS) would SIGKILL the launcher | ||
| 110 | // (and, with it, the process group) mid-handoff. | ||
| 111 | // ProcessType Interactive opts out of background-job resource throttling. | ||
| 112 | // AbandonProcessGroup stays at its default (false): launchd reaps leftover | ||
| 113 | // process-group members only when the launcher itself exits — the analog of | ||
| 114 | // KillMode=mixed's final cgroup kill — and not when the launcher restarts its | ||
| 115 | // child, so agent children survive server updates. | ||
| 116 | return [ | ||
| 117 | `<?xml version="1.0" encoding="UTF-8"?>`, | ||
| 118 | `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`, | ||
| 119 | `<plist version="1.0">`, | ||
| 120 | `<dict>`, | ||
| 121 | ` <key>Label</key>`, | ||
| 122 | ` <string>${BOOT_SERVICE_LAUNCHD_LABEL}</string>`, | ||
| 123 | ` <key>ProgramArguments</key>`, | ||
| 124 | ` <array>`, | ||
| 125 | ` <string>${escapeXmlText(plan.nodePath)}</string>`, | ||
| 126 | ` <string>${escapeXmlText(plan.launcherPath)}</string>`, | ||
| 127 | ` </array>`, | ||
| 128 | ` <key>EnvironmentVariables</key>`, | ||
| 129 | ` <dict>`, | ||
| 130 | ` <key>T3CODE_HOME</key>`, | ||
| 131 | ` <string>${escapeXmlText(plan.baseDir)}</string>`, | ||
| 132 | ` <key>${BOOT_SERVICE_UNIT_ENV}</key>`, | ||
| 133 | ` <string>${BOOT_SERVICE_PLIST_FILE}</string>`, | ||
| 134 | ` </dict>`, | ||
| 135 | ` <key>WorkingDirectory</key>`, | ||
| 136 | ` <string>${escapeXmlText(options.homeDir)}</string>`, | ||
| 137 | ` <key>RunAtLoad</key>`, | ||
| 138 | ` <true/>`, | ||
| 139 | ` <key>KeepAlive</key>`, | ||
| 140 | ` <true/>`, | ||
| 141 | ` <key>ThrottleInterval</key>`, | ||
| 142 | ` <integer>5</integer>`, | ||
| 143 | ` <key>ExitTimeOut</key>`, | ||
| 144 | ` <integer>90</integer>`, | ||
| 145 | ` <key>ProcessType</key>`, | ||
| 146 | ` <string>Interactive</string>`, | ||
| 147 | ` <key>StandardOutPath</key>`, | ||
| 148 | ` <string>${escapeXmlText(plan.logPath)}</string>`, | ||
| 149 | ` <key>StandardErrorPath</key>`, | ||
| 150 | ` <string>${escapeXmlText(plan.logPath)}</string>`, | ||
| 151 | `</dict>`, | ||
| 152 | `</plist>`, | ||
| 153 | ``, | ||
| 154 | ].join("\n"); | ||
| 155 | } | ||
| 156 | |||
| 157 | export interface BootServiceStep { | ||
| 158 | readonly step: string; | ||
| 159 | readonly command: string; | ||
| 160 | readonly args: ReadonlyArray<string>; | ||
| 161 | /** | ||
| 162 | * Non-zero exit is logged and ignored. Reserved for steps whose common | ||
| 163 | * failures (not loaded, already enabled) leave a state a later strict step | ||
| 164 | * either tolerates or fails loudly on. | ||
| 165 | */ | ||
| 166 | readonly optional?: boolean; | ||
| 167 | /** Override the ProcessRunner default (60s) for steps that block longer. */ | ||
| 168 | readonly timeout?: Duration.Input; | ||
| 169 | } | ||
| 170 | |||
| 171 | /** | ||
| 172 | * Stop commands block until the service manager gives up: 90s by default for | ||
| 173 | * systemd's TimeoutStopSec, and ExitTimeOut=90 in the rendered plist. This | ||
| 174 | * must stay above both, or the runner cancels the stop mid-shutdown and the | ||
| 175 | * next step races a still-loaded service. | ||
| 176 | */ | ||
| 177 | const STOP_STEP_TIMEOUT = Duration.seconds(120); | ||
| 178 | |||
| 179 | /** | ||
| 180 | * Platform service-manager integration as data: paths, a pure renderer, and | ||
| 181 | * the command steps each flow runs. install/uninstall/status consume this and | ||
| 182 | * never branch on platform. | ||
| 183 | */ | ||
| 184 | export interface BootServiceManager { | ||
| 185 | readonly kind: "systemd" | "launchd"; | ||
| 186 | readonly unitPath: string; | ||
| 187 | readonly render: (plan: BootServicePlan) => string; | ||
| 188 | /** Before rewriting files, when a unit is already installed. */ | ||
| 189 | readonly stop: ReadonlyArray<BootServiceStep>; | ||
| 190 | /** After files are written. The last entry starts the service. */ | ||
| 191 | readonly activate: ReadonlyArray<BootServiceStep>; | ||
| 192 | /** Best-effort recovery after a failed repair of an installed service. */ | ||
| 193 | readonly restart: ReadonlyArray<BootServiceStep>; | ||
| 194 | /** Uninstall, before the unit file is removed. */ | ||
| 195 | readonly deactivate: ReadonlyArray<BootServiceStep>; | ||
| 196 | /** Uninstall, after the unit file is removed. */ | ||
| 197 | readonly finalize: ReadonlyArray<BootServiceStep>; | ||
| 198 | } | ||
| 199 | |||
| 200 | export function systemdManager(input: { | ||
| 201 | readonly path: Path.Path; | ||
| 202 | readonly homeDir: string; | ||
| 203 | }): BootServiceManager { | ||
| 204 | const unitPath = input.path.join( | ||
| 205 | input.homeDir, | ||
| 206 | ".config", | ||
| 207 | "systemd", | ||
| 208 | "user", | ||
| 209 | BOOT_SERVICE_UNIT_FILE, | ||
| 210 | ); | ||
| 211 | return { | ||
| 212 | kind: "systemd", | ||
| 213 | unitPath, | ||
| 214 | render: renderBootServiceUnit, | ||
| 215 | stop: [ | ||
| 216 | { | ||
| 217 | step: "stopping the installed service", | ||
| 218 | command: "systemctl", | ||
| 219 | args: ["--user", "stop", BOOT_SERVICE_UNIT_FILE], | ||
| 220 | timeout: STOP_STEP_TIMEOUT, | ||
| 221 | }, | ||
| 222 | ], | ||
| 223 | activate: [ | ||
| 224 | { | ||
| 225 | step: "reloading systemd user units", | ||
| 226 | command: "systemctl", | ||
| 227 | args: ["--user", "daemon-reload"], | ||
| 228 | }, | ||
| 229 | { | ||
| 230 | step: "enabling the service", | ||
| 231 | command: "systemctl", | ||
| 232 | args: ["--user", "enable", BOOT_SERVICE_UNIT_FILE], | ||
| 233 | }, | ||
| 234 | { step: "enabling lingering for this user", command: "loginctl", args: ["enable-linger"] }, | ||
| 235 | // Start last. No administrative state write occurs after this succeeds. | ||
| 236 | { | ||
| 237 | step: "starting the service", | ||
| 238 | command: "systemctl", | ||
| 239 | args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE], | ||
| 240 | }, | ||
| 241 | ], | ||
| 242 | restart: [ | ||
| 243 | { | ||
| 244 | step: "restarting the service after a failed update", | ||
| 245 | command: "systemctl", | ||
| 246 | args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE], | ||
| 247 | }, | ||
| 248 | ], | ||
| 249 | deactivate: [ | ||
| 250 | { | ||
| 251 | step: "stopping the service", | ||
| 252 | command: "systemctl", | ||
| 253 | args: ["--user", "disable", "--now", BOOT_SERVICE_UNIT_FILE], | ||
| 254 | timeout: STOP_STEP_TIMEOUT, | ||
| 255 | }, | ||
| 256 | ], | ||
| 257 | finalize: [ | ||
| 258 | { | ||
| 259 | step: "reloading systemd user units", | ||
| 260 | command: "systemctl", | ||
| 261 | args: ["--user", "daemon-reload"], | ||
| 262 | }, | ||
| 263 | ], | ||
| 264 | }; | ||
| 265 | } | ||
| 266 | |||
| 267 | export function launchdManager(input: { | ||
| 268 | readonly path: Path.Path; | ||
| 269 | readonly homeDir: string; | ||
| 270 | readonly uid: number; | ||
| 271 | }): BootServiceManager { | ||
| 272 | const unitPath = input.path.join( | ||
| 273 | input.homeDir, | ||
| 274 | "Library", | ||
| 275 | "LaunchAgents", | ||
| 276 | BOOT_SERVICE_PLIST_FILE, | ||
| 277 | ); | ||
| 278 | const domainTarget = `gui/${input.uid}`; | ||
| 279 | const serviceTarget = `${domainTarget}/${BOOT_SERVICE_LAUNCHD_LABEL}`; | ||
| 280 | // bootout/enable are optional: they fail on not-loaded states that are fine | ||
| 281 | // to proceed from. The strict `bootstrap` runs last and is also the start: | ||
| 282 | // loading a RunAtLoad/KeepAlive plist starts the job, so a separate | ||
| 283 | // kickstart would kill and restart a server it just booted. A lingering job | ||
| 284 | // that survived bootout, or a gui domain with nobody logged in at the | ||
| 285 | // screen (SSH install), makes bootstrap fail the flow loudly rather than | ||
| 286 | // silently keeping a stale server. | ||
| 287 | return { | ||
| 288 | kind: "launchd", | ||
| 289 | unitPath, | ||
| 290 | render: (plan) => renderBootServicePlist(plan, { homeDir: input.homeDir }), | ||
| 291 | // Without --wait, bootout returns in milliseconds while the job drains | ||
| 292 | // for up to ExitTimeOut, and a bootstrap during the drain fails EIO. | ||
| 293 | // --wait (present on modern macOS, absent from the man page) blocks until | ||
| 294 | // the job is removed from the domain; STOP_STEP_TIMEOUT outlives it. | ||
| 295 | stop: [ | ||
| 296 | { | ||
| 297 | step: "stopping the installed launch agent", | ||
| 298 | command: "launchctl", | ||
| 299 | args: ["bootout", "--wait", serviceTarget], | ||
| 300 | optional: true, | ||
| 301 | timeout: STOP_STEP_TIMEOUT, | ||
| 302 | }, | ||
| 303 | ], | ||
| 304 | activate: [ | ||
| 305 | // A persisted `launchctl disable` override refuses bootstrap; clear it. | ||
| 306 | { | ||
| 307 | step: "enabling the launch agent", | ||
| 308 | command: "launchctl", | ||
| 309 | args: ["enable", serviceTarget], | ||
| 310 | optional: true, | ||
| 311 | }, | ||
| 312 | // Start last. No administrative state write occurs after this succeeds. | ||
| 313 | { | ||
| 314 | step: "starting the service", | ||
| 315 | command: "launchctl", | ||
| 316 | args: ["bootstrap", domainTarget, unitPath], | ||
| 317 | }, | ||
| 318 | ], | ||
| 319 | restart: [ | ||
| 320 | { | ||
| 321 | step: "restarting the service after a failed update", | ||
| 322 | command: "launchctl", | ||
| 323 | args: ["bootstrap", domainTarget, unitPath], | ||
| 324 | }, | ||
| 325 | ], | ||
| 326 | // No `launchctl disable` here: a persisted override would sabotage a | ||
| 327 | // later reinstall. Removing the plist is what stops the next login load. | ||
| 328 | // A bootout that fails for a reason other than "not loaded" leaves the | ||
| 329 | // job running until logout; the failure is in the boot-service log. | ||
| 330 | deactivate: [ | ||
| 331 | { | ||
| 332 | step: "stopping the service", | ||
| 333 | command: "launchctl", | ||
| 334 | args: ["bootout", "--wait", serviceTarget], | ||
| 335 | optional: true, | ||
| 336 | timeout: STOP_STEP_TIMEOUT, | ||
| 337 | }, | ||
| 338 | ], | ||
| 339 | finalize: [], | ||
| 340 | }; | ||
| 341 | } | ||
| 342 | |||
| 343 | /** Undefined means this host cannot run the background service. */ | ||
| 344 | export function selectBootServiceManager(input: { | ||
| 345 | readonly platform: NodeJS.Platform; | ||
| 346 | readonly homeDir: string; | ||
| 347 | readonly uid: number | undefined; | ||
| 348 | readonly path: Path.Path; | ||
| 349 | }): BootServiceManager | undefined { | ||
| 350 | if (input.homeDir === "") { | ||
| 351 | return undefined; | ||
| 352 | } | ||
| 353 | if (input.platform === "linux") { | ||
| 354 | return systemdManager({ path: input.path, homeDir: input.homeDir }); | ||
| 355 | } | ||
| 356 | if (input.platform === "darwin" && input.uid !== undefined) { | ||
| 357 | return launchdManager({ path: input.path, homeDir: input.homeDir, uid: input.uid }); | ||
| 358 | } | ||
| 359 | return undefined; | ||
| 360 | } | ||
| 361 | |||
| 86 | export class BootServiceUnsupportedError extends Schema.TaggedErrorClass<BootServiceUnsupportedError>()( | 362 | export class BootServiceUnsupportedError extends Schema.TaggedErrorClass<BootServiceUnsupportedError>()( |
| 87 | "BootServiceUnsupportedError", | 363 | "BootServiceUnsupportedError", |
| 88 | { platform: Schema.String }, | 364 | { platform: Schema.String }, |
| 89 | ) { | 365 | ) { |
| 90 | override get message(): string { | 366 | override get message(): string { |
| 91 | return `Background setup currently supports Linux with systemd; this machine reports '${this.platform}'.`; | 367 | return `Background setup supports Linux with systemd and macOS with launchd; this machine reports '${this.platform}'.`; |
| 92 | } | 368 | } |
| 93 | } | 369 | } |
| 94 | 370 | ||
| ... | @@ -163,14 +439,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -163,14 +439,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 163 | }) { | 439 | }) { |
| 164 | const hostExecPath = yield* HostProcessExecutablePath; | 440 | const hostExecPath = yield* HostProcessExecutablePath; |
| 165 | const platform = yield* HostProcessPlatform; | 441 | const platform = yield* HostProcessPlatform; |
| 442 | const uid = yield* HostProcessUserId; | ||
| 166 | const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); | 443 | const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); |
| 167 | const fs = yield* FileSystem.FileSystem; | 444 | const fs = yield* FileSystem.FileSystem; |
| 168 | const path = yield* Path.Path; | 445 | const path = yield* Path.Path; |
| 169 | const runner = yield* ProcessRunner.ProcessRunner; | 446 | const runner = yield* ProcessRunner.ProcessRunner; |
| 170 | const host = input.host ?? { execPath: hostExecPath }; | 447 | const host = input.host ?? { execPath: hostExecPath }; |
| 171 | 448 | ||
| 172 | const unitDir = path.join(homeDir, ".config", "systemd", "user"); | 449 | const detectedManager = selectBootServiceManager({ platform, homeDir, uid, path }); |
| 173 | const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE); | 450 | const unitPath = detectedManager?.unitPath ?? ""; |
| 174 | const logPath = path.join(input.logsDir, "boot-service.log"); | 451 | const logPath = path.join(input.logsDir, "boot-service.log"); |
| 175 | const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); | 452 | const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); |
| 176 | const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); | 453 | const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); |
| ... | @@ -198,11 +475,11 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -198,11 +475,11 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 198 | unitPath, | 475 | unitPath, |
| 199 | }; | 476 | }; |
| 200 | 477 | ||
| 201 | const requireSystemdLinux = Effect.gen(function* () { | 478 | const requireManager = Effect.suspend(() => |
| 202 | if (platform !== "linux" || homeDir === "") { | 479 | detectedManager === undefined |
| 203 | return yield* new BootServiceUnsupportedError({ platform }); | 480 | ? new BootServiceUnsupportedError({ platform }) |
| 204 | } | 481 | : Effect.succeed(detectedManager), |
| 205 | }); | 482 | ); |
| 206 | 483 | ||
| 207 | const runStep = Effect.fn("cloud.boot_service.run_step")(function* ( | 484 | const runStep = Effect.fn("cloud.boot_service.run_step")(function* ( |
| 208 | step: string, | 485 | step: string, |
| ... | @@ -235,8 +512,25 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -235,8 +512,25 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 235 | ); | 512 | ); |
| 236 | }); | 513 | }); |
| 237 | 514 | ||
| 515 | const runSteps = (steps: ReadonlyArray<BootServiceStep>) => | ||
| 516 | Effect.forEach( | ||
| 517 | steps, | ||
| 518 | (entry) => { | ||
| 519 | const run = runStep( | ||
| 520 | entry.step, | ||
| 521 | entry.command, | ||
| 522 | entry.args, | ||
| 523 | entry.timeout === undefined ? undefined : { timeout: entry.timeout }, | ||
| 524 | ); | ||
| 525 | // runStep's tapError already appends the failure to the log, so an | ||
| 526 | // ignored optional step still leaves a trace. | ||
| 527 | return entry.optional === true ? run.pipe(Effect.ignore) : run.pipe(Effect.asVoid); | ||
| 528 | }, | ||
| 529 | { discard: true }, | ||
| 530 | ); | ||
| 531 | |||
| 238 | const install: BootService["Service"]["install"] = Effect.gen(function* () { | 532 | const install: BootService["Service"]["install"] = Effect.gen(function* () { |
| 239 | yield* requireSystemdLinux; | 533 | const manager = yield* requireManager; |
| 240 | yield* fs | 534 | yield* fs |
| 241 | .makeDirectory(input.logsDir, { recursive: true }) | 535 | .makeDirectory(input.logsDir, { recursive: true }) |
| 242 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | 536 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); |
| ... | @@ -298,11 +592,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -298,11 +592,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 298 | .exists(unitPath) | 592 | .exists(unitPath) |
| 299 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | 593 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); |
| 300 | if (installed) { | 594 | if (installed) { |
| 301 | yield* runStep("stopping the installed service", "systemctl", [ | 595 | yield* runSteps(manager.stop); |
| 302 | "--user", | ||
| 303 | "stop", | ||
| 304 | BOOT_SERVICE_UNIT_FILE, | ||
| 305 | ]); | ||
| 306 | } | 596 | } |
| 307 | 597 | ||
| 308 | yield* Effect.gen(function* () { | 598 | yield* Effect.gen(function* () { |
| ... | @@ -316,7 +606,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -316,7 +606,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 316 | } | 606 | } |
| 317 | } | 607 | } |
| 318 | yield* fs | 608 | yield* fs |
| 319 | .makeDirectory(unitDir, { recursive: true }) | 609 | .makeDirectory(path.dirname(unitPath), { recursive: true }) |
| 320 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | 610 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); |
| 321 | yield* writeDurably(launcherPath, launcherSource); | 611 | yield* writeDurably(launcherPath, launcherSource); |
| 322 | yield* writeDurably( | 612 | yield* writeDurably( |
| ... | @@ -331,58 +621,35 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -331,58 +621,35 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 331 | 2, | 621 | 2, |
| 332 | )}\n`, | 622 | )}\n`, |
| 333 | ); | 623 | ); |
| 334 | yield* writeDurably(unitPath, renderBootServiceUnit(plan)); | 624 | yield* writeDurably(unitPath, manager.render(plan)); |
| 335 | 625 | ||
| 336 | yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); | 626 | yield* runSteps(manager.activate); |
| 337 | yield* runStep("enabling the service", "systemctl", [ | ||
| 338 | "--user", | ||
| 339 | "enable", | ||
| 340 | BOOT_SERVICE_UNIT_FILE, | ||
| 341 | ]); | ||
| 342 | yield* runStep("enabling lingering for this user", "loginctl", ["enable-linger"]); | ||
| 343 | // Start last. No administrative state write occurs after this succeeds. | ||
| 344 | yield* runStep("starting the service", "systemctl", [ | ||
| 345 | "--user", | ||
| 346 | "restart", | ||
| 347 | BOOT_SERVICE_UNIT_FILE, | ||
| 348 | ]); | ||
| 349 | }).pipe( | 627 | }).pipe( |
| 350 | Effect.tapError(() => | 628 | Effect.tapError(() => |
| 351 | installed | 629 | installed ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void, |
| 352 | ? runStep("restarting the service after a failed update", "systemctl", [ | ||
| 353 | "--user", | ||
| 354 | "restart", | ||
| 355 | BOOT_SERVICE_UNIT_FILE, | ||
| 356 | ]).pipe(Effect.ignore) | ||
| 357 | : Effect.void, | ||
| 358 | ), | 630 | ), |
| 359 | ); | 631 | ); |
| 360 | return plan; | 632 | return plan; |
| 361 | }).pipe(Effect.withSpan("cloud.boot_service.install")); | 633 | }).pipe(Effect.withSpan("cloud.boot_service.install")); |
| 362 | 634 | ||
| 363 | const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { | 635 | const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { |
| 364 | yield* requireSystemdLinux; | 636 | const manager = yield* requireManager; |
| 365 | if ( | 637 | if ( |
| 366 | !(yield* fs | 638 | !(yield* fs |
| 367 | .exists(unitPath) | 639 | .exists(unitPath) |
| 368 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })))) | 640 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })))) |
| 369 | ) | 641 | ) |
| 370 | return false; | 642 | return false; |
| 371 | yield* runStep("stopping the service", "systemctl", [ | 643 | yield* runSteps(manager.deactivate); |
| 372 | "--user", | ||
| 373 | "disable", | ||
| 374 | "--now", | ||
| 375 | BOOT_SERVICE_UNIT_FILE, | ||
| 376 | ]); | ||
| 377 | yield* fs | 644 | yield* fs |
| 378 | .remove(unitPath) | 645 | .remove(unitPath) |
| 379 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | 646 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); |
| 380 | yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); | 647 | yield* runSteps(manager.finalize); |
| 381 | return true; | 648 | return true; |
| 382 | }).pipe(Effect.withSpan("cloud.boot_service.uninstall")); | 649 | }).pipe(Effect.withSpan("cloud.boot_service.uninstall")); |
| 383 | 650 | ||
| 384 | const status: BootService["Service"]["status"] = Effect.gen(function* () { | 651 | const status: BootService["Service"]["status"] = Effect.gen(function* () { |
| 385 | if (platform !== "linux" || homeDir === "") { | 652 | if (detectedManager === undefined) { |
| 386 | return { supported: false, installed: false, current: false, unitPath, logPath }; | 653 | return { supported: false, installed: false, current: false, unitPath, logPath }; |
| 387 | } | 654 | } |
| 388 | if (!(yield* fs.exists(unitPath))) { | 655 | if (!(yield* fs.exists(unitPath))) { |
| ... | @@ -401,7 +668,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -401,7 +668,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 401 | supported: true, | 668 | supported: true, |
| 402 | installed: true, | 669 | installed: true, |
| 403 | current: | 670 | current: |
| 404 | unit === renderBootServiceUnit(plan) && | 671 | unit === detectedManager.render(plan) && |
| 405 | launcherExists && | 672 | launcherExists && |
| 406 | runtimeEntryExists && | 673 | runtimeEntryExists && |
| 407 | Option.isSome(runtimeSentinel) && | 674 | Option.isSome(runtimeSentinel) && |
apps/server/src/cloud/http.ts+2-1| ... | @@ -649,7 +649,8 @@ export const pendingServiceUpdateExists = Effect.gen(function* () { | ... | @@ -649,7 +649,8 @@ export const pendingServiceUpdateExists = Effect.gen(function* () { |
| 649 | }); | 649 | }); |
| 650 | 650 | ||
| 651 | // A pending update alone is not proof a replacement server is coming: an | 651 | // A pending update alone is not proof a replacement server is coming: an |
| 652 | // explicit launcher stop (`t3 service uninstall`, `systemctl stop`) during | 652 | // explicit launcher stop (`t3 service uninstall`, `systemctl stop`, |
| 653 | // `launchctl bootout`) during | ||
| 653 | // the pending window also tears this server down. The launcher marks that case | 654 | // the pending window also tears this server down. The launcher marks that case |
| 654 | // just before it signals the child, so pending + no marker is the handoff. | 655 | // just before it signals the child, so pending + no marker is the handoff. |
| 655 | const pendingUpdateHandoffExists = Effect.gen(function* () { | 656 | const pendingUpdateHandoffExists = Effect.gen(function* () { |
apps/server/src/cloud/pinnedRuntime.ts+2-2| ... | @@ -10,8 +10,8 @@ import * as ProcessRunner from "../processRunner.ts"; | ... | @@ -10,8 +10,8 @@ import * as ProcessRunner from "../processRunner.ts"; |
| 10 | 10 | ||
| 11 | /** | 11 | /** |
| 12 | * A pinned runtime is an exact `t3@<version>` npm-installed into | 12 | * A pinned runtime is an exact `t3@<version>` npm-installed into |
| 13 | * <baseDir>/runtime/versions/<version>. The boot service points its systemd | 13 | * <baseDir>/runtime/versions/<version>. The boot service points its unit or |
| 14 | * unit here, and server self-update installs the target version here before | 14 | * launch agent here, and server self-update installs the target version here before |
| 15 | * switching over, never `npx t3`, whose cache is ephemeral and whose | 15 | * switching over, never `npx t3`, whose cache is ephemeral and whose |
| 16 | * registry fetch at boot would make startup depend on the network. | 16 | * registry fetch at boot would make startup depend on the network. |
| 17 | */ | 17 | */ |
apps/server/src/serviceLauncher.ts+3-1| ... | @@ -317,7 +317,9 @@ export class Launcher { | ... | @@ -317,7 +317,9 @@ export class Launcher { |
| 317 | // This must happen synchronously at signal receipt. A queued update | 317 | // This must happen synchronously at signal receipt. A queued update |
| 318 | // transition may already be terminating the active child, and that child | 318 | // transition may already be terminating the active child, and that child |
| 319 | // needs to see the marker in its shutdown finalizer. KillMode=mixed also | 319 | // needs to see the marker in its shutdown finalizer. KillMode=mixed also |
| 320 | // ensures systemd signals the launcher before the rest of the cgroup. | 320 | // ensures systemd signals the launcher before the rest of the cgroup, and |
| 321 | // launchd signals only the job's main process (this launcher), so the | ||
| 322 | // marker lands before the child sees any signal on both platforms. | ||
| 321 | try { | 323 | try { |
| 322 | NodeFS.writeFileSync(stopMarkerPath(this.#baseDir), "", { mode: 0o600 }); | 324 | NodeFS.writeFileSync(stopMarkerPath(this.#baseDir), "", { mode: 0o600 }); |
| 323 | } catch { | 325 | } catch { |
docs/internals/server-updates.md+7-6| ... | @@ -2,14 +2,15 @@ | ... | @@ -2,14 +2,15 @@ |
| 2 | 2 | ||
| 3 | > For maintainers. Using T3 Code? See [docs/user](../user/). | 3 | > For maintainers. Using T3 Code? See [docs/user](../user/). |
| 4 | 4 | ||
| 5 | Remote server updates use one stable systemd launcher. Foreground CLI processes do not self-update, | 5 | Remote server updates use one stable launcher selected by the platform service manager (systemd on |
| 6 | and a running server never edits its systemd unit or durable service state. | 6 | Linux, launchd on macOS). Foreground CLI processes do not self-update, and a running server never |
| 7 | edits its service definition or durable service state. | ||
| 7 | 8 | ||
| 8 | ## Ownership | 9 | ## Ownership |
| 9 | 10 | ||
| 10 | The service files under `<baseDir>/runtime` are: | 11 | The service files under `<baseDir>/runtime` are: |
| 11 | 12 | ||
| 12 | - `service-launcher.mjs`, the stable process selected by systemd; | 13 | - `service-launcher.mjs`, the stable process selected by the service manager; |
| 13 | - `service-state.json`, the launcher's durable selection state; | 14 | - `service-state.json`, the launcher's durable selection state; |
| 14 | - `versions/<version>`, immutable exact-version npm installs. | 15 | - `versions/<version>`, immutable exact-version npm installs. |
| 15 | 16 | ||
| ... | @@ -22,7 +23,7 @@ The state contains one active version and, at most, one update record: | ... | @@ -22,7 +23,7 @@ The state contains one active version and, at most, one update record: |
| 22 | - `pending A → B` selects B as a retryable trial; | 23 | - `pending A → B` selects B as a retryable trial; |
| 23 | - `committed A → B` selects B for ordinary restarts; | 24 | - `committed A → B` selects B for ordinary restarts; |
| 24 | - `rolled-back A → B` or `failed A → B` selects A; | 25 | - `rolled-back A → B` or `failed A → B` selects A; |
| 25 | - invalid state fails closed so systemd cannot guess at a runtime. | 26 | - invalid state fails closed so the service manager cannot guess at a runtime. |
| 26 | 27 | ||
| 27 | Every write uses same-directory replacement plus file and directory fsync. | 28 | Every write uses same-directory replacement plus file and directory fsync. |
| 28 | 29 | ||
| ... | @@ -50,8 +51,8 @@ operations. It only opens prepared gates and publishes prepared lifecycle state. | ... | @@ -50,8 +51,8 @@ operations. It only opens prepared gates and publishes prepared lifecycle state. |
| 50 | The launcher serializes child exits, IPC messages, and timers. A trial must report prepared within | 51 | The launcher serializes child exits, IPC messages, and timers. A trial must report prepared within |
| 51 | 120 seconds. If the trial exits or times out before prepared, the launcher stops it, restores the | 52 | 120 seconds. If the trial exits or times out before prepared, the launcher stops it, restores the |
| 52 | snapshot, records rollback, and starts A. A durable restore marker makes an interrupted restore | 53 | snapshot, records rollback, and starts A. A durable restore marker makes an interrupted restore |
| 53 | resume before either version can boot. After commit, B is active and normal systemd restart policy | 54 | resume before either version can boot. After commit, B is active and the service manager's normal |
| 54 | applies. | 55 | restart policy applies. |
| 55 | 56 | ||
| 56 | ## Database Rollback | 57 | ## Database Rollback |
| 57 | 58 |
docs/user/background-service.md+35-11| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | # Running T3 Code in the Background | 1 | # Running T3 Code in the Background |
| 2 | 2 | ||
| 3 | On a Linux host, T3 Code can run as a background service for your user. It starts when the machine | 3 | On Linux and macOS, T3 Code can run as a background service for your user, so it is ready without |
| 4 | boots and keeps running after you log out. | 4 | keeping a terminal open. |
| 5 | 5 | ||
| 6 | ## Manage the Service | 6 | ## Manage the Service |
| 7 | 7 | ||
| ... | @@ -32,18 +32,42 @@ npx t3@latest service uninstall | ... | @@ -32,18 +32,42 @@ npx t3@latest service uninstall |
| 32 | Updating restarts T3 Code briefly. Let active agent work and terminal commands finish first. | 32 | Updating restarts T3 Code briefly. Let active agent work and terminal commands finish first. |
| 33 | If a remote update is already in progress, wait for it to finish before retrying a local update. | 33 | If a remote update is already in progress, wait for it to finish before retrying a local update. |
| 34 | 34 | ||
| 35 | The systemd unit runs a small stable launcher. Exact T3 Code versions are installed separately, so | 35 | The service runs a small stable launcher. Exact T3 Code versions are installed separately, so a |
| 36 | a failed remote candidate can return to the previous version without rewriting the unit. The | 36 | failed remote candidate can return to the previous version without rewriting the service |
| 37 | launcher snapshots the database before a remote candidate starts, so database updates roll back | 37 | definition. The launcher snapshots the database before a remote candidate starts, so database |
| 38 | with the server version. An older launcher may require one local `service update` before this is | 38 | updates roll back with the server version. An older launcher may require one local |
| 39 | available. | 39 | `service update` before this is available. |
| 40 | |||
| 41 | ## Platform Support | ||
| 42 | |||
| 43 | **Linux** uses a systemd user unit at `~/.config/systemd/user/t3code.service`. The service starts | ||
| 44 | when the machine boots and keeps running after you log out (lingering is enabled during install). | ||
| 45 | |||
| 46 | **macOS** uses a launch agent at `~/Library/LaunchAgents/com.t3tools.t3code.service.plist`. It | ||
| 47 | starts when you log in, not when the Mac boots, and it stops when you log out; macOS has no | ||
| 48 | equivalent of Linux lingering for user agents. For a Mac that should stay reachable unattended, | ||
| 49 | turn on automatic login (System Settings → Users & Groups; unavailable while FileVault is on) and | ||
| 50 | keep the Mac from sleeping. | ||
| 51 | |||
| 52 | A few more macOS notes: | ||
| 53 | |||
| 54 | - Installing over SSH needs someone logged in at the Mac's screen to start the agent right away. | ||
| 55 | Without that, the install command reports an error at the final start step, but the agent is | ||
| 56 | fully installed and starts at the next login. | ||
| 57 | - macOS may show privacy prompts for protected folders such as Desktop, Documents, or Downloads, | ||
| 58 | attributed to a bare `node` process, or deny access without a prompt. If agent work fails to | ||
| 59 | read those folders, grant Full Disk Access to the node binary listed in the launch agent's | ||
| 60 | `ProgramArguments`. | ||
| 61 | - The agent appears under System Settings → General → Login Items. If it was switched off there, | ||
| 62 | or disabled with `launchctl disable`, macOS will not start it at login until you switch it back | ||
| 63 | on. | ||
| 64 | |||
| 65 | **Windows** is not supported yet. | ||
| 40 | 66 | ||
| 41 | ## Using It with T3 Connect | 67 | ## Using It with T3 Connect |
| 42 | 68 | ||
| 43 | T3 Connect may offer to install the service during setup so the host stays reachable after you log | 69 | T3 Connect may offer to install the service during setup so the host stays reachable in the |
| 44 | out. This is only an onboarding shortcut: the service and T3 Connect are managed separately. | 70 | background. This is only an onboarding shortcut: the service and T3 Connect are managed separately. |
| 45 | 71 | ||
| 46 | Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer | 72 | Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer |
| 47 | want T3 Code to start in the background. | 73 | want T3 Code to start in the background. |
| 48 | |||
| 49 | The background service currently requires Linux with systemd. |
packages/shared/src/hostProcess.ts+8| ... | @@ -51,4 +51,12 @@ export const HostProcessArguments = Context.Reference<ReadonlyArray<string>>( | ... | @@ -51,4 +51,12 @@ export const HostProcessArguments = Context.Reference<ReadonlyArray<string>>( |
| 51 | }, | 51 | }, |
| 52 | ); | 52 | ); |
| 53 | 53 | ||
| 54 | /** Undefined on platforms without POSIX uids (Windows). */ | ||
| 55 | export const HostProcessUserId = Context.Reference<number | undefined>( | ||
| 56 | "@t3tools/shared/hostProcess/HostProcessUserId", | ||
| 57 | { | ||
| 58 | defaultValue: () => process.getuid?.(), | ||
| 59 | }, | ||
| 60 | ); | ||
| 61 | |||
| 54 | export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32"); | 62 | export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32"); |