| 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 | 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", { |
| 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 | } |
apps/server/src/cli/service.test.ts+2-2| ... | ... | @@ -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 | 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 | }); |
apps/server/src/cli/service.ts+10-3| ... | ... | @@ -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( |
| 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* () { |
| 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 | ); |
apps/server/src/cloud/bootService.test.ts+143-4| ... | ... | @@ -4,8 +4,10 @@ import { |
| 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", () => { |
| 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 &amp; &lt;Co&gt;</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* ( |
| 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* ( |
| 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) => { |
| 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) => { |
| 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 | }); |
apps/server/src/cloud/bootService.ts+314-47| ... | ... | @@ -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 { |
| 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 { |
| 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("&", "&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 | 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: { |
| 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: { |
| 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: { |
| 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: { |
| 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: { |
| 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: { |
| 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: { |
| 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) && |
apps/server/src/cloud/http.ts+2-1| ... | ... | @@ -649,7 +649,8 @@ export const pendingServiceUpdateExists = Effect.gen(function* () { |
| 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* () { |
apps/server/src/cloud/pinnedRuntime.ts+2-2| ... | ... | @@ -10,8 +10,8 @@ import * as ProcessRunner from "../processRunner.ts"; |
| 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 | */ |
apps/server/src/serviceLauncher.ts+3-1| ... | ... | @@ -317,7 +317,9 @@ export class Launcher { |
| 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 { |
docs/internals/server-updates.md+7-6| ... | ... | @@ -2,14 +2,15 @@ |
| 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: |
| 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. |
| 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 |
docs/user/background-service.md+35-11| ... | ... | @@ -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 |
| 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. |
packages/shared/src/hostProcess.ts+8| ... | ... | @@ -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 | 62 | export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32"); |