authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-13 23:22:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 23:22:18-07:00
logd8655ed2fcf5cac3e1742459476b4a392b949367
treebe3d52b16823d4cf916045708d644663548cf564
parentcba7dd77817df2a458d86977f1daf4606c67dfc8
signature Signed by PGP key B5690EEEBB952194

feat(cli): add t3 service restart and make t3 update repoint the service eagerly (#11702)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

9 files changed, 430 insertions(+), 61 deletions(-)

apps/server/src/cli/service.test.ts+38-10
......@@ -45,7 +45,7 @@ it("reports the installed service version and host paths", () => {
4545it("gives a direct repair command for a stale service", () => {
4646 assert.include(
4747 formatServiceStatus({ ...status, current: false }, "0.0.29"),
48 "Next: Run `npx t3@0.0.29 service update`.",
48 "Next: Run `t3 service install` to repair it.",
4949 );
5050});
5151
......@@ -64,17 +64,17 @@ it("explains an incomplete nightly installation and keeps repair on its installe
6464 expect(output).toContain("last login session ends");
6565 expect(output).toContain('sudo loginctl enable-linger "$(id -un)"');
6666 expect(output).toContain("[service-stopped]");
67 expect(output).toContain("npx t3@0.0.32-nightly.1 service update");
68 expect(output).not.toContain("t3@latest");
67 expect(output).toContain("Run `t3 service install` to repair it.");
68 expect(output).not.toContain("npx");
6969});
7070
71it("suggests the newer CLI version when the installed service needs an update", () => {
71it("points an older service at a repair, never at npx", () => {
7272 const output = formatServiceStatus(
7373 { ...status, current: false, installedVersion: "0.0.28" },
7474 "0.0.29",
7575 );
76 expect(output).toContain("npx t3@0.0.29 service update");
77 expect(output).not.toContain("npx t3@0.0.28 service update");
76 expect(output).toContain("Run `t3 service install` to repair it.");
77 expect(output).not.toContain("npx");
7878});
7979
8080it("explains where the service is supported", () => {
......@@ -84,23 +84,28 @@ it("explains where the service is supported", () => {
8484 );
8585});
8686
87it("reports a newer installed service and gives an exact-version repair command", () => {
87it("reports a newer installed service and tells the CLI to catch up to it", () => {
8888 const output = formatServiceStatus(
8989 { ...status, current: false, installedVersion: "0.0.32-nightly.1" },
9090 "0.0.31",
9191 );
9292
9393 assert.include(output, "t3@0.0.32-nightly.1 (newer than this t3@0.0.31 CLI)");
94 assert.include(output, "npx t3@0.0.32-nightly.1 service update");
95 assert.notInclude(output, "npx t3@latest service update");
94 assert.include(output, "Run `t3 update 0.0.32-nightly.1` to match it");
95 assert.notInclude(output, "npx");
9696});
9797
9898const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" };
9999
100100function makeTestService(serviceStatus: BootService.BootServiceStatus) {
101101 const installOptions: Array<Parameters<BootService.BootService["Service"]["install"]>[0]> = [];
102 const restarts: Array<true> = [];
102103 const service = BootService.BootService.of({
103104 status: Effect.succeed(serviceStatus),
105 restart: Effect.sync(() => {
106 restarts.push(true);
107 return serviceStatus.installed;
108 }),
104109 install: (options) =>
105110 Effect.sync(() => {
106111 installOptions.push(options);
......@@ -113,10 +118,33 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) {
113118 }),
114119 uninstall: Effect.succeed(false),
115120 });
116 return { service, installOptions };
121 return { service, installOptions, restarts };
117122}
118123
119124it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands", (it) => {
125 it.effect("restart restarts the installed service", () =>
126 Effect.gen(function* () {
127 const fs = yield* FileSystem.FileSystem;
128 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" });
129 const { service, installOptions, restarts } = makeTestService(status);
130 vi.spyOn(BootService, "layer").mockReturnValue(
131 Layer.succeed(BootService.BootService, service),
132 );
133
134 yield* Command.runWith(serviceCommand, { version: packageJson.version })([
135 "restart",
136 "--base-dir",
137 baseDir,
138 ]).pipe(
139 Effect.provideService(HostProcessEnvironment, {}),
140 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))),
141 );
142
143 expect(restarts).toEqual([true]);
144 expect(installOptions).toEqual([]);
145 }),
146 );
147
120148 it.effect.each(["install", "update"] as const)(
121149 "%s refuses a downgrade before changing the service",
122150 (command) =>
apps/server/src/cli/service.ts+33-6
......@@ -38,6 +38,7 @@ export type ServiceReconcileResult =
3838/** Install, update, or repair the service using the CLI version running this command. */
3939export const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: {
4040 readonly allowDowngrade?: boolean;
41 readonly start?: boolean;
4142}) {
4243 const service = yield* BootService.BootService;
4344 const status = yield* service.status;
......@@ -87,7 +88,7 @@ export function formatServiceStatus(
8788 ` Unit: ${status.unitPath}`,
8889 ` Logs: ${status.logPath}`,
8990 ...problems,
90 ` Next: Use \`npx t3@${installedVersion} service update\` to repair it, or pass \`--allow-downgrade\` explicitly.`,
91 ` Next: Run \`t3 update ${installedVersion}\` to match it, or pass \`--allow-downgrade\` to \`t3 service install\` explicitly.`,
9192 ].join("\n");
9293 }
9394 return [
......@@ -96,7 +97,7 @@ export function formatServiceStatus(
9697 ` Unit: ${status.unitPath}`,
9798 ` Logs: ${status.logPath}`,
9899 ...problems,
99 ...(status.current ? [] : [` Next: Run \`npx t3@${cliVersion} service update\`.`]),
100 ...(status.current ? [] : [" Next: Run `t3 service install` to repair it."]),
100101 ].join("\n");
101102}
102103
......@@ -138,14 +139,18 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip
138139 ),
139140);
140141
142// Kept one release for muscle memory and old docs. It did what `t3 service
143// install` does; the way to move to a newer release is `t3 update`.
141144const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe(
142 Command.withDescription(
143 "Update or repair the background service using this CLI version. Use `t3 update` to move to a newer release first.",
144 ),
145 Command.withDescription("Deprecated. Run `t3 update` to move to a newer release."),
146 Command.unlisted,
145147 Command.withHandler((flags) =>
146148 runServiceCommand(
147149 flags,
148150 Effect.gen(function* () {
151 yield* Console.log(
152 "`t3 service update` is deprecated: run `t3 update` to move to a newer release, or `t3 service install` to repair the service. Repairing now.",
153 );
149154 const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });
150155 if (!result.changed) {
151156 yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`);
......@@ -159,6 +164,27 @@ const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe(
159164 ),
160165);
161166
167const serviceRestartCommand = Command.make("restart", projectLocationFlags).pipe(
168 Command.withDescription(
169 "Restart the background service. Picks up a version installed by `t3 update` that was not restarted at the time.",
170 ),
171 Command.withHandler((flags) =>
172 runServiceCommand(
173 flags,
174 Effect.gen(function* () {
175 const service = yield* BootService.BootService;
176 const status = yield* service.status;
177 const restarted = yield* service.restart;
178 yield* Console.log(
179 restarted
180 ? `Restarted the T3 Code service${status.installedVersion === undefined ? "" : ` on t3@${status.installedVersion}`}.`
181 : "T3 Code service is not installed.",
182 );
183 }),
184 ),
185 ),
186);
187
162188const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).pipe(
163189 Command.withDescription("Stop and remove the T3 Code background service."),
164190 Command.withHandler((flags) =>
......@@ -265,8 +291,9 @@ export const serviceCommand = Command.make("service").pipe(
265291 Command.withDescription("Manage the T3 Code background service."),
266292 Command.withSubcommands([
267293 serviceInstallCommand,
294 serviceRestartCommand,
268295 serviceUninstallCommand,
269 serviceUpdateCommand,
270296 serviceStatusCommand,
297 serviceUpdateCommand,
271298 ]),
272299);
apps/server/src/cli/update.ts+47-29
......@@ -263,6 +263,7 @@ export const updateCommand = Command.make("update", {
263263 const config = yield* resolveCliAuthConfig(flags, logLevel);
264264 return yield* runUpdate({
265265 baseDir: config.baseDir,
266 logsDir: config.logsDir,
266267 serverRuntimeStatePath: config.serverRuntimeStatePath,
267268 channel: Option.getOrUndefined(flags.channel),
268269 requestedVersion: Option.getOrUndefined(flags.version),
......@@ -333,6 +334,7 @@ const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(fun
333334
334335const runUpdate = Effect.fn("cli.update.run")(function* (input: {
335336 readonly baseDir: string;
337 readonly logsDir: string;
336338 readonly serverRuntimeStatePath: string;
337339 readonly channel: CliReleaseChannel | undefined;
338340 readonly requestedVersion: string | undefined;
......@@ -408,8 +410,16 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
408410 const serviceVersion = serviceInstalled ? status.installedVersion : undefined;
409411 const executableCurrent = targetVersion === currentVersion;
410412 // A service whose recorded version is missing or unreadable is not known
411 // to be current, so it gets the update rather than being skipped.
412 const serviceCurrent = !serviceInstalled || serviceVersion === targetVersion;
413 // to be current, so it gets the update rather than being skipped. Nor is
414 // one on the right version that is stopped, disabled, or still running the
415 // version before it (an earlier update where the restart was declined):
416 // `status.current` covers all of that when the target is this executable,
417 // and the problem list is what can be judged for any other target.
418 const restartPending = status.problems?.includes("restart-pending") === true;
419 const serviceCurrent =
420 !serviceInstalled ||
421 (serviceVersion === targetVersion &&
422 (executableCurrent ? status.current : (status.problems ?? []).length === 0));
413423 const newestInstalled =
414424 serviceVersion !== undefined && compareExactServiceVersions(serviceVersion, currentVersion) > 0
415425 ? serviceVersion
......@@ -437,11 +447,13 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
437447 );
438448
439449 yield* Console.log(
440 executableCurrent
441 ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).`
442 : alreadyOnDisk
443 ? `Switching t3 ${currentVersion} -> ${targetVersion} (${targetChannel}, already downloaded).`
444 : `Updating t3 ${currentVersion} -> ${targetVersion} (${targetChannel}).`,
450 executableCurrent && restartPending
451 ? `The background service is still running the version before ${targetVersion} (${targetChannel}).`
452 : executableCurrent
453 ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).`
454 : alreadyOnDisk
455 ? `Switching t3 ${currentVersion} -> ${targetVersion} (${targetChannel}, already downloaded).`
456 : `Updating t3 ${currentVersion} -> ${targetVersion} (${targetChannel}).`,
445457 );
446458 let restartService = false;
447459 if (serviceInstalled && !serviceCurrent) {
......@@ -459,7 +471,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
459471 ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false)));
460472 } else {
461473 yield* Console.log(
462 " Not a terminal, so the service is left on its current version. Rerun with --yes to restart it, or run `t3 service update` later.",
474 " Not a terminal, so the service keeps running its current version. Rerun with --yes to restart it now, or run `t3 service restart` later.",
463475 );
464476 }
465477 }
......@@ -519,27 +531,33 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
519531 targetEntryPath: runtime.entryPath,
520532 });
521533
522 // The new executable owns the service switch: it verifies itself, writes its
523 // own version into the unit, and restarts the service on it.
534 // The service switch runs in this process against the target version: the
535 // downloaded runtime has already proven it runs (the `--version` check
536 // above), and doing it here rather than through the target's own CLI means
537 // a downgrade to a version without today's commands still works. The unit
538 // is rewritten either way so a later `t3 service restart` lands on the new
539 // version; only the restart itself waits for the user's answer.
524540 let serviceUpdated = false;
525 if (restartService) {
526 const result = yield* runner.run({
527 command: runtime.entryPath,
528 args: [
529 "service",
530 "update",
531 "--base-dir",
532 input.baseDir,
533 ...(input.allowDowngrade ? ["--allow-downgrade"] : []),
534 ],
535 timeout: Duration.minutes(5),
536 });
537 if (result.code !== 0) {
538 return yield* new CliUpdateError({
539 reason: `t3@${targetVersion} is installed but the background service could not be updated (exit ${String(result.code)}).\n${result.stderr.trim() || result.stdout.trim()}`,
540 });
541 }
542 serviceUpdated = true;
541 if (serviceInstalled && !serviceCurrent) {
542 yield* BootService.BootService.pipe(
543 Effect.flatMap((target) =>
544 target.install({ allowDowngrade: input.allowDowngrade, start: restartService }),
545 ),
546 Effect.provide(
547 BootService.layer({
548 baseDir: input.baseDir,
549 logsDir: input.logsDir,
550 cliVersion: targetVersion,
551 }),
552 ),
553 Effect.mapError(
554 (error) =>
555 new CliUpdateError({
556 reason: `t3@${targetVersion} is installed but the background service could not be ${restartService ? "updated" : "pointed at it"}: ${error.message}`,
557 }),
558 ),
559 );
560 serviceUpdated = restartService;
543561 }
544562
545563 yield* Console.log("");
......@@ -555,7 +573,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
555573 yield* Console.log(` Background service already on ${targetVersion}`);
556574 } else if (serviceInstalled) {
557575 yield* Console.log(
558 ` Background service still running ${serviceVersion ?? "an unknown version"}. Run \`t3 service update\` when you are ready to restart it.`,
576 ` Background service still running ${serviceVersion ?? "an unknown version"}. Run \`t3 service restart\` when you are ready to switch it to ${targetVersion}.`,
559577 );
560578 } else if (status.installed && !servesThisHome) {
561579 yield* Console.log(
apps/server/src/cloud/bootService.test.ts+161-8
......@@ -20,6 +20,7 @@ import { pinnedRuntimePaths } from "./pinnedRuntime.ts";
2020import {
2121 parseServiceState,
2222 SERVICE_LAUNCHER_PROTOCOL,
23 SERVICE_RESTART_PENDING_FILE,
2324 serviceStateHasPendingUpdate,
2425} from "./serviceProtocol.ts";
2526
......@@ -181,7 +182,9 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
181182 return {
182183 stdout:
183184 input.args[0] === "--version"
184 ? "t3 v1.2.3\n"
185 ? // The runtime under test reports the version of the directory it
186 // was launched from, like the real executable.
187 `t3 v${/versions\/([^/]+)\//.exec(input.command)?.[1] ?? "1.2.3"}\n`
185188 : input.command === "loginctl" && input.args[0] === "show-user"
186189 ? `${control.linger}\n`
187190 : input.args[1] === "is-enabled"
......@@ -201,12 +204,24 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
201204 };
202205 }),
203206 });
204 const makeService = (environmentPath = installerPath) =>
205 BootService.make({
206 baseDir,
207 logsDir: path.join(baseDir, "userdata", "logs"),
208 cliVersion: "1.2.3",
209 host: { execPath: "/usr/bin/t3" },
207 const makeService = (
208 environmentPath: string | undefined = installerPath,
209 cliVersion = "1.2.3",
210 serviceBaseDir = baseDir,
211 ) =>
212 Effect.gen(function* () {
213 // Every version the tests install is present and verified on disk, so
214 // install never downloads.
215 const paths = pinnedRuntimePaths(path, serviceBaseDir, cliVersion, platform);
216 yield* fs.makeDirectory(path.dirname(paths.entryPath), { recursive: true });
217 yield* fs.writeFileString(paths.entryPath, "#!/bin/sh\n");
218 yield* fs.writeFileString(paths.sentinelPath, `${cliVersion}\n`);
219 return yield* BootService.make({
220 baseDir: serviceBaseDir,
221 logsDir: path.join(serviceBaseDir, "userdata", "logs"),
222 cliVersion,
223 host: { execPath: "/usr/bin/t3" },
224 });
210225 }).pipe(
211226 Effect.provideService(ProcessRunner.ProcessRunner, runner),
212227 Effect.provide(
......@@ -220,7 +235,12 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
220235 ),
221236 ConfigProvider.layer(
222237 ConfigProvider.fromEnv({
223 env: { HOME: home, ...(environmentPath === "" ? {} : { PATH: environmentPath }) },
238 env: {
239 HOME: home,
240 ...(environmentPath === undefined || environmentPath === ""
241 ? {}
242 : { PATH: environmentPath }),
243 },
224244 }),
225245 ),
226246 ),
......@@ -469,6 +489,139 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
469489 }),
470490 );
471491
492 it.effect("install with start=false rewrites the files and marks a restart pending", () =>
493 Effect.gen(function* () {
494 const { service, fs, statePath, commands, makeService } = yield* makeHarness();
495 yield* service.install();
496 commands.length = 0;
497
498 const newer = yield* makeService(undefined, "1.2.4");
499 const plan = yield* newer.install({ start: false });
500
501 expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
502 protocol: SERVICE_LAUNCHER_PROTOCOL,
503 activeVersion: "1.2.4",
504 });
505 expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.4/t3");
506 expect(
507 commands.filter(
508 (command) => command.startsWith("systemctl ") && !command.includes("show-environment"),
509 ),
510 ).toEqual([]);
511 // The files say 1.2.4 but the process is still 1.2.3: not current, and
512 // the reason is named so `t3 service status` can point at restart.
513 const status = yield* newer.status;
514 expect(status.current).toBe(false);
515 expect(status.problems).toContain("restart-pending");
516
517 commands.length = 0;
518 expect(yield* newer.restart).toBe(true);
519 expect((yield* newer.status).problems).not.toContain("restart-pending");
520 expect((yield* newer.status).current).toBe(true);
521 }),
522 );
523
524 it.effect("install with start=false keeps the marker when a later write fails", () =>
525 Effect.gen(function* () {
526 const { service, fs, statePath, makeService } = yield* makeHarness();
527 const path = yield* Path.Path;
528 yield* service.install();
529 const newer = yield* makeService(undefined, "1.2.4");
530 // A non-empty directory in the unit's place: it still counts as an
531 // installed unit, and the rename that writes the new unit fails.
532 const unitPath = (yield* service.status).unitPath;
533 yield* fs.remove(unitPath);
534 yield* fs.makeDirectory(unitPath);
535 yield* fs.writeFileString(path.join(unitPath, "occupied"), "");
536
537 const error = yield* newer.install({ start: false }).pipe(Effect.flip);
538 expect(error._tag).toBe("BootServiceInstallError");
539 expect(
540 yield* fs.exists(path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE)),
541 ).toBe(true);
542 }),
543 );
544
545 it.effect("install with start=false refuses while a remote update is pending", () =>
546 Effect.gen(function* () {
547 const { service, fs, statePath } = yield* makeHarness();
548 yield* service.install();
549 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document.
550 const pendingState = JSON.stringify({
551 protocol: SERVICE_LAUNCHER_PROTOCOL,
552 activeVersion: "1.2.3",
553 update: {
554 id: "u",
555 fromVersion: "1.2.3",
556 targetVersion: "1.2.4",
557 dbPath: "/tmp/state.sqlite",
558 status: "pending",
559 },
560 });
561 yield* fs.writeFileString(statePath, pendingState);
562
563 const error = yield* service.install({ start: false }).pipe(Effect.flip);
564 expect(error._tag).toBe("BootServiceUpdatePendingError");
565 expect(yield* fs.readFileString(statePath)).toBe(pendingState);
566 }),
567 );
568
569 it.effect("restart stops and starts an installed service, and is a no-op otherwise", () =>
570 Effect.gen(function* () {
571 const { service, commands } = yield* makeHarness();
572 expect(yield* service.restart).toBe(false);
573 yield* service.install();
574 commands.length = 0;
575
576 expect(yield* service.restart).toBe(true);
577 expect(
578 commands.filter(
579 (command) => command.startsWith("systemctl ") && !command.includes("show-environment"),
580 ),
581 ).toEqual([
582 "systemctl --user stop t3code.service",
583 "systemctl --user daemon-reload",
584 "systemctl --user enable t3code.service",
585 "systemctl --user restart t3code.service",
586 ]);
587 }),
588 );
589
590 it.effect("restart leaves a service that serves another T3 home alone", () =>
591 Effect.gen(function* () {
592 const { service, fs, commands, makeService } = yield* makeHarness();
593 yield* service.install();
594 commands.length = 0;
595 const path = yield* Path.Path;
596 const otherHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-other-home-" });
597
598 const other = yield* makeService(undefined, "1.2.3", path.join(otherHome, ".t3"));
599 expect(yield* other.restart).toBe(false);
600 expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([]);
601 }),
602 );
603
604 it.effect("restart brings the service back when activation fails", () =>
605 Effect.gen(function* () {
606 const { service, commands, control } = yield* makeHarness();
607 yield* service.install();
608 commands.length = 0;
609 control.failCommand = "systemctl --user daemon-reload";
610
611 const error = yield* service.restart.pipe(Effect.flip);
612 expect(error._tag).toBe("BootServiceCommandError");
613 expect(
614 commands.filter(
615 (command) => command.startsWith("systemctl ") && !command.includes("show-environment"),
616 ),
617 ).toEqual([
618 "systemctl --user stop t3code.service",
619 "systemctl --user daemon-reload",
620 "systemctl --user restart t3code.service",
621 ]);
622 }),
623 );
624
472625 it.effect("restarts an installed service when repair fails", () =>
473626 Effect.gen(function* () {
474627 const { service, commands, control } = yield* makeHarness();
apps/server/src/cloud/bootService.ts+87-7
......@@ -27,6 +27,7 @@ import {
2727} from "./pinnedRuntime.ts";
2828import {
2929 SERVICE_LAUNCHER_PROTOCOL,
30 SERVICE_RESTART_PENDING_FILE,
3031 SERVICE_STATE_FILE,
3132 compareExactServiceVersions,
3233 parseServiceState,
......@@ -444,6 +445,7 @@ const BootServiceProblem = Schema.Literals([
444445 "linger-disabled",
445446 "service-disabled",
446447 "service-stopped",
448 "restart-pending",
447449]);
448450type BootServiceProblem = typeof BootServiceProblem.Type;
449451
......@@ -457,9 +459,11 @@ export function formatBootServiceProblem(problem: BootServiceProblem): string {
457459 case "linger-disabled":
458460 return 'Lingering is disabled. T3 Code will stop when your last login session ends and will not start at boot. Run `sudo loginctl enable-linger "$(id -un)"` on this machine, then retry the service command as your normal user.';
459461 case "service-disabled":
460 return "The service is not enabled to start automatically. Run `t3 service update` to repair it.";
462 return "The service is not enabled to start automatically. Run `t3 service install` to repair it.";
461463 case "service-stopped":
462 return "The service is not running. Check the service log and `systemctl --user status t3code.service`, then run `t3 service update`.";
464 return "The service is not running. Check the service log and `systemctl --user status t3code.service`, then run `t3 service install`.";
465 case "restart-pending":
466 return "A newer version is installed but the service is still running the previous one. Run `t3 service restart` to switch.";
463467 }
464468}
465469
......@@ -523,7 +527,20 @@ export class BootService extends Context.Service<
523527 {
524528 readonly install: (options?: {
525529 readonly allowDowngrade?: boolean;
530 /**
531 * Write the unit for this version but leave the service on whatever it
532 * is running now. `t3 update` uses this when the user declines the
533 * restart, so a later `t3 service restart` lands on the new version.
534 */
535 readonly start?: boolean;
526536 }) => Effect.Effect<BootServicePlan, BootServiceError>;
537 /**
538 * Stop and start the installed service on the version its unit names.
539 * Only when the unit serves this base dir: the unit name is per user, so
540 * another home's service is left alone. Resolves false when nothing was
541 * restarted.
542 */
543 readonly restart: Effect.Effect<boolean, BootServiceError>;
527544 readonly uninstall: Effect.Effect<boolean, BootServiceError>;
528545 readonly status: Effect.Effect<BootServiceStatus, BootServiceError>;
529546 }
......@@ -584,6 +601,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
584601 const unitPath = detectedManager?.unitPath ?? "";
585602 const logPath = path.join(input.logsDir, "boot-service.log");
586603 const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE);
604 const restartPendingPath = path.join(input.baseDir, "runtime", SERVICE_RESTART_PENDING_FILE);
587605 const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform);
588606 const writeDurably = (filePath: string, contents: string) =>
589607 Effect.scoped(
......@@ -728,6 +746,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
728746
729747 const install = Effect.fn("cloud.boot_service.install")(function* (options?: {
730748 readonly allowDowngrade?: boolean;
749 readonly start?: boolean;
731750 }) {
732751 const manager = yield* requireManager;
733752 yield* fs
......@@ -795,7 +814,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
795814 const installed = yield* fs
796815 .exists(unitPath)
797816 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
798 if (installed) {
817 // With start=false the service keeps running while its files change. The
818 // launcher reads the state file once at startup and the unit only matters
819 // on the next start, so that is safe as long as the launcher is not in
820 // the middle of a remote update, which is the one time it writes the
821 // state file itself. That case is refused below, before anything is
822 // written, from the same read the downgrade check uses; the stop that
823 // normally serialises against the launcher is skipped on purpose.
824 const start = options?.start !== false;
825 if (installed && start) {
799826 yield* runSteps(manager.stop);
800827 }
801828
......@@ -824,6 +851,13 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
824851 yield* fs
825852 .makeDirectory(path.dirname(unitPath), { recursive: true })
826853 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
854 if (!start && installed) {
855 // Written first: once the files below name the new version, the
856 // running service is behind them, and a failure between the two
857 // writes must not leave it looking current. The launcher removes the
858 // marker when it starts, `restart` and a started install do too.
859 yield* fs.writeFileString(restartPendingPath, `${input.cliVersion}\n`, { mode: 0o600 });
860 }
827861 yield* writeDurably(
828862 statePath,
829863 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document.
......@@ -836,17 +870,61 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
836870 2,
837871 )}\n`,
838872 );
873 if (!start && installed) {
874 // The launcher only writes this file while a remote update is in
875 // flight. One that began after the check above lands either before
876 // this write (then the launcher's copy in memory is what it keeps
877 // acting on, and its next write puts its own outcome back) or after
878 // it, which this read catches: the file no longer says what was just
879 // written, so stop here before repointing the unit.
880 const written = yield* fs.readFileString(statePath);
881 if (serviceStateActiveVersion(written) !== input.cliVersion) {
882 return yield* new BootServiceUpdatePendingError();
883 }
884 }
839885 yield* writeDurably(unitPath, manager.render(plan));
840886
841 yield* runSteps(manager.activate);
887 if (start) {
888 yield* runSteps(manager.activate);
889 yield* fs.remove(restartPendingPath, { force: true });
890 }
842891 }).pipe(
892 Effect.mapError((cause) =>
893 cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause,
894 ),
843895 Effect.tapError(() =>
844 installed ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void,
896 installed && start ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void,
845897 ),
846898 );
847899 return plan;
848900 });
849901
902 const restart: BootService["Service"]["restart"] = Effect.gen(function* () {
903 const manager = yield* requireManager;
904 const unit = yield* fs.readFileString(unitPath).pipe(Effect.option);
905 if (Option.isNone(unit)) return false;
906 const installedBaseDir = bootServiceBaseDirOf(unit.value);
907 if (
908 installedBaseDir === undefined ||
909 path.resolve(installedBaseDir) !== path.resolve(input.baseDir)
910 ) {
911 return false;
912 }
913 yield* runSteps(manager.stop);
914 yield* runSteps(manager.activate).pipe(
915 // Same recovery as a failed repair: a service that was running should
916 // not be left stopped because daemon-reload or enable failed.
917 Effect.tapError(() => runSteps(manager.restart).pipe(Effect.ignore)),
918 );
919 yield* fs.remove(restartPendingPath, { force: true });
920 return true;
921 }).pipe(
922 Effect.mapError((cause) =>
923 cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause,
924 ),
925 Effect.withSpan("cloud.boot_service.restart"),
926 );
927
850928 const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () {
851929 const manager = yield* requireManager;
852930 if (
......@@ -885,7 +963,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
885963 detectedManager.kind === "launchd"
886964 ? contents.replace(/(<key>PATH<\/key>\n\s*<string>)[^<]*(<\/string>)/, "$1$2")
887965 : contents;
888 const problems = detectedManager.kind === "systemd" ? yield* readSystemdProblems(true) : [];
966 const problems: BootServiceProblem[] =
967 detectedManager.kind === "systemd" ? [...(yield* readSystemdProblems(true))] : [];
968 if (yield* fs.exists(restartPendingPath)) problems.push("restart-pending");
889969 return {
890970 supported: true,
891971 installed: true,
......@@ -908,7 +988,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
908988 Effect.withSpan("cloud.boot_service.status"),
909989 );
910990
911 return BootService.of({ install, uninstall, status });
991 return BootService.of({ install, restart, uninstall, status });
912992});
913993
914994export const layer = (input: {
apps/server/src/cloud/serviceProtocol.ts+5
......@@ -8,6 +8,11 @@ export const SERVICE_STATE_FILE = "service-state.json";
88 the child can tell "the service is going away" from "the launcher is about
99 to start my replacement" while a pending update is recorded. */
1010export const SERVICE_STOP_MARKER_FILE = ".service-stopping";
11/** Written by `t3 update` when the unit was repointed at a new version but the
12 running service was deliberately left on the old one. The launcher removes
13 it when it starts (whoever restarted the service), so while it exists the
14 service is known to be behind its unit and status reports it that way. */
15export const SERVICE_RESTART_PENDING_FILE = ".restart-pending";
1116
1217export interface PendingServiceUpdate {
1318 readonly id: string;
apps/server/src/serviceLauncher.test.ts+45
......@@ -10,6 +10,7 @@ import {
1010 decodeServiceState,
1111 isExactServiceVersion,
1212 SERVICE_LAUNCHER_PROTOCOL,
13 SERVICE_RESTART_PENDING_FILE,
1314 SERVICE_STOP_MARKER_FILE,
1415} from "./cloud/serviceProtocol.ts";
1516
......@@ -113,6 +114,50 @@ it.layer(NodeServices.layer)("service state persistence", (it) => {
113114 }),
114115 );
115116
117 it.effect("a fresh launcher clears a restart deferred by t3 update", () =>
118 Effect.gen(function* () {
119 const fs = yield* FileSystem.FileSystem;
120 const path = yield* Path.Path;
121 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-restart-" });
122 const statePath = path.join(root, "runtime", "service-state.json");
123 const restartPending = path.join(root, "runtime", SERVICE_RESTART_PENDING_FILE);
124 yield* writeFakeRuntime(
125 fs,
126 path,
127 path.join(root, "runtime", "versions", "1.0.0"),
128 "setInterval(() => {}, 1_000);\n",
129 );
130 yield* Effect.promise(() =>
131 writeServiceState(statePath, {
132 protocol: SERVICE_LAUNCHER_PROTOCOL,
133 activeVersion: "1.0.0",
134 }),
135 );
136 const run = () =>
137 Effect.gen(function* () {
138 const launcher = new Launcher(
139 root,
140 yield* Effect.promise(() => readServiceState(statePath)),
141 );
142 const running = launcher.run();
143 yield* Effect.promise(() => launcher.stop("SIGTERM"));
144 yield* Effect.promise(() => running);
145 });
146
147 // A launcher that is still the old version leaves a marker that waits
148 // for a newer one.
149 yield* fs.writeFileString(restartPending, "1.0.1\n");
150 yield* run();
151 assert.isTrue(yield* fs.exists(restartPending));
152
153 // Whoever restarted the service, the launcher now runs what the unit
154 // names, so the deferred-restart marker is gone.
155 yield* fs.writeFileString(restartPending, "1.0.0\n");
156 yield* run();
157 assert.isFalse(yield* fs.exists(restartPending));
158 }),
159 );
160
116161 it.effect("serializes shutdown with launcher recovery", () =>
117162 Effect.gen(function* () {
118163 const fs = yield* FileSystem.FileSystem;
apps/server/src/serviceLauncher.ts+13-1
......@@ -26,6 +26,7 @@ import {
2626 SERVICE_LAUNCHER_CONTEXT_ENV,
2727 SERVICE_LAUNCHER_PROTOCOL,
2828 SERVICE_STATE_FILE,
29 SERVICE_RESTART_PENDING_FILE,
2930 SERVICE_STOP_MARKER_FILE,
3031} from "./cloud/serviceProtocol.ts";
3132
......@@ -273,6 +274,8 @@ async function terminateChild(
273274
274275const stopMarkerPath = (baseDir: string) =>
275276 NodePath.join(baseDir, "runtime", SERVICE_STOP_MARKER_FILE);
277const restartPendingPath = (baseDir: string) =>
278 NodePath.join(baseDir, "runtime", SERVICE_RESTART_PENDING_FILE);
276279
277280export class Launcher {
278281 readonly #baseDir: string;
......@@ -365,8 +368,17 @@ export class Launcher {
365368 async #recover(): Promise<void> {
366369 // A fresh launcher means servers are running again: any stop marker from
367370 // a previous explicit stop is stale and must not make a future update
368 // handoff release its tunnel.
371 // handoff release its tunnel. A restart deferred by `t3 update` is done
372 // no matter who restarted the service, but only once this launcher is
373 // the version the marker waits for: a launcher that came up between the
374 // CLI writing the marker and writing the new state still runs the old
375 // version, and the marker has to outlive it.
369376 await NodeFSP.rm(stopMarkerPath(this.#baseDir), { force: true }).catch(() => undefined);
377 const restartPending = restartPendingPath(this.#baseDir);
378 const awaitedVersion = await NodeFSP.readFile(restartPending, "utf8").catch(() => undefined);
379 if (awaitedVersion?.trim() === this.#state.activeVersion) {
380 await NodeFSP.rm(restartPending, { force: true }).catch(() => undefined);
381 }
370382 const update = this.#state.update;
371383 if (update?.status !== "pending") {
372384 if (update !== undefined) {
docs/user/background-service.md+1
......@@ -110,6 +110,7 @@ that session open.
110110| `linger-unavailable` | Run `loginctl show-user "$(id -un)" --property=Linger` and check that systemd-logind is available. |
111111| `user-manager-unavailable` | Run `systemctl --user status` in a login session for the service user; check your distribution's systemd user-session support. |
112112| `service-disabled` or `service-stopped` | Read the log and `systemctl --user status t3code.service`, then use the repair command printed by T3 Code. |
113| `restart-pending` | A newer version is installed but the service still runs the previous one. Run `t3 service restart`. |
113114
114115On macOS, check **System Settings → General → Login Items** if the service no
115116longer starts at login. If agent work cannot access Desktop, Documents, or