authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-19 04:04:51-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-19 01:04:51-07:00
log2aa5f095fc3bb65c00cc4efce66a5473e2d4554a
tree5312f6b584510b0b5996b706a840481b3ea79f39
parent7441b36926eb111a193183f98c06aced4f4c3acf
signature Signed by PGP key B5690EEEBB952194

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

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 {
55 type RelayClientInstallProgressStage,
66} from "@t3tools/contracts";
77import { RelayOkResponse } from "@t3tools/contracts/relay";
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
89import * as RelayClient from "@t3tools/shared/relayClient";
910import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
1011import * as Cause from "effect/Cause";
......@@ -695,8 +696,11 @@ export const connectCommand = Command.make("connect", {
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", () => {
2929 );
3030});
3131
32it("explains service availability without systemd", () => {
32it("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 @@
1import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
12import * as Console from "effect/Console";
23import * as Effect from "effect/Effect";
34import * as Layer from "effect/Layer";
......@@ -48,7 +49,7 @@ export function formatServiceStatus(
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* () {
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 {
44 HostProcessArguments,
55 HostProcessExecutablePath,
66 HostProcessPlatform,
7 HostProcessUserId,
78} from "@t3tools/shared/hostProcess";
89import * as ConfigProvider from "effect/ConfigProvider";
10import * as Duration from "effect/Duration";
911import * as Effect from "effect/Effect";
1012import * as FileSystem from "effect/FileSystem";
1113import * as Layer from "effect/Layer";
......@@ -47,6 +49,51 @@ it("survives the kernel OOM-killing a greedy agent child", () => {
4749 expect(unit).toContain("OOMPolicy=continue");
4850});
4951
52const 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
60it("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
68it("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
77it("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
88it("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
5097const 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* (
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* (
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
111161it.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) => {
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) => {
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 @@
1import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";
1import {
2 HostProcessExecutablePath,
3 HostProcessPlatform,
4 HostProcessUserId,
5} from "@t3tools/shared/hostProcess";
26import * as Config from "effect/Config";
37import * as Context from "effect/Context";
48import * as DateTime from "effect/DateTime";
......@@ -27,6 +31,10 @@ import {
2731
2832const BOOT_SERVICE_NAME = "t3code";
2933export 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.
36export const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service";
37export const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`;
3038export 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 {
8391 ].join("\n");
8492}
8593
94/** Plist values are emitted as XML text nodes; only these three need escaping. */
95export 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. */
100export 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
157export 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 */
177const 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 */
184export 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
200export 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
267export 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. */
344export 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
86362export 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: {
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: {
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: {
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: {
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: {
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: {
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: {
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* () {
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.
655656const pendingUpdateHandoffExists = Effect.gen(function* () {
apps/server/src/cloud/pinnedRuntime.ts+2-2
......@@ -10,8 +10,8 @@ import * as ProcessRunner from "../processRunner.ts";
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 {
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 @@
22
33> For maintainers. Using T3 Code? See [docs/user](../user/).
44
5Remote server updates use one stable systemd launcher. Foreground CLI processes do not self-update,
6and a running server never edits its systemd unit or durable service state.
5Remote server updates use one stable launcher selected by the platform service manager (systemd on
6Linux, launchd on macOS). Foreground CLI processes do not self-update, and a running server never
7edits its service definition or durable service state.
78
89## Ownership
910
1011The 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:
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
2728Every 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.
5051The launcher serializes child exits, IPC messages, and timers. A trial must report prepared within
5152120 seconds. If the trial exits or times out before prepared, the launcher stops it, restores the
5253snapshot, records rollback, and starts A. A durable restore marker makes an interrupted restore
53resume before either version can boot. After commit, B is active and normal systemd restart policy
54applies.
54resume before either version can boot. After commit, B is active and the service manager's normal
55restart 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
3On a Linux host, T3 Code can run as a background service for your user. It starts when the machine
4boots and keeps running after you log out.
3On Linux and macOS, T3 Code can run as a background service for your user, so it is ready without
4keeping a terminal open.
55
66## Manage the Service
77
......@@ -32,18 +32,42 @@ npx t3@latest service uninstall
3232Updating restarts T3 Code briefly. Let active agent work and terminal commands finish first.
3333If a remote update is already in progress, wait for it to finish before retrying a local update.
3434
35The systemd unit runs a small stable launcher. Exact T3 Code versions are installed separately, so
36a failed remote candidate can return to the previous version without rewriting the unit. The
37launcher snapshots the database before a remote candidate starts, so database updates roll back
38with the server version. An older launcher may require one local `service update` before this is
39available.
35The service runs a small stable launcher. Exact T3 Code versions are installed separately, so a
36failed remote candidate can return to the previous version without rewriting the service
37definition. The launcher snapshots the database before a remote candidate starts, so database
38updates 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
44when 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
47starts when you log in, not when the Mac boots, and it stops when you log out; macOS has no
48equivalent of Linux lingering for user agents. For a Mac that should stay reachable unattended,
49turn on automatic login (System Settings → Users & Groups; unavailable while FileVault is on) and
50keep the Mac from sleeping.
51
52A 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
43T3 Connect may offer to install the service during setup so the host stays reachable after you log
44out. This is only an onboarding shortcut: the service and T3 Connect are managed separately.
69T3 Connect may offer to install the service during setup so the host stays reachable in the
70background. This is only an onboarding shortcut: the service and T3 Connect are managed separately.
4571
4672Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer
4773want T3 Code to start in the background.
48
49The background service currently requires Linux with systemd.
packages/shared/src/hostProcess.ts+8
......@@ -51,4 +51,12 @@ export const HostProcessArguments = Context.Reference<ReadonlyArray<string>>(
5151 },
5252);
5353
54/** Undefined on platforms without POSIX uids (Windows). */
55export const HostProcessUserId = Context.Reference<number | undefined>(
56 "@t3tools/shared/hostProcess/HostProcessUserId",
57 {
58 defaultValue: () => process.getuid?.(),
59 },
60);
61
5462export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32");