Unified · Split
| @@ -5,6 +5,7 @@ import { · 4 unmodified lines | ||
| 5 | 5 | type RelayClientInstallProgressStage, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | import { RelayOkResponse } from "@t3tools/contracts/relay"; |
| 8 | +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 8 | 9 | import * as RelayClient from "@t3tools/shared/relayClient"; |
| 9 | 10 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; |
| 10 | 11 | import * as Cause from "effect/Cause"; |
| @@ -695,8 +696,11 @@ export const connectCommand = Command.make("connect", { · 684 unmodified lines | ||
| 695 | 696 | // fail the command, just tell the user what happened and move on. |
| 696 | 697 | const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding); |
| 697 | 698 | if (background) { |
| 699 | + const platform = yield* HostProcessPlatform; | |
| 698 | 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 | 705 | return; |
| 702 | 706 | } |
| @@ -29,9 +29,9 @@ it("gives a direct repair command for a stale service", () => { · 28 unmodified lines | ||
| 29 | 29 | ); |
| 30 | 30 | }); |
| 31 | 31 | |
| 32 | −it("explains service availability without systemd", () => { | |
| 32 | +it("explains where the service is supported", () => { | |
| 33 | 33 | assert.include( |
| 34 | 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 | }); |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 1 | 2 | import * as Console from "effect/Console"; |
| 2 | 3 | import * as Effect from "effect/Effect"; |
| 3 | 4 | import * as Layer from "effect/Layer"; |
| @@ -48,7 +49,7 @@ export function formatServiceStatus( · 44 unmodified lines | ||
| 48 | 49 | cliVersion: string, |
| 49 | 50 | ): string { |
| 50 | 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 | 54 | if (!status.installed) { |
| 54 | 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* () { · 97 unmodified lines | ||
| 152 | 153 | yield* Console.log("T3 Code is already set up to run in the background on this machine."); |
| 153 | 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 | 159 | const wanted = yield* Prompt.run( |
| 156 | 160 | Prompt.confirm({ |
| 157 | 161 | message: installed |
| 158 | 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? " + | |
| 160 | − "It stays reachable through T3 Connect even after you log out.", | |
| 163 | + : platform === "darwin" | |
| 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 | 168 | initial: true, |
| 162 | 169 | }), |
| 163 | 170 | ); |
| @@ -4,8 +4,10 @@ import { · 3 unmodified lines | ||
| 4 | 4 | HostProcessArguments, |
| 5 | 5 | HostProcessExecutablePath, |
| 6 | 6 | HostProcessPlatform, |
| 7 | + HostProcessUserId, | |
| 7 | 8 | } from "@t3tools/shared/hostProcess"; |
| 8 | 9 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 10 | +import * as Duration from "effect/Duration"; | |
| 9 | 11 | import * as Effect from "effect/Effect"; |
| 10 | 12 | import * as FileSystem from "effect/FileSystem"; |
| 11 | 13 | import * as Layer from "effect/Layer"; |
| @@ -47,6 +49,51 @@ it("survives the kernel OOM-killing a greedy agent child", () => { · 35 unmodified lines | ||
| 47 | 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 & <Co></string>"); | |
| 95 | +}); | |
| 96 | + | |
| 50 | 97 | const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 51 | 98 | platform: NodeJS.Platform = "linux", |
| 52 | 99 | usePinnedLauncher = false, |
| @@ -68,12 +115,14 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( · 15 unmodified lines | ||
| 68 | 115 | yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n"); |
| 69 | 116 | |
| 70 | 117 | const commands: string[] = []; |
| 118 | + const timeouts = new Map<string, unknown>(); | |
| 71 | 119 | const control: { failCommand: string | undefined } = { failCommand: undefined }; |
| 72 | 120 | const runner = ProcessRunner.ProcessRunner.of({ |
| 73 | 121 | run: (input) => |
| 74 | 122 | Effect.sync(() => { |
| 75 | 123 | const command = `${input.command} ${input.args.join(" ")}`; |
| 76 | 124 | commands.push(command); |
| 125 | + timeouts.set(command, input.timeout); | |
| 77 | 126 | return { |
| 78 | 127 | stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "", |
| 79 | 128 | stderr: "", |
| @@ -99,19 +148,20 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( · 19 unmodified lines | ||
| 99 | 148 | Effect.provide( |
| 100 | 149 | Layer.mergeAll( |
| 101 | 150 | Layer.succeed(HostProcessPlatform, platform), |
| 151 | + Layer.succeed(HostProcessUserId, 501), | |
| 102 | 152 | Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"), |
| 103 | 153 | Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]), |
| 104 | 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 | 161 | it.layer(NodeServices.layer)("boot service install", (it) => { |
| 112 | 162 | it.effect("installs, reports current state, and uninstalls", () => |
| 113 | 163 | Effect.gen(function* () { |
| 114 | − const { service, fs, statePath, commands } = yield* makeHarness(); | |
| 164 | + const { service, fs, statePath, commands, timeouts } = yield* makeHarness(); | |
| 115 | 165 | const plan = yield* service.install; |
| 116 | 166 | |
| 117 | 167 | expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ |
| @@ -137,6 +187,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { · 19 unmodified lines | ||
| 137 | 187 | expect(yield* service.uninstall).toBe(true); |
| 138 | 188 | expect((yield* service.status).installed).toBe(false); |
| 139 | 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) => { · 52 unmodified lines | ||
| 195 | 250 | }), |
| 196 | 251 | ); |
| 197 | 252 | |
| 198 | − it.effect("fails closed off Linux", () => | |
| 253 | + it.effect("fails closed on Windows", () => | |
| 199 | 254 | Effect.gen(function* () { |
| 200 | − const { service } = yield* makeHarness("darwin"); | |
| 255 | + const { service } = yield* makeHarness("win32"); | |
| 201 | 256 | expect((yield* service.status).supported).toBe(false); |
| 202 | 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 | }); |
| @@ -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 | 6 | import * as Config from "effect/Config"; |
| 3 | 7 | import * as Context from "effect/Context"; |
| 4 | 8 | import * as DateTime from "effect/DateTime"; |
| @@ -27,6 +31,10 @@ import { · 22 unmodified lines | ||
| 27 | 31 | |
| 28 | 32 | const BOOT_SERVICE_NAME = "t3code"; |
| 29 | 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 | 38 | export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; |
| 31 | 39 | |
| 32 | 40 | /** systemd expands `%` specifiers, including in unquoted append-log paths. */ |
| @@ -83,12 +91,280 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { · 50 unmodified lines | ||
| 83 | 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("&", "&").replaceAll("<", "<").replaceAll(">", ">"); | |
| 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 | 362 | export class BootServiceUnsupportedError extends Schema.TaggedErrorClass<BootServiceUnsupportedError>()( |
| 87 | 363 | "BootServiceUnsupportedError", |
| 88 | 364 | { platform: Schema.String }, |
| 89 | 365 | ) { |
| 90 | 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: { · 68 unmodified lines | ||
| 163 | 439 | }) { |
| 164 | 440 | const hostExecPath = yield* HostProcessExecutablePath; |
| 165 | 441 | const platform = yield* HostProcessPlatform; |
| 442 | + const uid = yield* HostProcessUserId; | |
| 166 | 443 | const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); |
| 167 | 444 | const fs = yield* FileSystem.FileSystem; |
| 168 | 445 | const path = yield* Path.Path; |
| 169 | 446 | const runner = yield* ProcessRunner.ProcessRunner; |
| 170 | 447 | const host = input.host ?? { execPath: hostExecPath }; |
| 171 | 448 | |
| 172 | − const unitDir = path.join(homeDir, ".config", "systemd", "user"); | |
| 173 | − const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE); | |
| 449 | + const detectedManager = selectBootServiceManager({ platform, homeDir, uid, path }); | |
| 450 | + const unitPath = detectedManager?.unitPath ?? ""; | |
| 174 | 451 | const logPath = path.join(input.logsDir, "boot-service.log"); |
| 175 | 452 | const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); |
| 176 | 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: { · 21 unmodified lines | ||
| 198 | 475 | unitPath, |
| 199 | 476 | }; |
| 200 | 477 | |
| 201 | − const requireSystemdLinux = Effect.gen(function* () { | |
| 202 | − if (platform !== "linux" || homeDir === "") { | |
| 203 | − return yield* new BootServiceUnsupportedError({ platform }); | |
| 204 | − } | |
| 205 | − }); | |
| 478 | + const requireManager = Effect.suspend(() => | |
| 479 | + detectedManager === undefined | |
| 480 | + ? new BootServiceUnsupportedError({ platform }) | |
| 481 | + : Effect.succeed(detectedManager), | |
| 482 | + ); | |
| 206 | 483 | |
| 207 | 484 | const runStep = Effect.fn("cloud.boot_service.run_step")(function* ( |
| 208 | 485 | step: string, |
| @@ -235,8 +512,25 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 26 unmodified lines | ||
| 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 | 532 | const install: BootService["Service"]["install"] = Effect.gen(function* () { |
| 239 | − yield* requireSystemdLinux; | |
| 533 | + const manager = yield* requireManager; | |
| 240 | 534 | yield* fs |
| 241 | 535 | .makeDirectory(input.logsDir, { recursive: true }) |
| 242 | 536 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); |
| @@ -298,11 +592,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 55 unmodified lines | ||
| 298 | 592 | .exists(unitPath) |
| 299 | 593 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); |
| 300 | 594 | if (installed) { |
| 301 | − yield* runStep("stopping the installed service", "systemctl", [ | |
| 302 | − "--user", | |
| 303 | − "stop", | |
| 304 | − BOOT_SERVICE_UNIT_FILE, | |
| 305 | − ]); | |
| 595 | + yield* runSteps(manager.stop); | |
| 306 | 596 | } |
| 307 | 597 | |
| 308 | 598 | yield* Effect.gen(function* () { |
| @@ -316,7 +606,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 7 unmodified lines | ||
| 316 | 606 | } |
| 317 | 607 | } |
| 318 | 608 | yield* fs |
| 319 | − .makeDirectory(unitDir, { recursive: true }) | |
| 609 | + .makeDirectory(path.dirname(unitPath), { recursive: true }) | |
| 320 | 610 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); |
| 321 | 611 | yield* writeDurably(launcherPath, launcherSource); |
| 322 | 612 | yield* writeDurably( |
| @@ -331,58 +621,35 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 8 unmodified lines | ||
| 331 | 621 | 2, |
| 332 | 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"]); | |
| 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 | − ]); | |
| 626 | + yield* runSteps(manager.activate); | |
| 349 | 627 | }).pipe( |
| 350 | 628 | Effect.tapError(() => |
| 351 | − installed | |
| 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, | |
| 629 | + installed ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void, | |
| 358 | 630 | ), |
| 359 | 631 | ); |
| 360 | 632 | return plan; |
| 361 | 633 | }).pipe(Effect.withSpan("cloud.boot_service.install")); |
| 362 | 634 | |
| 363 | 635 | const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { |
| 364 | − yield* requireSystemdLinux; | |
| 636 | + const manager = yield* requireManager; | |
| 365 | 637 | if ( |
| 366 | 638 | !(yield* fs |
| 367 | 639 | .exists(unitPath) |
| 368 | 640 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })))) |
| 369 | 641 | ) |
| 370 | 642 | return false; |
| 371 | − yield* runStep("stopping the service", "systemctl", [ | |
| 372 | − "--user", | |
| 373 | − "disable", | |
| 374 | − "--now", | |
| 375 | − BOOT_SERVICE_UNIT_FILE, | |
| 376 | − ]); | |
| 643 | + yield* runSteps(manager.deactivate); | |
| 377 | 644 | yield* fs |
| 378 | 645 | .remove(unitPath) |
| 379 | 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 | 648 | return true; |
| 382 | 649 | }).pipe(Effect.withSpan("cloud.boot_service.uninstall")); |
| 383 | 650 | |
| 384 | 651 | const status: BootService["Service"]["status"] = Effect.gen(function* () { |
| 385 | − if (platform !== "linux" || homeDir === "") { | |
| 652 | + if (detectedManager === undefined) { | |
| 386 | 653 | return { supported: false, installed: false, current: false, unitPath, logPath }; |
| 387 | 654 | } |
| 388 | 655 | if (!(yield* fs.exists(unitPath))) { |
| @@ -401,7 +668,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 12 unmodified lines | ||
| 401 | 668 | supported: true, |
| 402 | 669 | installed: true, |
| 403 | 670 | current: |
| 404 | − unit === renderBootServiceUnit(plan) && | |
| 671 | + unit === detectedManager.render(plan) && | |
| 405 | 672 | launcherExists && |
| 406 | 673 | runtimeEntryExists && |
| 407 | 674 | Option.isSome(runtimeSentinel) && |
| @@ -649,7 +649,8 @@ export const pendingServiceUpdateExists = Effect.gen(function* () { · 648 unmodified lines | ||
| 649 | 649 | }); |
| 650 | 650 | |
| 651 | 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 | 654 | // the pending window also tears this server down. The launcher marks that case |
| 654 | 655 | // just before it signals the child, so pending + no marker is the handoff. |
| 655 | 656 | const pendingUpdateHandoffExists = Effect.gen(function* () { |
| @@ -10,8 +10,8 @@ import * as ProcessRunner from "../processRunner.ts"; · 9 unmodified lines | ||
| 10 | 10 | |
| 11 | 11 | /** |
| 12 | 12 | * A pinned runtime is an exact `t3@<version>` npm-installed into |
| 13 | − * <baseDir>/runtime/versions/<version>. The boot service points its systemd | |
| 14 | − * unit here, and server self-update installs the target version here before | |
| 13 | + * <baseDir>/runtime/versions/<version>. The boot service points its unit or | |
| 14 | + * launch agent here, and server self-update installs the target version here before | |
| 15 | 15 | * switching over, never `npx t3`, whose cache is ephemeral and whose |
| 16 | 16 | * registry fetch at boot would make startup depend on the network. |
| 17 | 17 | */ |
| @@ -317,7 +317,9 @@ export class Launcher { · 316 unmodified lines | ||
| 317 | 317 | // This must happen synchronously at signal receipt. A queued update |
| 318 | 318 | // transition may already be terminating the active child, and that child |
| 319 | 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 | 323 | try { |
| 322 | 324 | NodeFS.writeFileSync(stopMarkerPath(this.#baseDir), "", { mode: 0o600 }); |
| 323 | 325 | } catch { |
| @@ -2,14 +2,15 @@ · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 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, | |
| 6 | −and a running server never edits its systemd unit or durable service state. | |
| 5 | +Remote server updates use one stable launcher selected by the platform service manager (systemd on | |
| 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 | 9 | ## Ownership |
| 9 | 10 | |
| 10 | 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 | 14 | - `service-state.json`, the launcher's durable selection state; |
| 14 | 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: · 6 unmodified lines | ||
| 22 | 23 | - `pending A → B` selects B as a retryable trial; |
| 23 | 24 | - `committed A → B` selects B for ordinary restarts; |
| 24 | 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 | 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. · 21 unmodified lines | ||
| 50 | 51 | The launcher serializes child exits, IPC messages, and timers. A trial must report prepared within |
| 51 | 52 | 120 seconds. If the trial exits or times out before prepared, the launcher stops it, restores the |
| 52 | 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 | −applies. | |
| 54 | +resume before either version can boot. After commit, B is active and the service manager's normal | |
| 55 | +restart policy applies. | |
| 55 | 56 | |
| 56 | 57 | ## Database Rollback |
| 57 | 58 | |
| @@ -1,7 +1,7 @@ | ||
| 1 | 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 | |
| 4 | −boots and keeps running after you log out. | |
| 3 | +On Linux and macOS, T3 Code can run as a background service for your user, so it is ready without | |
| 4 | +keeping a terminal open. | |
| 5 | 5 | |
| 6 | 6 | ## Manage the Service |
| 7 | 7 | |
| @@ -32,18 +32,42 @@ npx t3@latest service uninstall · 24 unmodified lines | ||
| 32 | 32 | Updating restarts T3 Code briefly. Let active agent work and terminal commands finish first. |
| 33 | 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 | |
| 36 | −a failed remote candidate can return to the previous version without rewriting the unit. The | |
| 37 | −launcher snapshots the database before a remote candidate starts, so database updates roll back | |
| 38 | −with the server version. An older launcher may require one local `service update` before this is | |
| 39 | −available. | |
| 35 | +The service runs a small stable launcher. Exact T3 Code versions are installed separately, so a | |
| 36 | +failed remote candidate can return to the previous version without rewriting the service | |
| 37 | +definition. The launcher snapshots the database before a remote candidate starts, so database | |
| 38 | +updates roll back with the server version. An older launcher may require one local | |
| 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 | 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 | |
| 44 | −out. This is only an onboarding shortcut: the service and T3 Connect are managed separately. | |
| 69 | +T3 Connect may offer to install the service during setup so the host stays reachable in the | |
| 70 | +background. This is only an onboarding shortcut: the service and T3 Connect are managed separately. | |
| 45 | 71 | |
| 46 | 72 | Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer |
| 47 | 73 | want T3 Code to start in the background. |
| 48 | − | |
| 49 | −The background service currently requires Linux with systemd. | |
| @@ -51,4 +51,12 @@ export const HostProcessArguments = Context.Reference<ReadonlyArray<string>>( · 50 unmodified lines | ||
| 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 | 62 | export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32"); |