| author | |
| committer | |
| log | d8655ed2fcf5cac3e1742459476b4a392b949367 |
| tree | be3d52b16823d4cf916045708d644663548cf564 |
| parent | cba7dd77817df2a458d86977f1daf4606c67dfc8 |
| signature | Signed by PGP key B5690EEEBB952194 |
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", () => { | ... | @@ -45,7 +45,7 @@ it("reports the installed service version and host paths", () => { |
| 45 | it("gives a direct repair command for a stale service", () => { | 45 | it("gives a direct repair command for a stale service", () => { |
| 46 | assert.include( | 46 | assert.include( |
| 47 | formatServiceStatus({ ...status, current: false }, "0.0.29"), | 47 | 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.", |
| 49 | ); | 49 | ); |
| 50 | }); | 50 | }); |
| 51 | 51 | ||
| ... | @@ -64,17 +64,17 @@ it("explains an incomplete nightly installation and keeps repair on its installe | ... | @@ -64,17 +64,17 @@ it("explains an incomplete nightly installation and keeps repair on its installe |
| 64 | expect(output).toContain("last login session ends"); | 64 | expect(output).toContain("last login session ends"); |
| 65 | expect(output).toContain('sudo loginctl enable-linger "$(id -un)"'); | 65 | expect(output).toContain('sudo loginctl enable-linger "$(id -un)"'); |
| 66 | expect(output).toContain("[service-stopped]"); | 66 | expect(output).toContain("[service-stopped]"); |
| 67 | expect(output).toContain("npx t3@0.0.32-nightly.1 service update"); | 67 | expect(output).toContain("Run `t3 service install` to repair it."); |
| 68 | expect(output).not.toContain("t3@latest"); | 68 | expect(output).not.toContain("npx"); |
| 69 | }); | 69 | }); |
| 70 | 70 | ||
| 71 | it("suggests the newer CLI version when the installed service needs an update", () => { | 71 | it("points an older service at a repair, never at npx", () => { |
| 72 | const output = formatServiceStatus( | 72 | const output = formatServiceStatus( |
| 73 | { ...status, current: false, installedVersion: "0.0.28" }, | 73 | { ...status, current: false, installedVersion: "0.0.28" }, |
| 74 | "0.0.29", | 74 | "0.0.29", |
| 75 | ); | 75 | ); |
| 76 | expect(output).toContain("npx t3@0.0.29 service update"); | 76 | expect(output).toContain("Run `t3 service install` to repair it."); |
| 77 | expect(output).not.toContain("npx t3@0.0.28 service update"); | 77 | expect(output).not.toContain("npx"); |
| 78 | }); | 78 | }); |
| 79 | 79 | ||
| 80 | it("explains where the service is supported", () => { | 80 | it("explains where the service is supported", () => { |
| ... | @@ -84,23 +84,28 @@ it("explains where the service is supported", () => { | ... | @@ -84,23 +84,28 @@ it("explains where the service is supported", () => { |
| 84 | ); | 84 | ); |
| 85 | }); | 85 | }); |
| 86 | 86 | ||
| 87 | it("reports a newer installed service and gives an exact-version repair command", () => { | 87 | it("reports a newer installed service and tells the CLI to catch up to it", () => { |
| 88 | const output = formatServiceStatus( | 88 | const output = formatServiceStatus( |
| 89 | { ...status, current: false, installedVersion: "0.0.32-nightly.1" }, | 89 | { ...status, current: false, installedVersion: "0.0.32-nightly.1" }, |
| 90 | "0.0.31", | 90 | "0.0.31", |
| 91 | ); | 91 | ); |
| 92 | 92 | ||
| 93 | assert.include(output, "t3@0.0.32-nightly.1 (newer than this t3@0.0.31 CLI)"); | 93 | 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"); | 94 | assert.include(output, "Run `t3 update 0.0.32-nightly.1` to match it"); |
| 95 | assert.notInclude(output, "npx t3@latest service update"); | 95 | assert.notInclude(output, "npx"); |
| 96 | }); | 96 | }); |
| 97 | 97 | ||
| 98 | const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" }; | 98 | const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" }; |
| 99 | 99 | ||
| 100 | function makeTestService(serviceStatus: BootService.BootServiceStatus) { | 100 | function makeTestService(serviceStatus: BootService.BootServiceStatus) { |
| 101 | const installOptions: Array<Parameters<BootService.BootService["Service"]["install"]>[0]> = []; | 101 | const installOptions: Array<Parameters<BootService.BootService["Service"]["install"]>[0]> = []; |
| 102 | const restarts: Array<true> = []; | ||
| 102 | const service = BootService.BootService.of({ | 103 | const service = BootService.BootService.of({ |
| 103 | status: Effect.succeed(serviceStatus), | 104 | status: Effect.succeed(serviceStatus), |
| 105 | restart: Effect.sync(() => { | ||
| 106 | restarts.push(true); | ||
| 107 | return serviceStatus.installed; | ||
| 108 | }), | ||
| 104 | install: (options) => | 109 | install: (options) => |
| 105 | Effect.sync(() => { | 110 | Effect.sync(() => { |
| 106 | installOptions.push(options); | 111 | installOptions.push(options); |
| ... | @@ -113,10 +118,33 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) { | ... | @@ -113,10 +118,33 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) { |
| 113 | }), | 118 | }), |
| 114 | uninstall: Effect.succeed(false), | 119 | uninstall: Effect.succeed(false), |
| 115 | }); | 120 | }); |
| 116 | return { service, installOptions }; | 121 | return { service, installOptions, restarts }; |
| 117 | } | 122 | } |
| 118 | 123 | ||
| 119 | it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands", (it) => { | 124 | it.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 | |||
| 120 | it.effect.each(["install", "update"] as const)( | 148 | it.effect.each(["install", "update"] as const)( |
| 121 | "%s refuses a downgrade before changing the service", | 149 | "%s refuses a downgrade before changing the service", |
| 122 | (command) => | 150 | (command) => |
apps/server/src/cli/service.ts+33-6| ... | @@ -38,6 +38,7 @@ export type ServiceReconcileResult = | ... | @@ -38,6 +38,7 @@ export type ServiceReconcileResult = |
| 38 | /** Install, update, or repair the service using the CLI version running this command. */ | 38 | /** Install, update, or repair the service using the CLI version running this command. */ |
| 39 | export const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: { | 39 | export const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: { |
| 40 | readonly allowDowngrade?: boolean; | 40 | readonly allowDowngrade?: boolean; |
| 41 | readonly start?: boolean; | ||
| 41 | }) { | 42 | }) { |
| 42 | const service = yield* BootService.BootService; | 43 | const service = yield* BootService.BootService; |
| 43 | const status = yield* service.status; | 44 | const status = yield* service.status; |
| ... | @@ -87,7 +88,7 @@ export function formatServiceStatus( | ... | @@ -87,7 +88,7 @@ export function formatServiceStatus( |
| 87 | ` Unit: ${status.unitPath}`, | 88 | ` Unit: ${status.unitPath}`, |
| 88 | ` Logs: ${status.logPath}`, | 89 | ` Logs: ${status.logPath}`, |
| 89 | ...problems, | 90 | ...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.`, |
| 91 | ].join("\n"); | 92 | ].join("\n"); |
| 92 | } | 93 | } |
| 93 | return [ | 94 | return [ |
| ... | @@ -96,7 +97,7 @@ export function formatServiceStatus( | ... | @@ -96,7 +97,7 @@ export function formatServiceStatus( |
| 96 | ` Unit: ${status.unitPath}`, | 97 | ` Unit: ${status.unitPath}`, |
| 97 | ` Logs: ${status.logPath}`, | 98 | ` Logs: ${status.logPath}`, |
| 98 | ...problems, | 99 | ...problems, |
| 99 | ...(status.current ? [] : [` Next: Run \`npx t3@${cliVersion} service update\`.`]), | 100 | ...(status.current ? [] : [" Next: Run `t3 service install` to repair it."]), |
| 100 | ].join("\n"); | 101 | ].join("\n"); |
| 101 | } | 102 | } |
| 102 | 103 | ||
| ... | @@ -138,14 +139,18 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip | ... | @@ -138,14 +139,18 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip |
| 138 | ), | 139 | ), |
| 139 | ); | 140 | ); |
| 140 | 141 | ||
| 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`. | ||
| 141 | const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( | 144 | const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( |
| 142 | Command.withDescription( | 145 | Command.withDescription("Deprecated. Run `t3 update` to move to a newer release."), |
| 143 | "Update or repair the background service using this CLI version. Use `t3 update` to move to a newer release first.", | 146 | Command.unlisted, |
| 144 | ), | ||
| 145 | Command.withHandler((flags) => | 147 | Command.withHandler((flags) => |
| 146 | runServiceCommand( | 148 | runServiceCommand( |
| 147 | flags, | 149 | flags, |
| 148 | Effect.gen(function* () { | 150 | 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 | ); | ||
| 149 | const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); | 154 | const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); |
| 150 | if (!result.changed) { | 155 | if (!result.changed) { |
| 151 | yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`); | 156 | yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`); |
| ... | @@ -159,6 +164,27 @@ const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( | ... | @@ -159,6 +164,27 @@ const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( |
| 159 | ), | 164 | ), |
| 160 | ); | 165 | ); |
| 161 | 166 | ||
| 167 | const 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 | |||
| 162 | const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).pipe( | 188 | const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).pipe( |
| 163 | Command.withDescription("Stop and remove the T3 Code background service."), | 189 | Command.withDescription("Stop and remove the T3 Code background service."), |
| 164 | Command.withHandler((flags) => | 190 | Command.withHandler((flags) => |
| ... | @@ -265,8 +291,9 @@ export const serviceCommand = Command.make("service").pipe( | ... | @@ -265,8 +291,9 @@ export const serviceCommand = Command.make("service").pipe( |
| 265 | Command.withDescription("Manage the T3 Code background service."), | 291 | Command.withDescription("Manage the T3 Code background service."), |
| 266 | Command.withSubcommands([ | 292 | Command.withSubcommands([ |
| 267 | serviceInstallCommand, | 293 | serviceInstallCommand, |
| 294 | serviceRestartCommand, | ||
| 268 | serviceUninstallCommand, | 295 | serviceUninstallCommand, |
| 269 | serviceUpdateCommand, | ||
| 270 | serviceStatusCommand, | 296 | serviceStatusCommand, |
| 297 | serviceUpdateCommand, | ||
| 271 | ]), | 298 | ]), |
| 272 | ); | 299 | ); |
apps/server/src/cli/update.ts+47-29| ... | @@ -263,6 +263,7 @@ export const updateCommand = Command.make("update", { | ... | @@ -263,6 +263,7 @@ export const updateCommand = Command.make("update", { |
| 263 | const config = yield* resolveCliAuthConfig(flags, logLevel); | 263 | const config = yield* resolveCliAuthConfig(flags, logLevel); |
| 264 | return yield* runUpdate({ | 264 | return yield* runUpdate({ |
| 265 | baseDir: config.baseDir, | 265 | baseDir: config.baseDir, |
| 266 | logsDir: config.logsDir, | ||
| 266 | serverRuntimeStatePath: config.serverRuntimeStatePath, | 267 | serverRuntimeStatePath: config.serverRuntimeStatePath, |
| 267 | channel: Option.getOrUndefined(flags.channel), | 268 | channel: Option.getOrUndefined(flags.channel), |
| 268 | requestedVersion: Option.getOrUndefined(flags.version), | 269 | requestedVersion: Option.getOrUndefined(flags.version), |
| ... | @@ -333,6 +334,7 @@ const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(fun | ... | @@ -333,6 +334,7 @@ const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(fun |
| 333 | 334 | ||
| 334 | const runUpdate = Effect.fn("cli.update.run")(function* (input: { | 335 | const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 335 | readonly baseDir: string; | 336 | readonly baseDir: string; |
| 337 | readonly logsDir: string; | ||
| 336 | readonly serverRuntimeStatePath: string; | 338 | readonly serverRuntimeStatePath: string; |
| 337 | readonly channel: CliReleaseChannel | undefined; | 339 | readonly channel: CliReleaseChannel | undefined; |
| 338 | readonly requestedVersion: string | undefined; | 340 | readonly requestedVersion: string | undefined; |
| ... | @@ -408,8 +410,16 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { | ... | @@ -408,8 +410,16 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 408 | const serviceVersion = serviceInstalled ? status.installedVersion : undefined; | 410 | const serviceVersion = serviceInstalled ? status.installedVersion : undefined; |
| 409 | const executableCurrent = targetVersion === currentVersion; | 411 | const executableCurrent = targetVersion === currentVersion; |
| 410 | // A service whose recorded version is missing or unreadable is not known | 412 | // 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. | 413 | // to be current, so it gets the update rather than being skipped. Nor is |
| 412 | const serviceCurrent = !serviceInstalled || serviceVersion === targetVersion; | 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)); | ||
| 413 | const newestInstalled = | 423 | const newestInstalled = |
| 414 | serviceVersion !== undefined && compareExactServiceVersions(serviceVersion, currentVersion) > 0 | 424 | serviceVersion !== undefined && compareExactServiceVersions(serviceVersion, currentVersion) > 0 |
| 415 | ? serviceVersion | 425 | ? serviceVersion |
| ... | @@ -437,11 +447,13 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { | ... | @@ -437,11 +447,13 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 437 | ); | 447 | ); |
| 438 | 448 | ||
| 439 | yield* Console.log( | 449 | yield* Console.log( |
| 440 | executableCurrent | 450 | executableCurrent && restartPending |
| 441 | ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).` | 451 | ? `The background service is still running the version before ${targetVersion} (${targetChannel}).` |
| 442 | : alreadyOnDisk | 452 | : executableCurrent |
| 443 | ? `Switching t3 ${currentVersion} -> ${targetVersion} (${targetChannel}, already downloaded).` | 453 | ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).` |
| 444 | : `Updating t3 ${currentVersion} -> ${targetVersion} (${targetChannel}).`, | 454 | : alreadyOnDisk |
| 455 | ? `Switching t3 ${currentVersion} -> ${targetVersion} (${targetChannel}, already downloaded).` | ||
| 456 | : `Updating t3 ${currentVersion} -> ${targetVersion} (${targetChannel}).`, | ||
| 445 | ); | 457 | ); |
| 446 | let restartService = false; | 458 | let restartService = false; |
| 447 | if (serviceInstalled && !serviceCurrent) { | 459 | if (serviceInstalled && !serviceCurrent) { |
| ... | @@ -459,7 +471,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { | ... | @@ -459,7 +471,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 459 | ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false))); | 471 | ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false))); |
| 460 | } else { | 472 | } else { |
| 461 | yield* Console.log( | 473 | 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.", |
| 463 | ); | 475 | ); |
| 464 | } | 476 | } |
| 465 | } | 477 | } |
| ... | @@ -519,27 +531,33 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { | ... | @@ -519,27 +531,33 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 519 | targetEntryPath: runtime.entryPath, | 531 | targetEntryPath: runtime.entryPath, |
| 520 | }); | 532 | }); |
| 521 | 533 | ||
| 522 | // The new executable owns the service switch: it verifies itself, writes its | 534 | // The service switch runs in this process against the target version: the |
| 523 | // own version into the unit, and restarts the service on it. | 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. | ||
| 524 | let serviceUpdated = false; | 540 | let serviceUpdated = false; |
| 525 | if (restartService) { | 541 | if (serviceInstalled && !serviceCurrent) { |
| 526 | const result = yield* runner.run({ | 542 | yield* BootService.BootService.pipe( |
| 527 | command: runtime.entryPath, | 543 | Effect.flatMap((target) => |
| 528 | args: [ | 544 | target.install({ allowDowngrade: input.allowDowngrade, start: restartService }), |
| 529 | "service", | 545 | ), |
| 530 | "update", | 546 | Effect.provide( |
| 531 | "--base-dir", | 547 | BootService.layer({ |
| 532 | input.baseDir, | 548 | baseDir: input.baseDir, |
| 533 | ...(input.allowDowngrade ? ["--allow-downgrade"] : []), | 549 | logsDir: input.logsDir, |
| 534 | ], | 550 | cliVersion: targetVersion, |
| 535 | timeout: Duration.minutes(5), | 551 | }), |
| 536 | }); | 552 | ), |
| 537 | if (result.code !== 0) { | 553 | Effect.mapError( |
| 538 | return yield* new CliUpdateError({ | 554 | (error) => |
| 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()}`, | 555 | new CliUpdateError({ |
| 540 | }); | 556 | reason: `t3@${targetVersion} is installed but the background service could not be ${restartService ? "updated" : "pointed at it"}: ${error.message}`, |
| 541 | } | 557 | }), |
| 542 | serviceUpdated = true; | 558 | ), |
| 559 | ); | ||
| 560 | serviceUpdated = restartService; | ||
| 543 | } | 561 | } |
| 544 | 562 | ||
| 545 | yield* Console.log(""); | 563 | yield* Console.log(""); |
| ... | @@ -555,7 +573,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { | ... | @@ -555,7 +573,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 555 | yield* Console.log(` Background service already on ${targetVersion}`); | 573 | yield* Console.log(` Background service already on ${targetVersion}`); |
| 556 | } else if (serviceInstalled) { | 574 | } else if (serviceInstalled) { |
| 557 | yield* Console.log( | 575 | 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}.`, |
| 559 | ); | 577 | ); |
| 560 | } else if (status.installed && !servesThisHome) { | 578 | } else if (status.installed && !servesThisHome) { |
| 561 | yield* Console.log( | 579 | yield* Console.log( |
apps/server/src/cloud/bootService.test.ts+161-8| ... | @@ -20,6 +20,7 @@ import { pinnedRuntimePaths } from "./pinnedRuntime.ts"; | ... | @@ -20,6 +20,7 @@ import { pinnedRuntimePaths } from "./pinnedRuntime.ts"; |
| 20 | import { | 20 | import { |
| 21 | parseServiceState, | 21 | parseServiceState, |
| 22 | SERVICE_LAUNCHER_PROTOCOL, | 22 | SERVICE_LAUNCHER_PROTOCOL, |
| 23 | SERVICE_RESTART_PENDING_FILE, | ||
| 23 | serviceStateHasPendingUpdate, | 24 | serviceStateHasPendingUpdate, |
| 24 | } from "./serviceProtocol.ts"; | 25 | } from "./serviceProtocol.ts"; |
| 25 | 26 | ||
| ... | @@ -181,7 +182,9 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( | ... | @@ -181,7 +182,9 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 181 | return { | 182 | return { |
| 182 | stdout: | 183 | stdout: |
| 183 | input.args[0] === "--version" | 184 | 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` | ||
| 185 | : input.command === "loginctl" && input.args[0] === "show-user" | 188 | : input.command === "loginctl" && input.args[0] === "show-user" |
| 186 | ? `${control.linger}\n` | 189 | ? `${control.linger}\n` |
| 187 | : input.args[1] === "is-enabled" | 190 | : input.args[1] === "is-enabled" |
| ... | @@ -201,12 +204,24 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( | ... | @@ -201,12 +204,24 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 201 | }; | 204 | }; |
| 202 | }), | 205 | }), |
| 203 | }); | 206 | }); |
| 204 | const makeService = (environmentPath = installerPath) => | 207 | const makeService = ( |
| 205 | BootService.make({ | 208 | environmentPath: string | undefined = installerPath, |
| 206 | baseDir, | 209 | cliVersion = "1.2.3", |
| 207 | logsDir: path.join(baseDir, "userdata", "logs"), | 210 | serviceBaseDir = baseDir, |
| 208 | cliVersion: "1.2.3", | 211 | ) => |
| 209 | host: { execPath: "/usr/bin/t3" }, | 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 | }); | ||
| 210 | }).pipe( | 225 | }).pipe( |
| 211 | Effect.provideService(ProcessRunner.ProcessRunner, runner), | 226 | Effect.provideService(ProcessRunner.ProcessRunner, runner), |
| 212 | Effect.provide( | 227 | Effect.provide( |
| ... | @@ -220,7 +235,12 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( | ... | @@ -220,7 +235,12 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 220 | ), | 235 | ), |
| 221 | ConfigProvider.layer( | 236 | ConfigProvider.layer( |
| 222 | ConfigProvider.fromEnv({ | 237 | ConfigProvider.fromEnv({ |
| 223 | env: { HOME: home, ...(environmentPath === "" ? {} : { PATH: environmentPath }) }, | 238 | env: { |
| 239 | HOME: home, | ||
| 240 | ...(environmentPath === undefined || environmentPath === "" | ||
| 241 | ? {} | ||
| 242 | : { PATH: environmentPath }), | ||
| 243 | }, | ||
| 224 | }), | 244 | }), |
| 225 | ), | 245 | ), |
| 226 | ), | 246 | ), |
| ... | @@ -469,6 +489,139 @@ it.layer(NodeServices.layer)("boot service install", (it) => { | ... | @@ -469,6 +489,139 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 469 | }), | 489 | }), |
| 470 | ); | 490 | ); |
| 471 | 491 | ||
| 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 | |||
| 472 | it.effect("restarts an installed service when repair fails", () => | 625 | it.effect("restarts an installed service when repair fails", () => |
| 473 | Effect.gen(function* () { | 626 | Effect.gen(function* () { |
| 474 | const { service, commands, control } = yield* makeHarness(); | 627 | const { service, commands, control } = yield* makeHarness(); |
apps/server/src/cloud/bootService.ts+87-7| ... | @@ -27,6 +27,7 @@ import { | ... | @@ -27,6 +27,7 @@ import { |
| 27 | } from "./pinnedRuntime.ts"; | 27 | } from "./pinnedRuntime.ts"; |
| 28 | import { | 28 | import { |
| 29 | SERVICE_LAUNCHER_PROTOCOL, | 29 | SERVICE_LAUNCHER_PROTOCOL, |
| 30 | SERVICE_RESTART_PENDING_FILE, | ||
| 30 | SERVICE_STATE_FILE, | 31 | SERVICE_STATE_FILE, |
| 31 | compareExactServiceVersions, | 32 | compareExactServiceVersions, |
| 32 | parseServiceState, | 33 | parseServiceState, |
| ... | @@ -444,6 +445,7 @@ const BootServiceProblem = Schema.Literals([ | ... | @@ -444,6 +445,7 @@ const BootServiceProblem = Schema.Literals([ |
| 444 | "linger-disabled", | 445 | "linger-disabled", |
| 445 | "service-disabled", | 446 | "service-disabled", |
| 446 | "service-stopped", | 447 | "service-stopped", |
| 448 | "restart-pending", | ||
| 447 | ]); | 449 | ]); |
| 448 | type BootServiceProblem = typeof BootServiceProblem.Type; | 450 | type BootServiceProblem = typeof BootServiceProblem.Type; |
| 449 | 451 | ||
| ... | @@ -457,9 +459,11 @@ export function formatBootServiceProblem(problem: BootServiceProblem): string { | ... | @@ -457,9 +459,11 @@ export function formatBootServiceProblem(problem: BootServiceProblem): string { |
| 457 | case "linger-disabled": | 459 | case "linger-disabled": |
| 458 | 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.'; | 460 | 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.'; |
| 459 | case "service-disabled": | 461 | 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."; |
| 461 | case "service-stopped": | 463 | 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."; | ||
| 463 | } | 467 | } |
| 464 | } | 468 | } |
| 465 | 469 | ||
| ... | @@ -523,7 +527,20 @@ export class BootService extends Context.Service< | ... | @@ -523,7 +527,20 @@ export class BootService extends Context.Service< |
| 523 | { | 527 | { |
| 524 | readonly install: (options?: { | 528 | readonly install: (options?: { |
| 525 | readonly allowDowngrade?: boolean; | 529 | 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; | ||
| 526 | }) => Effect.Effect<BootServicePlan, BootServiceError>; | 536 | }) => 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>; | ||
| 527 | readonly uninstall: Effect.Effect<boolean, BootServiceError>; | 544 | readonly uninstall: Effect.Effect<boolean, BootServiceError>; |
| 528 | readonly status: Effect.Effect<BootServiceStatus, BootServiceError>; | 545 | readonly status: Effect.Effect<BootServiceStatus, BootServiceError>; |
| 529 | } | 546 | } |
| ... | @@ -584,6 +601,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -584,6 +601,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 584 | const unitPath = detectedManager?.unitPath ?? ""; | 601 | const unitPath = detectedManager?.unitPath ?? ""; |
| 585 | const logPath = path.join(input.logsDir, "boot-service.log"); | 602 | const logPath = path.join(input.logsDir, "boot-service.log"); |
| 586 | const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); | 603 | const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); |
| 604 | const restartPendingPath = path.join(input.baseDir, "runtime", SERVICE_RESTART_PENDING_FILE); | ||
| 587 | const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform); | 605 | const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform); |
| 588 | const writeDurably = (filePath: string, contents: string) => | 606 | const writeDurably = (filePath: string, contents: string) => |
| 589 | Effect.scoped( | 607 | Effect.scoped( |
| ... | @@ -728,6 +746,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -728,6 +746,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 728 | 746 | ||
| 729 | const install = Effect.fn("cloud.boot_service.install")(function* (options?: { | 747 | const install = Effect.fn("cloud.boot_service.install")(function* (options?: { |
| 730 | readonly allowDowngrade?: boolean; | 748 | readonly allowDowngrade?: boolean; |
| 749 | readonly start?: boolean; | ||
| 731 | }) { | 750 | }) { |
| 732 | const manager = yield* requireManager; | 751 | const manager = yield* requireManager; |
| 733 | yield* fs | 752 | yield* fs |
| ... | @@ -795,7 +814,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -795,7 +814,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 795 | const installed = yield* fs | 814 | const installed = yield* fs |
| 796 | .exists(unitPath) | 815 | .exists(unitPath) |
| 797 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | 816 | .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) { | ||
| 799 | yield* runSteps(manager.stop); | 826 | yield* runSteps(manager.stop); |
| 800 | } | 827 | } |
| 801 | 828 | ||
| ... | @@ -824,6 +851,13 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -824,6 +851,13 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 824 | yield* fs | 851 | yield* fs |
| 825 | .makeDirectory(path.dirname(unitPath), { recursive: true }) | 852 | .makeDirectory(path.dirname(unitPath), { recursive: true }) |
| 826 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | 853 | .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 | } | ||
| 827 | yield* writeDurably( | 861 | yield* writeDurably( |
| 828 | statePath, | 862 | statePath, |
| 829 | // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document. | 863 | // @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: { | ... | @@ -836,17 +870,61 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 836 | 2, | 870 | 2, |
| 837 | )}\n`, | 871 | )}\n`, |
| 838 | ); | 872 | ); |
| 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 | } | ||
| 839 | yield* writeDurably(unitPath, manager.render(plan)); | 885 | yield* writeDurably(unitPath, manager.render(plan)); |
| 840 | 886 | ||
| 841 | yield* runSteps(manager.activate); | 887 | if (start) { |
| 888 | yield* runSteps(manager.activate); | ||
| 889 | yield* fs.remove(restartPendingPath, { force: true }); | ||
| 890 | } | ||
| 842 | }).pipe( | 891 | }).pipe( |
| 892 | Effect.mapError((cause) => | ||
| 893 | cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause, | ||
| 894 | ), | ||
| 843 | Effect.tapError(() => | 895 | Effect.tapError(() => |
| 844 | installed ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void, | 896 | installed && start ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void, |
| 845 | ), | 897 | ), |
| 846 | ); | 898 | ); |
| 847 | return plan; | 899 | return plan; |
| 848 | }); | 900 | }); |
| 849 | 901 | ||
| 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 | |||
| 850 | const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { | 928 | const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { |
| 851 | const manager = yield* requireManager; | 929 | const manager = yield* requireManager; |
| 852 | if ( | 930 | if ( |
| ... | @@ -885,7 +963,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -885,7 +963,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 885 | detectedManager.kind === "launchd" | 963 | detectedManager.kind === "launchd" |
| 886 | ? contents.replace(/(<key>PATH<\/key>\n\s*<string>)[^<]*(<\/string>)/, "$1$2") | 964 | ? contents.replace(/(<key>PATH<\/key>\n\s*<string>)[^<]*(<\/string>)/, "$1$2") |
| 887 | : contents; | 965 | : 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"); | ||
| 889 | return { | 969 | return { |
| 890 | supported: true, | 970 | supported: true, |
| 891 | installed: true, | 971 | installed: true, |
| ... | @@ -908,7 +988,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -908,7 +988,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 908 | Effect.withSpan("cloud.boot_service.status"), | 988 | Effect.withSpan("cloud.boot_service.status"), |
| 909 | ); | 989 | ); |
| 910 | 990 | ||
| 911 | return BootService.of({ install, uninstall, status }); | 991 | return BootService.of({ install, restart, uninstall, status }); |
| 912 | }); | 992 | }); |
| 913 | 993 | ||
| 914 | export const layer = (input: { | 994 | export const layer = (input: { |
apps/server/src/cloud/serviceProtocol.ts+5| ... | @@ -8,6 +8,11 @@ export const SERVICE_STATE_FILE = "service-state.json"; | ... | @@ -8,6 +8,11 @@ export const SERVICE_STATE_FILE = "service-state.json"; |
| 8 | the child can tell "the service is going away" from "the launcher is about | 8 | the child can tell "the service is going away" from "the launcher is about |
| 9 | to start my replacement" while a pending update is recorded. */ | 9 | to start my replacement" while a pending update is recorded. */ |
| 10 | export const SERVICE_STOP_MARKER_FILE = ".service-stopping"; | 10 | export 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. */ | ||
| 15 | export const SERVICE_RESTART_PENDING_FILE = ".restart-pending"; | ||
| 11 | 16 | ||
| 12 | export interface PendingServiceUpdate { | 17 | export interface PendingServiceUpdate { |
| 13 | readonly id: string; | 18 | readonly id: string; |
apps/server/src/serviceLauncher.test.ts+45| ... | @@ -10,6 +10,7 @@ import { | ... | @@ -10,6 +10,7 @@ import { |
| 10 | decodeServiceState, | 10 | decodeServiceState, |
| 11 | isExactServiceVersion, | 11 | isExactServiceVersion, |
| 12 | SERVICE_LAUNCHER_PROTOCOL, | 12 | SERVICE_LAUNCHER_PROTOCOL, |
| 13 | SERVICE_RESTART_PENDING_FILE, | ||
| 13 | SERVICE_STOP_MARKER_FILE, | 14 | SERVICE_STOP_MARKER_FILE, |
| 14 | } from "./cloud/serviceProtocol.ts"; | 15 | } from "./cloud/serviceProtocol.ts"; |
| 15 | 16 | ||
| ... | @@ -113,6 +114,50 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { | ... | @@ -113,6 +114,50 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { |
| 113 | }), | 114 | }), |
| 114 | ); | 115 | ); |
| 115 | 116 | ||
| 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 | |||
| 116 | it.effect("serializes shutdown with launcher recovery", () => | 161 | it.effect("serializes shutdown with launcher recovery", () => |
| 117 | Effect.gen(function* () { | 162 | Effect.gen(function* () { |
| 118 | const fs = yield* FileSystem.FileSystem; | 163 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/serviceLauncher.ts+13-1| ... | @@ -26,6 +26,7 @@ import { | ... | @@ -26,6 +26,7 @@ import { |
| 26 | SERVICE_LAUNCHER_CONTEXT_ENV, | 26 | SERVICE_LAUNCHER_CONTEXT_ENV, |
| 27 | SERVICE_LAUNCHER_PROTOCOL, | 27 | SERVICE_LAUNCHER_PROTOCOL, |
| 28 | SERVICE_STATE_FILE, | 28 | SERVICE_STATE_FILE, |
| 29 | SERVICE_RESTART_PENDING_FILE, | ||
| 29 | SERVICE_STOP_MARKER_FILE, | 30 | SERVICE_STOP_MARKER_FILE, |
| 30 | } from "./cloud/serviceProtocol.ts"; | 31 | } from "./cloud/serviceProtocol.ts"; |
| 31 | 32 | ||
| ... | @@ -273,6 +274,8 @@ async function terminateChild( | ... | @@ -273,6 +274,8 @@ async function terminateChild( |
| 273 | 274 | ||
| 274 | const stopMarkerPath = (baseDir: string) => | 275 | const stopMarkerPath = (baseDir: string) => |
| 275 | NodePath.join(baseDir, "runtime", SERVICE_STOP_MARKER_FILE); | 276 | NodePath.join(baseDir, "runtime", SERVICE_STOP_MARKER_FILE); |
| 277 | const restartPendingPath = (baseDir: string) => | ||
| 278 | NodePath.join(baseDir, "runtime", SERVICE_RESTART_PENDING_FILE); | ||
| 276 | 279 | ||
| 277 | export class Launcher { | 280 | export class Launcher { |
| 278 | readonly #baseDir: string; | 281 | readonly #baseDir: string; |
| ... | @@ -365,8 +368,17 @@ export class Launcher { | ... | @@ -365,8 +368,17 @@ export class Launcher { |
| 365 | async #recover(): Promise<void> { | 368 | async #recover(): Promise<void> { |
| 366 | // A fresh launcher means servers are running again: any stop marker from | 369 | // A fresh launcher means servers are running again: any stop marker from |
| 367 | // a previous explicit stop is stale and must not make a future update | 370 | // 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. | ||
| 369 | await NodeFSP.rm(stopMarkerPath(this.#baseDir), { force: true }).catch(() => undefined); | 376 | 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 | } | ||
| 370 | const update = this.#state.update; | 382 | const update = this.#state.update; |
| 371 | if (update?.status !== "pending") { | 383 | if (update?.status !== "pending") { |
| 372 | if (update !== undefined) { | 384 | if (update !== undefined) { |
docs/user/background-service.md+1| ... | @@ -110,6 +110,7 @@ that session open. | ... | @@ -110,6 +110,7 @@ that session open. |
| 110 | | `linger-unavailable` | Run `loginctl show-user "$(id -un)" --property=Linger` and check that systemd-logind is available. | | 110 | | `linger-unavailable` | Run `loginctl show-user "$(id -un)" --property=Linger` and check that systemd-logind is available. | |
| 111 | | `user-manager-unavailable` | Run `systemctl --user status` in a login session for the service user; check your distribution's systemd user-session support. | | 111 | | `user-manager-unavailable` | Run `systemctl --user status` in a login session for the service user; check your distribution's systemd user-session support. | |
| 112 | | `service-disabled` or `service-stopped` | Read the log and `systemctl --user status t3code.service`, then use the repair command printed by T3 Code. | | 112 | | `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`. | | ||
| 113 | 114 | ||
| 114 | On macOS, check **System Settings → General → Login Items** if the service no | 115 | On macOS, check **System Settings → General → Login Items** if the service no |
| 115 | longer starts at login. If agent work cannot access Desktop, Documents, or | 116 | longer starts at login. If agent work cannot access Desktop, Documents, or |