History

feat(server): run the background service on macOS via launchd (#6286)

Theo Browne committed 2 months ago

Commit 2aa5f091 parent 7441b3611 files · +531 −78Browse files

Unified · Split

11 files
  1. apps/server/src/cli/connect.ts +5 −1
  2. apps/server/src/cli/service.test.ts +2 −2
  3. apps/server/src/cli/service.ts +10 −3
  4. apps/server/src/cloud/bootService.test.ts +143 −4
  5. apps/server/src/cloud/bootService.ts +314 −47
  6. apps/server/src/cloud/http.ts +2 −1
  7. apps/server/src/cloud/pinnedRuntime.ts +2 −2
  8. apps/server/src/serviceLauncher.ts +3 −1
  9. docs/internals/server-updates.md +7 −6
  10. docs/user/background-service.md +35 −11
  11. packages/shared/src/hostProcess.ts +8 −0
apps/server/src/cli/connect.ts+5−1
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
55 type RelayClientInstallProgressStage,
66 } from "@t3tools/contracts";
77 import { RelayOkResponse } from "@t3tools/contracts/relay";
8+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
89 import * as RelayClient from "@t3tools/shared/relayClient";
910 import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
1011 import * as Cause from "effect/Cause";
@@ -695,8 +696,11 @@ export const connectCommand = Command.make("connect", { · 684 unmodified lines
695696 // fail the command, just tell the user what happened and move on.
696697 const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding);
697698 if (background) {
699+ const platform = yield* HostProcessPlatform;
698700 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.",
700704 );
701705 return;
702706 }
apps/server/src/cli/service.test.ts+2−2
@@ -29,9 +29,9 @@ it("gives a direct repair command for a stale service", () => { · 28 unmodified lines
2929 );
3030 });
3131
32−it("explains service availability without systemd", () => {
32+it("explains where the service is supported", () => {
3333 assert.include(
3434 formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"),
35− "Supported on: Linux with systemd",
35+ "Supported on: Linux with systemd, macOS with launchd",
3636 );
3737 });
apps/server/src/cli/service.ts+10−3
@@ -1,3 +1,4 @@
1+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
12 import * as Console from "effect/Console";
23 import * as Effect from "effect/Effect";
34 import * as Layer from "effect/Layer";
@@ -48,7 +49,7 @@ export function formatServiceStatus( · 44 unmodified lines
4849 cliVersion: string,
4950 ): string {
5051 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";
5253 }
5354 if (!status.installed) {
5455 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
152153 yield* Console.log("T3 Code is already set up to run in the background on this machine.");
153154 return true;
154155 }
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;
155159 const wanted = yield* Prompt.run(
156160 Prompt.confirm({
157161 message: installed
158162 ? "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.",
161168 initial: true,
162169 }),
163170 );
apps/server/src/cloud/bootService.test.ts+143−4
@@ -4,8 +4,10 @@ import { · 3 unmodified lines
44 HostProcessArguments,
55 HostProcessExecutablePath,
66 HostProcessPlatform,
7+ HostProcessUserId,
78 } from "@t3tools/shared/hostProcess";
89 import * as ConfigProvider from "effect/ConfigProvider";
10+import * as Duration from "effect/Duration";
911 import * as Effect from "effect/Effect";
1012 import * as FileSystem from "effect/FileSystem";
1113 import * as Layer from "effect/Layer";
@@ -47,6 +49,51 @@ it("survives the kernel OOM-killing a greedy agent child", () => { · 35 unmodified lines
4749 expect(unit).toContain("OOMPolicy=continue");
4850 });
4951
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+
5097 const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
5198 platform: NodeJS.Platform = "linux",
5299 usePinnedLauncher = false,
@@ -68,12 +115,14 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( · 15 unmodified lines
68115 yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");
69116
70117 const commands: string[] = [];
118+ const timeouts = new Map<string, unknown>();
71119 const control: { failCommand: string | undefined } = { failCommand: undefined };
72120 const runner = ProcessRunner.ProcessRunner.of({
73121 run: (input) =>
74122 Effect.sync(() => {
75123 const command = `${input.command} ${input.args.join(" ")}`;
76124 commands.push(command);
125+ timeouts.set(command, input.timeout);
77126 return {
78127 stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "",
79128 stderr: "",
@@ -99,19 +148,20 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( · 19 unmodified lines
99148 Effect.provide(
100149 Layer.mergeAll(
101150 Layer.succeed(HostProcessPlatform, platform),
151+ Layer.succeed(HostProcessUserId, 501),
102152 Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"),
103153 Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]),
104154 ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })),
105155 ),
106156 ),
107157 );
108− return { service, fs, statePath, commands, control };
158+ return { service, fs, statePath, commands, timeouts, control };
109159 });
110160
111161 it.layer(NodeServices.layer)("boot service install", (it) => {
112162 it.effect("installs, reports current state, and uninstalls", () =>
113163 Effect.gen(function* () {
114− const { service, fs, statePath, commands } = yield* makeHarness();
164+ const { service, fs, statePath, commands, timeouts } = yield* makeHarness();
115165 const plan = yield* service.install;
116166
117167 expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
@@ -137,6 +187,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { · 19 unmodified lines
137187 expect(yield* service.uninstall).toBe(true);
138188 expect((yield* service.status).installed).toBe(false);
139189 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+ );
140195 }),
141196 );
142197
@@ -195,11 +250,95 @@ it.layer(NodeServices.layer)("boot service install", (it) => { · 52 unmodified lines
195250 }),
196251 );
197252
198− it.effect("fails closed off Linux", () =>
253+ it.effect("fails closed on Windows", () =>
199254 Effect.gen(function* () {
200− const { service } = yield* makeHarness("darwin");
255+ const { service } = yield* makeHarness("win32");
201256 expect((yield* service.status).supported).toBe(false);
202257 expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUnsupportedError");
203258 }),
204259 );
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+ );
205344 });
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";
26 import * as Config from "effect/Config";
37 import * as Context from "effect/Context";
48 import * as DateTime from "effect/DateTime";
@@ -27,6 +31,10 @@ import { · 22 unmodified lines
2731
2832 const BOOT_SERVICE_NAME = "t3code";
2933 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`;
3038 export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT";
3139
3240 /** systemd expands `%` specifiers, including in unquoted append-log paths. */
@@ -83,12 +91,280 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { · 50 unmodified lines
8391 ].join("\n");
8492 }
8593
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+
86362 export class BootServiceUnsupportedError extends Schema.TaggedErrorClass<BootServiceUnsupportedError>()(
87363 "BootServiceUnsupportedError",
88364 { platform: Schema.String },
89365 ) {
90366 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}'.`;
92368 }
93369 }
94370
@@ -163,14 +439,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 68 unmodified lines
163439 }) {
164440 const hostExecPath = yield* HostProcessExecutablePath;
165441 const platform = yield* HostProcessPlatform;
442+ const uid = yield* HostProcessUserId;
166443 const homeDir = yield* Config.string("HOME").pipe(Config.withDefault(""));
167444 const fs = yield* FileSystem.FileSystem;
168445 const path = yield* Path.Path;
169446 const runner = yield* ProcessRunner.ProcessRunner;
170447 const host = input.host ?? { execPath: hostExecPath };
171448
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 ?? "";
174451 const logPath = path.join(input.logsDir, "boot-service.log");
175452 const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE);
176453 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
198475 unitPath,
199476 };
200477
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+ );
206483
207484 const runStep = Effect.fn("cloud.boot_service.run_step")(function* (
208485 step: string,
@@ -235,8 +512,25 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 26 unmodified lines
235512 );
236513 });
237514
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+
238532 const install: BootService["Service"]["install"] = Effect.gen(function* () {
239− yield* requireSystemdLinux;
533+ const manager = yield* requireManager;
240534 yield* fs
241535 .makeDirectory(input.logsDir, { recursive: true })
242536 .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
298592 .exists(unitPath)
299593 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
300594 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);
306596 }
307597
308598 yield* Effect.gen(function* () {
@@ -316,7 +606,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 7 unmodified lines
316606 }
317607 }
318608 yield* fs
319− .makeDirectory(unitDir, { recursive: true })
609+ .makeDirectory(path.dirname(unitPath), { recursive: true })
320610 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
321611 yield* writeDurably(launcherPath, launcherSource);
322612 yield* writeDurably(
@@ -331,58 +621,35 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 8 unmodified lines
331621 2,
332622 )}\n`,
333623 );
334− yield* writeDurably(unitPath, renderBootServiceUnit(plan));
624+ yield* writeDurably(unitPath, manager.render(plan));
335625
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);
349627 }).pipe(
350628 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,
358630 ),
359631 );
360632 return plan;
361633 }).pipe(Effect.withSpan("cloud.boot_service.install"));
362634
363635 const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () {
364− yield* requireSystemdLinux;
636+ const manager = yield* requireManager;
365637 if (
366638 !(yield* fs
367639 .exists(unitPath)
368640 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))))
369641 )
370642 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);
377644 yield* fs
378645 .remove(unitPath)
379646 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
380− yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]);
647+ yield* runSteps(manager.finalize);
381648 return true;
382649 }).pipe(Effect.withSpan("cloud.boot_service.uninstall"));
383650
384651 const status: BootService["Service"]["status"] = Effect.gen(function* () {
385− if (platform !== "linux" || homeDir === "") {
652+ if (detectedManager === undefined) {
386653 return { supported: false, installed: false, current: false, unitPath, logPath };
387654 }
388655 if (!(yield* fs.exists(unitPath))) {
@@ -401,7 +668,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 12 unmodified lines
401668 supported: true,
402669 installed: true,
403670 current:
404− unit === renderBootServiceUnit(plan) &&
671+ unit === detectedManager.render(plan) &&
405672 launcherExists &&
406673 runtimeEntryExists &&
407674 Option.isSome(runtimeSentinel) &&
apps/server/src/cloud/http.ts+2−1
@@ -649,7 +649,8 @@ export const pendingServiceUpdateExists = Effect.gen(function* () { · 648 unmodified lines
649649 });
650650
651651 // 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
653654 // the pending window also tears this server down. The launcher marks that case
654655 // just before it signals the child, so pending + no marker is the handoff.
655656 const pendingUpdateHandoffExists = Effect.gen(function* () {
apps/server/src/cloud/pinnedRuntime.ts+2−2
@@ -10,8 +10,8 @@ import * as ProcessRunner from "../processRunner.ts"; · 9 unmodified lines
1010
1111 /**
1212 * 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
1515 * switching over, never `npx t3`, whose cache is ephemeral and whose
1616 * registry fetch at boot would make startup depend on the network.
1717 */
apps/server/src/serviceLauncher.ts+3−1
@@ -317,7 +317,9 @@ export class Launcher { · 316 unmodified lines
317317 // This must happen synchronously at signal receipt. A queued update
318318 // transition may already be terminating the active child, and that child
319319 // 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.
321323 try {
322324 NodeFS.writeFileSync(stopMarkerPath(this.#baseDir), "", { mode: 0o600 });
323325 } catch {
docs/internals/server-updates.md+7−6
@@ -2,14 +2,15 @@ · 1 unmodified line
22
33 > For maintainers. Using T3 Code? See [docs/user](../user/).
44
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.
78
89 ## Ownership
910
1011 The service files under `<baseDir>/runtime` are:
1112
12−- `service-launcher.mjs`, the stable process selected by systemd;
13+- `service-launcher.mjs`, the stable process selected by the service manager;
1314 - `service-state.json`, the launcher's durable selection state;
1415 - `versions/<version>`, immutable exact-version npm installs.
1516
@@ -22,7 +23,7 @@ The state contains one active version and, at most, one update record: · 6 unmodified lines
2223 - `pending A → B` selects B as a retryable trial;
2324 - `committed A → B` selects B for ordinary restarts;
2425 - `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.
2627
2728 Every write uses same-directory replacement plus file and directory fsync.
2829
@@ -50,8 +51,8 @@ operations. It only opens prepared gates and publishes prepared lifecycle state. · 21 unmodified lines
5051 The launcher serializes child exits, IPC messages, and timers. A trial must report prepared within
5152 120 seconds. If the trial exits or times out before prepared, the launcher stops it, restores the
5253 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.
5556
5657 ## Database Rollback
5758
docs/user/background-service.md+35−11
@@ -1,7 +1,7 @@
11 # Running T3 Code in the Background
22
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.
55
66 ## Manage the Service
77
@@ -32,18 +32,42 @@ npx t3@latest service uninstall · 24 unmodified lines
3232 Updating restarts T3 Code briefly. Let active agent work and terminal commands finish first.
3333 If a remote update is already in progress, wait for it to finish before retrying a local update.
3434
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.
4066
4167 ## Using It with T3 Connect
4268
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.
4571
4672 Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer
4773 want T3 Code to start in the background.
48−
49−The background service currently requires Linux with systemd.
packages/shared/src/hostProcess.ts+8−0
@@ -51,4 +51,12 @@ export const HostProcessArguments = Context.Reference<ReadonlyArray<string>>( · 50 unmodified lines
5151 },
5252 );
5353
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+
5462 export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32");