diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 50bdb1f5fbd3..74c2489e7b12 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -45,7 +45,7 @@ it("reports the installed service version and host paths", () => { it("gives a direct repair command for a stale service", () => { assert.include( formatServiceStatus({ ...status, current: false }, "0.0.29"), - "Next: Run `npx t3@0.0.29 service update`.", + "Next: Run `t3 service install` to repair it.", ); }); @@ -64,17 +64,17 @@ it("explains an incomplete nightly installation and keeps repair on its installe expect(output).toContain("last login session ends"); expect(output).toContain('sudo loginctl enable-linger "$(id -un)"'); expect(output).toContain("[service-stopped]"); - expect(output).toContain("npx t3@0.0.32-nightly.1 service update"); - expect(output).not.toContain("t3@latest"); + expect(output).toContain("Run `t3 service install` to repair it."); + expect(output).not.toContain("npx"); }); -it("suggests the newer CLI version when the installed service needs an update", () => { +it("points an older service at a repair, never at npx", () => { const output = formatServiceStatus( { ...status, current: false, installedVersion: "0.0.28" }, "0.0.29", ); - expect(output).toContain("npx t3@0.0.29 service update"); - expect(output).not.toContain("npx t3@0.0.28 service update"); + expect(output).toContain("Run `t3 service install` to repair it."); + expect(output).not.toContain("npx"); }); it("explains where the service is supported", () => { @@ -84,23 +84,28 @@ it("explains where the service is supported", () => { ); }); -it("reports a newer installed service and gives an exact-version repair command", () => { +it("reports a newer installed service and tells the CLI to catch up to it", () => { const output = formatServiceStatus( { ...status, current: false, installedVersion: "0.0.32-nightly.1" }, "0.0.31", ); assert.include(output, "t3@0.0.32-nightly.1 (newer than this t3@0.0.31 CLI)"); - assert.include(output, "npx t3@0.0.32-nightly.1 service update"); - assert.notInclude(output, "npx t3@latest service update"); + assert.include(output, "Run `t3 update 0.0.32-nightly.1` to match it"); + assert.notInclude(output, "npx"); }); const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" }; function makeTestService(serviceStatus: BootService.BootServiceStatus) { const installOptions: Array[0]> = []; + const restarts: Array = []; const service = BootService.BootService.of({ status: Effect.succeed(serviceStatus), + restart: Effect.sync(() => { + restarts.push(true); + return serviceStatus.installed; + }), install: (options) => Effect.sync(() => { installOptions.push(options); @@ -113,10 +118,33 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) { }), uninstall: Effect.succeed(false), }); - return { service, installOptions }; + return { service, installOptions, restarts }; } it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands", (it) => { + it.effect("restart restarts the installed service", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); + const { service, installOptions, restarts } = makeTestService(status); + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed(BootService.BootService, service), + ); + + yield* Command.runWith(serviceCommand, { version: packageJson.version })([ + "restart", + "--base-dir", + baseDir, + ]).pipe( + Effect.provideService(HostProcessEnvironment, {}), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), + ); + + expect(restarts).toEqual([true]); + expect(installOptions).toEqual([]); + }), + ); + it.effect.each(["install", "update"] as const)( "%s refuses a downgrade before changing the service", (command) => diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 0de1ce75799c..580938de69d0 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -38,6 +38,7 @@ export type ServiceReconcileResult = /** Install, update, or repair the service using the CLI version running this command. */ export const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: { readonly allowDowngrade?: boolean; + readonly start?: boolean; }) { const service = yield* BootService.BootService; const status = yield* service.status; @@ -87,7 +88,7 @@ export function formatServiceStatus( ` Unit: ${status.unitPath}`, ` Logs: ${status.logPath}`, ...problems, - ` Next: Use \`npx t3@${installedVersion} service update\` to repair it, or pass \`--allow-downgrade\` explicitly.`, + ` Next: Run \`t3 update ${installedVersion}\` to match it, or pass \`--allow-downgrade\` to \`t3 service install\` explicitly.`, ].join("\n"); } return [ @@ -96,7 +97,7 @@ export function formatServiceStatus( ` Unit: ${status.unitPath}`, ` Logs: ${status.logPath}`, ...problems, - ...(status.current ? [] : [` Next: Run \`npx t3@${cliVersion} service update\`.`]), + ...(status.current ? [] : [" Next: Run `t3 service install` to repair it."]), ].join("\n"); } @@ -138,14 +139,18 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip ), ); +// Kept one release for muscle memory and old docs. It did what `t3 service +// install` does; the way to move to a newer release is `t3 update`. const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( - Command.withDescription( - "Update or repair the background service using this CLI version. Use `t3 update` to move to a newer release first.", - ), + Command.withDescription("Deprecated. Run `t3 update` to move to a newer release."), + Command.unlisted, Command.withHandler((flags) => runServiceCommand( flags, Effect.gen(function* () { + yield* Console.log( + "`t3 service update` is deprecated: run `t3 update` to move to a newer release, or `t3 service install` to repair the service. Repairing now.", + ); const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); if (!result.changed) { yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`); @@ -159,6 +164,27 @@ const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( ), ); +const serviceRestartCommand = Command.make("restart", projectLocationFlags).pipe( + Command.withDescription( + "Restart the background service. Picks up a version installed by `t3 update` that was not restarted at the time.", + ), + Command.withHandler((flags) => + runServiceCommand( + flags, + Effect.gen(function* () { + const service = yield* BootService.BootService; + const status = yield* service.status; + const restarted = yield* service.restart; + yield* Console.log( + restarted + ? `Restarted the T3 Code service${status.installedVersion === undefined ? "" : ` on t3@${status.installedVersion}`}.` + : "T3 Code service is not installed.", + ); + }), + ), + ), +); + const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).pipe( Command.withDescription("Stop and remove the T3 Code background service."), Command.withHandler((flags) => @@ -265,8 +291,9 @@ export const serviceCommand = Command.make("service").pipe( Command.withDescription("Manage the T3 Code background service."), Command.withSubcommands([ serviceInstallCommand, + serviceRestartCommand, serviceUninstallCommand, - serviceUpdateCommand, serviceStatusCommand, + serviceUpdateCommand, ]), ); diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 9edbc30d96b0..950910c403f1 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -263,6 +263,7 @@ export const updateCommand = Command.make("update", { const config = yield* resolveCliAuthConfig(flags, logLevel); return yield* runUpdate({ baseDir: config.baseDir, + logsDir: config.logsDir, serverRuntimeStatePath: config.serverRuntimeStatePath, channel: Option.getOrUndefined(flags.channel), requestedVersion: Option.getOrUndefined(flags.version), @@ -333,6 +334,7 @@ const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(fun const runUpdate = Effect.fn("cli.update.run")(function* (input: { readonly baseDir: string; + readonly logsDir: string; readonly serverRuntimeStatePath: string; readonly channel: CliReleaseChannel | undefined; readonly requestedVersion: string | undefined; @@ -408,8 +410,16 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { const serviceVersion = serviceInstalled ? status.installedVersion : undefined; const executableCurrent = targetVersion === currentVersion; // A service whose recorded version is missing or unreadable is not known - // to be current, so it gets the update rather than being skipped. - const serviceCurrent = !serviceInstalled || serviceVersion === targetVersion; + // to be current, so it gets the update rather than being skipped. Nor is + // one on the right version that is stopped, disabled, or still running the + // version before it (an earlier update where the restart was declined): + // `status.current` covers all of that when the target is this executable, + // and the problem list is what can be judged for any other target. + const restartPending = status.problems?.includes("restart-pending") === true; + const serviceCurrent = + !serviceInstalled || + (serviceVersion === targetVersion && + (executableCurrent ? status.current : (status.problems ?? []).length === 0)); const newestInstalled = serviceVersion !== undefined && compareExactServiceVersions(serviceVersion, currentVersion) > 0 ? serviceVersion @@ -437,11 +447,13 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { ); yield* Console.log( - executableCurrent - ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).` - : alreadyOnDisk - ? `Switching t3 ${currentVersion} -> ${targetVersion} (${targetChannel}, already downloaded).` - : `Updating t3 ${currentVersion} -> ${targetVersion} (${targetChannel}).`, + executableCurrent && restartPending + ? `The background service is still running the version before ${targetVersion} (${targetChannel}).` + : executableCurrent + ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).` + : alreadyOnDisk + ? `Switching t3 ${currentVersion} -> ${targetVersion} (${targetChannel}, already downloaded).` + : `Updating t3 ${currentVersion} -> ${targetVersion} (${targetChannel}).`, ); let restartService = false; if (serviceInstalled && !serviceCurrent) { @@ -459,7 +471,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false))); } else { yield* Console.log( - " Not a terminal, so the service is left on its current version. Rerun with --yes to restart it, or run `t3 service update` later.", + " Not a terminal, so the service keeps running its current version. Rerun with --yes to restart it now, or run `t3 service restart` later.", ); } } @@ -519,27 +531,33 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { targetEntryPath: runtime.entryPath, }); - // The new executable owns the service switch: it verifies itself, writes its - // own version into the unit, and restarts the service on it. + // The service switch runs in this process against the target version: the + // downloaded runtime has already proven it runs (the `--version` check + // above), and doing it here rather than through the target's own CLI means + // a downgrade to a version without today's commands still works. The unit + // is rewritten either way so a later `t3 service restart` lands on the new + // version; only the restart itself waits for the user's answer. let serviceUpdated = false; - if (restartService) { - const result = yield* runner.run({ - command: runtime.entryPath, - args: [ - "service", - "update", - "--base-dir", - input.baseDir, - ...(input.allowDowngrade ? ["--allow-downgrade"] : []), - ], - timeout: Duration.minutes(5), - }); - if (result.code !== 0) { - return yield* new CliUpdateError({ - reason: `t3@${targetVersion} is installed but the background service could not be updated (exit ${String(result.code)}).\n${result.stderr.trim() || result.stdout.trim()}`, - }); - } - serviceUpdated = true; + if (serviceInstalled && !serviceCurrent) { + yield* BootService.BootService.pipe( + Effect.flatMap((target) => + target.install({ allowDowngrade: input.allowDowngrade, start: restartService }), + ), + Effect.provide( + BootService.layer({ + baseDir: input.baseDir, + logsDir: input.logsDir, + cliVersion: targetVersion, + }), + ), + Effect.mapError( + (error) => + new CliUpdateError({ + reason: `t3@${targetVersion} is installed but the background service could not be ${restartService ? "updated" : "pointed at it"}: ${error.message}`, + }), + ), + ); + serviceUpdated = restartService; } yield* Console.log(""); @@ -555,7 +573,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { yield* Console.log(` Background service already on ${targetVersion}`); } else if (serviceInstalled) { yield* Console.log( - ` Background service still running ${serviceVersion ?? "an unknown version"}. Run \`t3 service update\` when you are ready to restart it.`, + ` Background service still running ${serviceVersion ?? "an unknown version"}. Run \`t3 service restart\` when you are ready to switch it to ${targetVersion}.`, ); } else if (status.installed && !servesThisHome) { yield* Console.log( diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 54ef1ffdd5f5..25609853d2d3 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -20,6 +20,7 @@ import { pinnedRuntimePaths } from "./pinnedRuntime.ts"; import { parseServiceState, SERVICE_LAUNCHER_PROTOCOL, + SERVICE_RESTART_PENDING_FILE, serviceStateHasPendingUpdate, } from "./serviceProtocol.ts"; @@ -181,7 +182,9 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( return { stdout: input.args[0] === "--version" - ? "t3 v1.2.3\n" + ? // The runtime under test reports the version of the directory it + // was launched from, like the real executable. + `t3 v${/versions\/([^/]+)\//.exec(input.command)?.[1] ?? "1.2.3"}\n` : input.command === "loginctl" && input.args[0] === "show-user" ? `${control.linger}\n` : input.args[1] === "is-enabled" @@ -201,12 +204,24 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( }; }), }); - const makeService = (environmentPath = installerPath) => - BootService.make({ - baseDir, - logsDir: path.join(baseDir, "userdata", "logs"), - cliVersion: "1.2.3", - host: { execPath: "/usr/bin/t3" }, + const makeService = ( + environmentPath: string | undefined = installerPath, + cliVersion = "1.2.3", + serviceBaseDir = baseDir, + ) => + Effect.gen(function* () { + // Every version the tests install is present and verified on disk, so + // install never downloads. + const paths = pinnedRuntimePaths(path, serviceBaseDir, cliVersion, platform); + yield* fs.makeDirectory(path.dirname(paths.entryPath), { recursive: true }); + yield* fs.writeFileString(paths.entryPath, "#!/bin/sh\n"); + yield* fs.writeFileString(paths.sentinelPath, `${cliVersion}\n`); + return yield* BootService.make({ + baseDir: serviceBaseDir, + logsDir: path.join(serviceBaseDir, "userdata", "logs"), + cliVersion, + host: { execPath: "/usr/bin/t3" }, + }); }).pipe( Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.provide( @@ -220,7 +235,12 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( ), ConfigProvider.layer( ConfigProvider.fromEnv({ - env: { HOME: home, ...(environmentPath === "" ? {} : { PATH: environmentPath }) }, + env: { + HOME: home, + ...(environmentPath === undefined || environmentPath === "" + ? {} + : { PATH: environmentPath }), + }, }), ), ), @@ -469,6 +489,139 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect("install with start=false rewrites the files and marks a restart pending", () => + Effect.gen(function* () { + const { service, fs, statePath, commands, makeService } = yield* makeHarness(); + yield* service.install(); + commands.length = 0; + + const newer = yield* makeService(undefined, "1.2.4"); + const plan = yield* newer.install({ start: false }); + + expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.2.4", + }); + expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.4/t3"); + expect( + commands.filter( + (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), + ), + ).toEqual([]); + // The files say 1.2.4 but the process is still 1.2.3: not current, and + // the reason is named so `t3 service status` can point at restart. + const status = yield* newer.status; + expect(status.current).toBe(false); + expect(status.problems).toContain("restart-pending"); + + commands.length = 0; + expect(yield* newer.restart).toBe(true); + expect((yield* newer.status).problems).not.toContain("restart-pending"); + expect((yield* newer.status).current).toBe(true); + }), + ); + + it.effect("install with start=false keeps the marker when a later write fails", () => + Effect.gen(function* () { + const { service, fs, statePath, makeService } = yield* makeHarness(); + const path = yield* Path.Path; + yield* service.install(); + const newer = yield* makeService(undefined, "1.2.4"); + // A non-empty directory in the unit's place: it still counts as an + // installed unit, and the rename that writes the new unit fails. + const unitPath = (yield* service.status).unitPath; + yield* fs.remove(unitPath); + yield* fs.makeDirectory(unitPath); + yield* fs.writeFileString(path.join(unitPath, "occupied"), ""); + + const error = yield* newer.install({ start: false }).pipe(Effect.flip); + expect(error._tag).toBe("BootServiceInstallError"); + expect( + yield* fs.exists(path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE)), + ).toBe(true); + }), + ); + + it.effect("install with start=false refuses while a remote update is pending", () => + Effect.gen(function* () { + const { service, fs, statePath } = yield* makeHarness(); + yield* service.install(); + // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document. + const pendingState = JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.2.3", + update: { + id: "u", + fromVersion: "1.2.3", + targetVersion: "1.2.4", + dbPath: "/tmp/state.sqlite", + status: "pending", + }, + }); + yield* fs.writeFileString(statePath, pendingState); + + const error = yield* service.install({ start: false }).pipe(Effect.flip); + expect(error._tag).toBe("BootServiceUpdatePendingError"); + expect(yield* fs.readFileString(statePath)).toBe(pendingState); + }), + ); + + it.effect("restart stops and starts an installed service, and is a no-op otherwise", () => + Effect.gen(function* () { + const { service, commands } = yield* makeHarness(); + expect(yield* service.restart).toBe(false); + yield* service.install(); + commands.length = 0; + + expect(yield* service.restart).toBe(true); + expect( + commands.filter( + (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), + ), + ).toEqual([ + "systemctl --user stop t3code.service", + "systemctl --user daemon-reload", + "systemctl --user enable t3code.service", + "systemctl --user restart t3code.service", + ]); + }), + ); + + it.effect("restart leaves a service that serves another T3 home alone", () => + Effect.gen(function* () { + const { service, fs, commands, makeService } = yield* makeHarness(); + yield* service.install(); + commands.length = 0; + const path = yield* Path.Path; + const otherHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-other-home-" }); + + const other = yield* makeService(undefined, "1.2.3", path.join(otherHome, ".t3")); + expect(yield* other.restart).toBe(false); + expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([]); + }), + ); + + it.effect("restart brings the service back when activation fails", () => + Effect.gen(function* () { + const { service, commands, control } = yield* makeHarness(); + yield* service.install(); + commands.length = 0; + control.failCommand = "systemctl --user daemon-reload"; + + const error = yield* service.restart.pipe(Effect.flip); + expect(error._tag).toBe("BootServiceCommandError"); + expect( + commands.filter( + (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), + ), + ).toEqual([ + "systemctl --user stop t3code.service", + "systemctl --user daemon-reload", + "systemctl --user restart t3code.service", + ]); + }), + ); + it.effect("restarts an installed service when repair fails", () => Effect.gen(function* () { const { service, commands, control } = yield* makeHarness(); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index cb712879bbdd..24495c4c2e8e 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -27,6 +27,7 @@ import { } from "./pinnedRuntime.ts"; import { SERVICE_LAUNCHER_PROTOCOL, + SERVICE_RESTART_PENDING_FILE, SERVICE_STATE_FILE, compareExactServiceVersions, parseServiceState, @@ -444,6 +445,7 @@ const BootServiceProblem = Schema.Literals([ "linger-disabled", "service-disabled", "service-stopped", + "restart-pending", ]); type BootServiceProblem = typeof BootServiceProblem.Type; @@ -457,9 +459,11 @@ export function formatBootServiceProblem(problem: BootServiceProblem): string { case "linger-disabled": 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.'; case "service-disabled": - return "The service is not enabled to start automatically. Run `t3 service update` to repair it."; + return "The service is not enabled to start automatically. Run `t3 service install` to repair it."; case "service-stopped": - return "The service is not running. Check the service log and `systemctl --user status t3code.service`, then run `t3 service update`."; + return "The service is not running. Check the service log and `systemctl --user status t3code.service`, then run `t3 service install`."; + case "restart-pending": + return "A newer version is installed but the service is still running the previous one. Run `t3 service restart` to switch."; } } @@ -523,7 +527,20 @@ export class BootService extends Context.Service< { readonly install: (options?: { readonly allowDowngrade?: boolean; + /** + * Write the unit for this version but leave the service on whatever it + * is running now. `t3 update` uses this when the user declines the + * restart, so a later `t3 service restart` lands on the new version. + */ + readonly start?: boolean; }) => Effect.Effect; + /** + * Stop and start the installed service on the version its unit names. + * Only when the unit serves this base dir: the unit name is per user, so + * another home's service is left alone. Resolves false when nothing was + * restarted. + */ + readonly restart: Effect.Effect; readonly uninstall: Effect.Effect; readonly status: Effect.Effect; } @@ -584,6 +601,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const unitPath = detectedManager?.unitPath ?? ""; const logPath = path.join(input.logsDir, "boot-service.log"); const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); + const restartPendingPath = path.join(input.baseDir, "runtime", SERVICE_RESTART_PENDING_FILE); const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform); const writeDurably = (filePath: string, contents: string) => Effect.scoped( @@ -728,6 +746,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const install = Effect.fn("cloud.boot_service.install")(function* (options?: { readonly allowDowngrade?: boolean; + readonly start?: boolean; }) { const manager = yield* requireManager; yield* fs @@ -795,7 +814,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const installed = yield* fs .exists(unitPath) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - if (installed) { + // With start=false the service keeps running while its files change. The + // launcher reads the state file once at startup and the unit only matters + // on the next start, so that is safe as long as the launcher is not in + // the middle of a remote update, which is the one time it writes the + // state file itself. That case is refused below, before anything is + // written, from the same read the downgrade check uses; the stop that + // normally serialises against the launcher is skipped on purpose. + const start = options?.start !== false; + if (installed && start) { yield* runSteps(manager.stop); } @@ -824,6 +851,13 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { yield* fs .makeDirectory(path.dirname(unitPath), { recursive: true }) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + if (!start && installed) { + // Written first: once the files below name the new version, the + // running service is behind them, and a failure between the two + // writes must not leave it looking current. The launcher removes the + // marker when it starts, `restart` and a started install do too. + yield* fs.writeFileString(restartPendingPath, `${input.cliVersion}\n`, { mode: 0o600 }); + } yield* writeDurably( statePath, // @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: { 2, )}\n`, ); + if (!start && installed) { + // The launcher only writes this file while a remote update is in + // flight. One that began after the check above lands either before + // this write (then the launcher's copy in memory is what it keeps + // acting on, and its next write puts its own outcome back) or after + // it, which this read catches: the file no longer says what was just + // written, so stop here before repointing the unit. + const written = yield* fs.readFileString(statePath); + if (serviceStateActiveVersion(written) !== input.cliVersion) { + return yield* new BootServiceUpdatePendingError(); + } + } yield* writeDurably(unitPath, manager.render(plan)); - yield* runSteps(manager.activate); + if (start) { + yield* runSteps(manager.activate); + yield* fs.remove(restartPendingPath, { force: true }); + } }).pipe( + Effect.mapError((cause) => + cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause, + ), Effect.tapError(() => - installed ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void, + installed && start ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void, ), ); return plan; }); + const restart: BootService["Service"]["restart"] = Effect.gen(function* () { + const manager = yield* requireManager; + const unit = yield* fs.readFileString(unitPath).pipe(Effect.option); + if (Option.isNone(unit)) return false; + const installedBaseDir = bootServiceBaseDirOf(unit.value); + if ( + installedBaseDir === undefined || + path.resolve(installedBaseDir) !== path.resolve(input.baseDir) + ) { + return false; + } + yield* runSteps(manager.stop); + yield* runSteps(manager.activate).pipe( + // Same recovery as a failed repair: a service that was running should + // not be left stopped because daemon-reload or enable failed. + Effect.tapError(() => runSteps(manager.restart).pipe(Effect.ignore)), + ); + yield* fs.remove(restartPendingPath, { force: true }); + return true; + }).pipe( + Effect.mapError((cause) => + cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause, + ), + Effect.withSpan("cloud.boot_service.restart"), + ); + const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { const manager = yield* requireManager; if ( @@ -885,7 +963,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { detectedManager.kind === "launchd" ? contents.replace(/(PATH<\/key>\n\s*)[^<]*(<\/string>)/, "$1$2") : contents; - const problems = detectedManager.kind === "systemd" ? yield* readSystemdProblems(true) : []; + const problems: BootServiceProblem[] = + detectedManager.kind === "systemd" ? [...(yield* readSystemdProblems(true))] : []; + if (yield* fs.exists(restartPendingPath)) problems.push("restart-pending"); return { supported: true, installed: true, @@ -908,7 +988,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { Effect.withSpan("cloud.boot_service.status"), ); - return BootService.of({ install, uninstall, status }); + return BootService.of({ install, restart, uninstall, status }); }); export const layer = (input: { diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts index 89610ca918a3..a008cbc2030b 100644 --- a/apps/server/src/cloud/serviceProtocol.ts +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -8,6 +8,11 @@ export const SERVICE_STATE_FILE = "service-state.json"; the child can tell "the service is going away" from "the launcher is about to start my replacement" while a pending update is recorded. */ export const SERVICE_STOP_MARKER_FILE = ".service-stopping"; +/** Written by `t3 update` when the unit was repointed at a new version but the + running service was deliberately left on the old one. The launcher removes + it when it starts (whoever restarted the service), so while it exists the + service is known to be behind its unit and status reports it that way. */ +export const SERVICE_RESTART_PENDING_FILE = ".restart-pending"; export interface PendingServiceUpdate { readonly id: string; diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index db1a6a8bee34..30b239f3e47f 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -10,6 +10,7 @@ import { decodeServiceState, isExactServiceVersion, SERVICE_LAUNCHER_PROTOCOL, + SERVICE_RESTART_PENDING_FILE, SERVICE_STOP_MARKER_FILE, } from "./cloud/serviceProtocol.ts"; @@ -113,6 +114,50 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { }), ); + it.effect("a fresh launcher clears a restart deferred by t3 update", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-restart-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const restartPending = path.join(root, "runtime", SERVICE_RESTART_PENDING_FILE); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", "1.0.0"), + "setInterval(() => {}, 1_000);\n", + ); + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + }), + ); + const run = () => + Effect.gen(function* () { + const launcher = new Launcher( + root, + yield* Effect.promise(() => readServiceState(statePath)), + ); + const running = launcher.run(); + yield* Effect.promise(() => launcher.stop("SIGTERM")); + yield* Effect.promise(() => running); + }); + + // A launcher that is still the old version leaves a marker that waits + // for a newer one. + yield* fs.writeFileString(restartPending, "1.0.1\n"); + yield* run(); + assert.isTrue(yield* fs.exists(restartPending)); + + // Whoever restarted the service, the launcher now runs what the unit + // names, so the deferred-restart marker is gone. + yield* fs.writeFileString(restartPending, "1.0.0\n"); + yield* run(); + assert.isFalse(yield* fs.exists(restartPending)); + }), + ); + it.effect("serializes shutdown with launcher recovery", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index a1e16a627163..cfdd3d75b57e 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -26,6 +26,7 @@ import { SERVICE_LAUNCHER_CONTEXT_ENV, SERVICE_LAUNCHER_PROTOCOL, SERVICE_STATE_FILE, + SERVICE_RESTART_PENDING_FILE, SERVICE_STOP_MARKER_FILE, } from "./cloud/serviceProtocol.ts"; @@ -273,6 +274,8 @@ async function terminateChild( const stopMarkerPath = (baseDir: string) => NodePath.join(baseDir, "runtime", SERVICE_STOP_MARKER_FILE); +const restartPendingPath = (baseDir: string) => + NodePath.join(baseDir, "runtime", SERVICE_RESTART_PENDING_FILE); export class Launcher { readonly #baseDir: string; @@ -365,8 +368,17 @@ export class Launcher { async #recover(): Promise { // A fresh launcher means servers are running again: any stop marker from // a previous explicit stop is stale and must not make a future update - // handoff release its tunnel. + // handoff release its tunnel. A restart deferred by `t3 update` is done + // no matter who restarted the service, but only once this launcher is + // the version the marker waits for: a launcher that came up between the + // CLI writing the marker and writing the new state still runs the old + // version, and the marker has to outlive it. await NodeFSP.rm(stopMarkerPath(this.#baseDir), { force: true }).catch(() => undefined); + const restartPending = restartPendingPath(this.#baseDir); + const awaitedVersion = await NodeFSP.readFile(restartPending, "utf8").catch(() => undefined); + if (awaitedVersion?.trim() === this.#state.activeVersion) { + await NodeFSP.rm(restartPending, { force: true }).catch(() => undefined); + } const update = this.#state.update; if (update?.status !== "pending") { if (update !== undefined) { diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 7eca6329f325..e82d97b8c3f4 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -110,6 +110,7 @@ that session open. | `linger-unavailable` | Run `loginctl show-user "$(id -un)" --property=Linger` and check that systemd-logind is available. | | `user-manager-unavailable` | Run `systemctl --user status` in a login session for the service user; check your distribution's systemd user-session support. | | `service-disabled` or `service-stopped` | Read the log and `systemctl --user status t3code.service`, then use the repair command printed by T3 Code. | +| `restart-pending` | A newer version is installed but the service still runs the previous one. Run `t3 service restart`. | On macOS, check **System Settings → General → Login Items** if the service no longer starts at login. If agent work cannot access Desktop, Documents, or