From b4ef4e6070ab52b93aef48991e31812de36ec81b Mon Sep 17 00:00:00 2001 From: Rookmate <14072042+rookmate@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:36:29 +0400 Subject: [PATCH 1/6] fix(server): t3 update and t3 service restart run from inside T3 no longer leave the service stopped Run from a T3 terminal or by an agent, both commands stopped t3code.service first. The stop killed the command itself, since it runs in the unit's cgroup, so the service was never started again. When T3_BOOT_SERVICE_UNIT says we are inside the unit, install writes the files through the existing start=false path and then queues the restart with `systemctl --user restart --no-block`. restart does the same. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/cloud/bootService.test.ts | 76 ++++++++++++++++++++++- apps/server/src/cloud/bootService.ts | 53 ++++++++++++++-- 2 files changed, 123 insertions(+), 6 deletions(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 25609853d2d3..ab8985a71b02 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -171,7 +171,12 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( if (!failed && command === "loginctl enable-linger --no-ask-password 501") control.linger = "yes"; if (!failed && command === "systemctl --user enable t3code.service") control.enabled = true; - if (!failed && command === "systemctl --user restart t3code.service") control.active = true; + if ( + !failed && + (command === "systemctl --user restart t3code.service" || + command === "systemctl --user restart --no-block t3code.service") + ) + control.active = true; if ( control.stateAfterStop !== undefined && (command === "systemctl --user stop t3code.service" || @@ -208,6 +213,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( environmentPath: string | undefined = installerPath, cliVersion = "1.2.3", serviceBaseDir = baseDir, + insideService = false, ) => Effect.gen(function* () { // Every version the tests install is present and verified on disk, so @@ -240,6 +246,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( ...(environmentPath === undefined || environmentPath === "" ? {} : { PATH: environmentPath }), + ...(insideService ? { T3_BOOT_SERVICE_UNIT: "t3code.service" } : {}), }, }), ), @@ -622,6 +629,73 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect("updates from inside the service without stopping the process doing it", () => + Effect.gen(function* () { + const { service, fs, statePath, commands, makeService } = yield* makeHarness(); + const path = yield* Path.Path; + yield* service.install(); + commands.length = 0; + + const newer = yield* makeService(undefined, "1.2.4", undefined, true); + const plan = yield* newer.install(); + + expect( + commands.filter( + (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), + ), + ).toEqual([ + "systemctl --user daemon-reload", + "systemctl --user enable t3code.service", + "systemctl --user restart --no-block t3code.service", + ]); + expect(parseServiceState(yield* fs.readFileString(statePath))?.activeVersion).toBe("1.2.4"); + expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.4/t3"); + // Until the launcher comes back on 1.2.4 and clears it. + expect( + yield* fs.exists(path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE)), + ).toBe(true); + }), + ); + + it.effect("restarts from inside the service by queuing the restart", () => + Effect.gen(function* () { + const { service, commands, makeService } = yield* makeHarness(); + yield* service.install(); + commands.length = 0; + + const inside = yield* makeService(undefined, "1.2.3", undefined, true); + expect(yield* inside.restart).toBe(true); + expect( + commands.filter( + (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), + ), + ).toEqual([ + "systemctl --user daemon-reload", + "systemctl --user enable t3code.service", + "systemctl --user restart --no-block t3code.service", + ]); + }), + ); + + it.effect("leaves the service running when a handoff from inside it fails", () => + Effect.gen(function* () { + const { service, commands, control, makeService } = yield* makeHarness(); + yield* service.install(); + commands.length = 0; + control.failCommand = "systemctl --user daemon-reload"; + + const newer = yield* makeService(undefined, "1.2.4", undefined, true); + const error = yield* newer.install().pipe(Effect.flip); + expect(error._tag).toBe("BootServiceCommandError"); + // Nothing was stopped, so there is nothing to bring back. + expect( + commands.filter( + (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), + ), + ).toEqual(["systemctl --user daemon-reload"]); + }), + ); + 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 fc8fc1549a6b..63a4139e564c 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -228,6 +228,12 @@ export interface BootServiceManager { readonly activate: ReadonlyArray; /** Best-effort recovery after a failed repair of an installed service. */ readonly restart: ReadonlyArray; + /** + * Replaces stop + activate when the caller runs inside the service, where a + * stop would kill it before the start. The last entry queues the restart + * with the service manager instead of waiting for it. + */ + readonly handoff?: ReadonlyArray; /** Uninstall, before the unit file is removed. */ readonly deactivate: ReadonlyArray; /** Uninstall, after the unit file is removed. */ @@ -282,6 +288,25 @@ function systemdManager(input: { args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE], }, ], + handoff: [ + { + step: "reloading systemd user units", + command: "systemctl", + args: ["--user", "daemon-reload"], + }, + { + step: "enabling the service", + command: "systemctl", + args: ["--user", "enable", BOOT_SERVICE_UNIT_FILE], + }, + // The user manager owns the queued job and finishes it after the stop + // has killed this process along with the rest of the unit. + { + step: "restarting the service", + command: "systemctl", + args: ["--user", "restart", "--no-block", BOOT_SERVICE_UNIT_FILE], + }, + ], deactivate: [ { step: "stopping the service", @@ -568,6 +593,11 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ); const homeDir = yield* Config.String("HOME").pipe(Config.withDefault("")); const installerPath = yield* Config.String("PATH").pipe(Config.withDefault("")); + // The unit exports its name to everything it runs, so this is set for agent + // tool calls and terminals started by the service. + const runningInsideService = + (yield* Config.String(BOOT_SERVICE_UNIT_ENV).pipe(Config.withDefault(""))) === + BOOT_SERVICE_UNIT_FILE; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runner = yield* ProcessRunner.ProcessRunner; @@ -824,7 +854,13 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { // 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) { + // From inside the service the files are written as for start=false, with + // the same guard against a concurrent remote update, and the service + // manager restarts onto them. + const handoff = installed && start && runningInsideService ? manager.handoff : undefined; + const deferred = installed && (!start || handoff !== undefined); + const stopped = installed && start && handoff === undefined; + if (stopped) { yield* runSteps(manager.stop); } @@ -853,7 +889,7 @@ 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) { + if (deferred) { // 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 @@ -872,7 +908,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { 2, )}\n`, ); - if (!start && installed) { + if (deferred) { // 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 @@ -886,7 +922,10 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { } yield* writeDurably(unitPath, manager.render(plan)); - if (start) { + if (handoff !== undefined) { + // The launcher clears the restart marker once it runs this version. + yield* runSteps(handoff); + } else if (start) { yield* runSteps(manager.activate); yield* fs.remove(restartPendingPath, { force: true }); } @@ -895,7 +934,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause, ), Effect.tapError(() => - installed && start ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void, + stopped ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void, ), ); return plan; @@ -912,6 +951,10 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ) { return false; } + if (runningInsideService && manager.handoff !== undefined) { + yield* runSteps(manager.handoff); + return true; + } yield* runSteps(manager.stop); yield* runSteps(manager.activate).pipe( // Same recovery as a failed repair: a service that was running should From 1d31934de11991013df2019c1d0a2c93edbed9e6 Mon Sep 17 00:00:00 2001 From: Rookmate <14072042+rookmate@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:41:25 +0400 Subject: [PATCH 2/6] fix(server): run an in-service install from a transient unit Writing the state file without stopping the launcher first could replace a remote update it had just accepted, and the restart that followed would drop it. Hand the whole install to `systemd-run --user` instead, so it keeps the stop-first path that serialises with the launcher. restart keeps the queued restart: it writes no state. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/cloud/bootService.test.ts | 32 ++++++++-------- apps/server/src/cloud/bootService.ts | 45 ++++++++++++++--------- 2 files changed, 44 insertions(+), 33 deletions(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index ab8985a71b02..36fa5790a546 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -629,31 +629,31 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); - it.effect("updates from inside the service without stopping the process doing it", () => + it.effect("hands an install from inside the service to a transient unit", () => Effect.gen(function* () { const { service, fs, statePath, commands, makeService } = yield* makeHarness(); const path = yield* Path.Path; - yield* service.install(); + const plan = yield* service.install(); + const unit = yield* fs.readFileString(plan.unitPath); + const state = yield* fs.readFileString(statePath); commands.length = 0; + const baseDir = path.dirname(path.dirname(statePath)); const newer = yield* makeService(undefined, "1.2.4", undefined, true); - const plan = yield* newer.install(); + yield* newer.install({ allowDowngrade: true }); expect( commands.filter( - (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), + (command) => + (command.startsWith("systemctl ") || command.startsWith("systemd-run ")) && + !command.includes("show-environment"), ), ).toEqual([ - "systemctl --user daemon-reload", - "systemctl --user enable t3code.service", - "systemctl --user restart --no-block t3code.service", + `systemd-run --user --collect --quiet --setenv=T3_BOOT_SERVICE_UNIT= ${pinnedRuntimePaths(path, baseDir, "1.2.4", "linux").entryPath} service install --base-dir ${baseDir} --allow-downgrade`, ]); - expect(parseServiceState(yield* fs.readFileString(statePath))?.activeVersion).toBe("1.2.4"); - expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.4/t3"); - // Until the launcher comes back on 1.2.4 and clears it. - expect( - yield* fs.exists(path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE)), - ).toBe(true); + // The transient unit writes them after stopping the launcher. + expect(yield* fs.readFileString(statePath)).toBe(state); + expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); }), ); @@ -677,15 +677,15 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); - it.effect("leaves the service running when a handoff from inside it fails", () => + it.effect("leaves the service running when a restart handoff from inside it fails", () => Effect.gen(function* () { const { service, commands, control, makeService } = yield* makeHarness(); yield* service.install(); commands.length = 0; control.failCommand = "systemctl --user daemon-reload"; - const newer = yield* makeService(undefined, "1.2.4", undefined, true); - const error = yield* newer.install().pipe(Effect.flip); + const inside = yield* makeService(undefined, "1.2.3", undefined, true); + const error = yield* inside.restart.pipe(Effect.flip); expect(error._tag).toBe("BootServiceCommandError"); // Nothing was stopped, so there is nothing to bring back. expect( diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 63a4139e564c..df0c3dd564b9 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -229,9 +229,9 @@ export interface BootServiceManager { /** Best-effort recovery after a failed repair of an installed service. */ readonly restart: ReadonlyArray; /** - * Replaces stop + activate when the caller runs inside the service, where a - * stop would kill it before the start. The last entry queues the restart - * with the service manager instead of waiting for it. + * Replaces stop + activate for `restart` when the caller runs inside the + * service, where a stop would kill it before the start. The last entry + * queues the restart with the service manager instead of waiting for it. */ readonly handoff?: ReadonlyArray; /** Uninstall, before the unit file is removed. */ @@ -854,13 +854,25 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { // 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; - // From inside the service the files are written as for start=false, with - // the same guard against a concurrent remote update, and the service - // manager restarts onto them. - const handoff = installed && start && runningInsideService ? manager.handoff : undefined; - const deferred = installed && (!start || handoff !== undefined); - const stopped = installed && start && handoff === undefined; - if (stopped) { + if (installed && start && runningInsideService && manager.kind === "systemd") { + // Stopping the unit from inside it kills this process before the start. + // A transient unit runs the same install from outside, stop first, so it + // still serialises with a remote update the launcher may be starting. + yield* runStep("handing the install to a transient unit", "systemd-run", [ + "--user", + "--collect", + "--quiet", + `--setenv=${BOOT_SERVICE_UNIT_ENV}=`, + runtimePaths.entryPath, + "service", + "install", + "--base-dir", + input.baseDir, + ...(options?.allowDowngrade === true ? ["--allow-downgrade"] : []), + ]); + return plan; + } + if (installed && start) { yield* runSteps(manager.stop); } @@ -889,7 +901,7 @@ 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 (deferred) { + 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 @@ -908,7 +920,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { 2, )}\n`, ); - if (deferred) { + 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 @@ -922,10 +934,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { } yield* writeDurably(unitPath, manager.render(plan)); - if (handoff !== undefined) { - // The launcher clears the restart marker once it runs this version. - yield* runSteps(handoff); - } else if (start) { + if (start) { yield* runSteps(manager.activate); yield* fs.remove(restartPendingPath, { force: true }); } @@ -934,7 +943,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause, ), Effect.tapError(() => - stopped ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void, + installed && start ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void, ), ); return plan; @@ -951,6 +960,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ) { return false; } + // Writes no state, and the launcher lets an update transition in flight + // finish before the queued stop tears it down. if (runningInsideService && manager.handoff !== undefined) { yield* runSteps(manager.handoff); return true; From 5589ddf733a331bfb4f00ef7895225358c73badd Mon Sep 17 00:00:00 2001 From: Rookmate <14072042+rookmate@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:51:59 +0400 Subject: [PATCH 3/6] fix(server): escape $ in the base dir handed to systemd-run systemd expands $VAR in command arguments, so a literal ${...} in the T3 home would reach the transient install altered. The executable path is not expanded and stays as is. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/cloud/bootService.test.ts | 21 +++++++++++++++++++++ apps/server/src/cloud/bootService.ts | 3 ++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 36fa5790a546..b7f254c012fa 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -657,6 +657,27 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect("keeps a literal $ in the base dir it hands to systemd-run", () => + Effect.gen(function* () { + const { service, commands, makeService } = yield* makeHarness(); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* service.install(); + commands.length = 0; + + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dollar-" }); + const baseDir = path.join(home, "${UNSET}", ".t3"); + const inside = yield* makeService(undefined, "1.2.4", baseDir, true); + yield* inside.install(); + + const handoff = commands.find((command) => command.startsWith("systemd-run ")); + expect(handoff).toContain( + `${pinnedRuntimePaths(path, baseDir, "1.2.4", "linux").entryPath} service install`, + ); + expect(handoff).toContain(`--base-dir ${path.join(home, "$${UNSET}", ".t3")}`); + }), + ); + it.effect("restarts from inside the service by queuing the restart", () => Effect.gen(function* () { const { service, commands, makeService } = yield* makeHarness(); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index df0c3dd564b9..368749654e6c 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -867,7 +867,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { "service", "install", "--base-dir", - input.baseDir, + // systemd expands $VAR in arguments (not in the executable path). + input.baseDir.replaceAll("$", () => "$$"), ...(options?.allowDowngrade === true ? ["--allow-downgrade"] : []), ]); return plan; From f4e9d6c3ba53e73b2ccafb2a992d2ab2a6e0a368 Mon Sep 17 00:00:00 2001 From: Rookmate <14072042+rookmate@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:37:32 +0400 Subject: [PATCH 4/6] fix(server): report handed-off installs and queued restarts as pending An in-service install hands its work to a transient unit and an in-service restart only queues one, so neither is done when the command returns. The transient install now appends its output to boot-service.log, restart resolves "queued" instead of true, and onboarding resolves "pending", so `t3 update`, `t3 service install/restart` and `t3 connect` say the switch is under way instead of reporting it finished. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/cli/connect.test.ts | 27 ++++++- apps/server/src/cli/connect.ts | 41 +++++++---- apps/server/src/cli/service.test.ts | 66 +++++++++++++++-- apps/server/src/cli/service.ts | 86 ++++++++++++++++------- apps/server/src/cli/update.ts | 12 +++- apps/server/src/cloud/bootService.test.ts | 32 +++++---- apps/server/src/cloud/bootService.ts | 27 ++++--- 7 files changed, 224 insertions(+), 67 deletions(-) diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index f3cc88d1b58a..3e484a4860c7 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -15,6 +15,7 @@ import { acquireRelayClientForLink, headlessSessionConfig, reportCloudDisconnectResults, + reportServiceOnboarding, } from "./connect.ts"; import { recoverServiceOnboardingOffer } from "./service.ts"; @@ -40,7 +41,7 @@ it.effect("detects headless operation from individual SSH config values", () => it.effect("treats cancelling optional background setup as a successful skip", () => Effect.gen(function* () { const result = yield* recoverServiceOnboardingOffer(Effect.fail(new Terminal.QuitError({}))); - assert.isFalse(result); + assert.equal(result, "skipped"); }), ); @@ -49,10 +50,32 @@ it.effect("keeps a successful connection when a remote service update is pending const result = yield* recoverServiceOnboardingOffer( Effect.fail(new BootService.BootServiceUpdatePendingError()), ); - assert.isFalse(result); + assert.equal(result, "skipped"); }), ); +it.effect("does not announce a background service that is still switching over", () => { + const lines: Array = []; + const testConsole = { + ...globalThis.console, + log: (...args: ReadonlyArray) => { + lines.push(args.map(String).join(" ")); + }, + } satisfies Console.Console; + + return reportServiceOnboarding("pending").pipe( + Effect.provideService(Console.Console, testConsole), + Effect.tap(() => + Effect.sync(() => { + const output = lines.join("\n"); + assert.notInclude(output, "ready"); + assert.notInclude(output, "Start the server"); + assert.include(output, "Background service restarting"); + }), + ), + ); +}); + it.effect("does not install the relay client when the user declines the managed download", () => Effect.gen(function* () { let installCalls = 0; diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 3427941bedeb..aefaf6d550a5 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -53,6 +53,7 @@ import { bootServiceLayer, offerServiceDuringOnboarding, recoverServiceOnboardingOffer, + type ServiceOnboardingOutcome, } from "./service.ts"; const jsonFlag = Flag.Boolean("json").pipe( @@ -670,6 +671,31 @@ const connectLogoutCommand = Command.make("logout", { ), ); +/** What `t3 connect` tells the user once background setup has settled. */ +export const reportServiceOnboarding = Effect.fn("cli.connect.report_service_onboarding")( + function* (outcome: ServiceOnboardingOutcome) { + if (outcome === "pending") { + yield* Console.log( + "\nBackground service restarting\n\nThe server establishes the T3 Connect link once it is back. Check the log above if it does not come back.", + ); + return; + } + if (outcome === "ready") { + const platform = yield* HostProcessPlatform; + yield* Console.log( + platform === "darwin" + ? "\n✓ Background service ready\n\nT3 Code is set to run while you are logged in to this Mac. The server establishes the T3 Connect link on startup." + : "\n✓ Background service ready\n\nT3 Code is set to keep running after you log out. The server establishes the T3 Connect link on startup.", + ); + return; + } + const serveCommand = yield* resolveCliCommand("serve"); + yield* Console.log( + `\nNext\n Start the server with \`${serveCommand}\` to make this machine reachable.`, + ); + }, +); + export const connectCommand = Command.make("connect", { ...projectLocationFlags, headless: headlessFlag, @@ -691,19 +717,8 @@ export const connectCommand = Command.make("connect", { // Authorization is stored. If service setup fails, preserve it and // show how to run the server manually. - const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding); - if (background) { - const platform = yield* HostProcessPlatform; - yield* Console.log( - platform === "darwin" - ? "\n✓ Background service ready\n\nT3 Code is set to run while you are logged in to this Mac. The server establishes the T3 Connect link on startup." - : "\n✓ Background service ready\n\nT3 Code is set to keep running after you log out. The server establishes the T3 Connect link on startup.", - ); - return; - } - const serveCommand = yield* resolveCliCommand("serve"); - yield* Console.log( - `\nNext\n Start the server with \`${serveCommand}\` to make this machine reachable.`, + yield* reportServiceOnboarding( + yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding), ); }), ), diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 74c2489e7b12..32ec8330dc5c 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -3,6 +3,7 @@ import { assert, expect, it } from "@effect/vitest"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as NetService from "@t3tools/shared/Net"; import * as ConfigProvider from "effect/ConfigProvider"; +import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -13,6 +14,7 @@ import { afterEach, vi } from "vite-plus/test"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; import { + formatServiceChange, formatServiceStatus, offerServiceDuringOnboarding, reconcileService, @@ -42,6 +44,24 @@ it("reports the installed service version and host paths", () => { ); }); +it("does not claim a handed-off install has finished", () => { + const plan = { + program: ["/home/me/.t3/runtime/versions/0.0.30/t3", "__service-launcher"], + baseDir: "/home/me/.t3", + logPath: status.logPath, + unitPath: status.unitPath, + }; + expect(formatServiceChange({ changed: true, previouslyInstalled: true, plan }, "0.0.30")).toBe( + `Updated T3 Code service with t3@0.0.30.\nLogs: ${status.logPath}`, + ); + const handedOff = formatServiceChange( + { changed: true, previouslyInstalled: true, plan: { ...plan, handedOff: true } }, + "0.0.30", + ); + expect(handedOff).not.toContain("Updated"); + expect(handedOff).toContain(`Result: ${status.logPath}`); +}); + it("gives a direct repair command for a stale service", () => { assert.include( formatServiceStatus({ ...status, current: false }, "0.0.29"), @@ -97,14 +117,17 @@ it("reports a newer installed service and tells the CLI to catch up to it", () = const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" }; -function makeTestService(serviceStatus: BootService.BootServiceStatus) { +function makeTestService( + serviceStatus: BootService.BootServiceStatus, + restartOutcome: "restarted" | "queued" = "restarted", +) { 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; + return serviceStatus.installed ? restartOutcome : "skipped"; }), install: (options) => Effect.sync(() => { @@ -145,6 +168,39 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands }), ); + it.effect("restart from inside the service says the restart is only queued", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); + const { service } = makeTestService({ ...status, installedVersion: "0.0.30" }, "queued"); + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed(BootService.BootService, service), + ); + const lines: Array = []; + const testConsole = { + ...globalThis.console, + log: (...args: ReadonlyArray) => { + lines.push(args.map(String).join(" ")); + }, + } satisfies Console.Console; + + yield* Command.runWith(serviceCommand, { version: packageJson.version })([ + "restart", + "--base-dir", + baseDir, + ]).pipe( + Effect.provideService(HostProcessEnvironment, {}), + Effect.provideService(Console.Console, testConsole), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), + ); + + const output = lines.join("\n"); + expect(output).not.toContain("Restarted"); + expect(output).toContain("Queued a restart of the T3 Code service on t3@0.0.30"); + expect(output).toContain(`Logs: ${status.logPath}`); + }), + ); + it.effect.each(["install", "update"] as const)( "%s refuses a downgrade before changing the service", (command) => @@ -246,7 +302,7 @@ it.effect("leaves a newer service unchanged during onboarding without prompting" Effect.provide(NodeServices.layer), ); - expect(ready).toBe(false); + expect(ready).toBe("skipped"); expect(installOptions).toEqual([]); }), ); @@ -262,7 +318,7 @@ it.effect("keeps onboarding successful when a newer version appears before insta ), ); - expect(ready).toBe(false); + expect(ready).toBe("skipped"); }), ); @@ -271,6 +327,6 @@ it.effect("keeps the manual-server fallback when background prerequisites fail", const ready = yield* recoverServiceOnboardingOffer( Effect.fail(new BootService.BootServicePrerequisiteError({ problem: "linger-disabled" })), ); - expect(ready).toBe(false); + expect(ready).toBe("skipped"); }), ); diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 692bf4f3a099..1ba1680a32c8 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -63,6 +63,17 @@ export const reconcileService = Effect.fn("cli.service.reconcile")(function* (op } satisfies ServiceReconcileResult; }); +/** What a changed reconcile did, for the commands that report it. */ +export function formatServiceChange( + result: Extract, + cliVersion: string, +): string { + if (result.plan.handedOff === true) { + return `Handed the T3 Code service switch to t3@${cliVersion} to a separate systemd unit, since this session runs inside the service and disconnects when it restarts.\nResult: ${result.plan.logPath}`; + } + return `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${cliVersion}.\nLogs: ${result.plan.logPath}`; +} + export function formatServiceStatus( status: BootService.BootServiceStatus, cliVersion: string, @@ -131,9 +142,7 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip ); return; } - yield* Console.log( - `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`, - ); + yield* Console.log(formatServiceChange(result, packageJson.version)); }), ), ), @@ -156,9 +165,7 @@ const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`); return; } - yield* Console.log( - `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`, - ); + yield* Console.log(formatServiceChange(result, packageJson.version)); }), ), ), @@ -174,11 +181,15 @@ const serviceRestartCommand = Command.make("restart", projectLocationFlags).pipe Effect.gen(function* () { const service = yield* BootService.BootService; const status = yield* service.status; - const restarted = yield* service.restart; + const outcome = yield* service.restart; + const onVersion = + status.installedVersion === undefined ? "" : ` on t3@${status.installedVersion}`; yield* Console.log( - restarted - ? `Restarted the T3 Code service${status.installedVersion === undefined ? "" : ` on t3@${status.installedVersion}`}.` - : "T3 Code service is not installed.", + outcome === "restarted" + ? `Restarted the T3 Code service${onVersion}.` + : outcome === "queued" + ? `Queued a restart of the T3 Code service${onVersion}. This session runs inside the service and disconnects when it goes down.\nLogs: ${status.logPath}` + : "T3 Code service is not installed.", ); }), ), @@ -214,16 +225,23 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( ), ); +/** + * `pending`: an install run inside the service was handed to systemd and + * finishes after this session ends. `skipped`: the server has to be started + * by hand. + */ +export type ServiceOnboardingOutcome = "ready" | "pending" | "skipped"; + export const offerServiceDuringOnboarding = Effect.gen(function* () { const service = yield* BootService.BootService; const status = yield* service.status; const { supported, installed, current } = status; if (!supported) { - return false; + return "skipped" as const; } if (installed && current) { yield* Console.log("T3 Code is already set up to run in the background on this machine."); - return true; + return "ready" as const; } for (const problem of status.problems ?? []) { yield* Console.warn(`[${problem}] ${BootService.formatBootServiceProblem(problem)}`); @@ -237,7 +255,7 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { `A newer t3@${status.installedVersion} background service is installed. Leaving it unchanged.`, ); // This CLI cannot verify the newer service. Keep the manual fallback available. - return false; + return "skipped" as const; } // A LaunchAgent starts at login and dies at logout; there is no // enable-linger equivalent on macOS. Do not promise more than that. @@ -255,35 +273,55 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { }), ); if (!wanted) { - return false; + return "skipped" as const; } const result = yield* reconcileService(); if (result.changed) { yield* Console.log( - `Background service ${result.previouslyInstalled ? "updated" : "installed"}. Logs: ${result.plan.logPath}`, + result.plan.handedOff === true + ? formatServiceChange(result, packageJson.version) + : `Background service ${result.previouslyInstalled ? "updated" : "installed"}. Logs: ${result.plan.logPath}`, ); } - return true; + return result.changed && result.plan.handedOff === true + ? ("pending" as const) + : ("ready" as const); }); export const recoverServiceOnboardingOffer = ( - offer: Effect.Effect, + offer: Effect.Effect< + ServiceOnboardingOutcome, + BootService.BootServiceError | Terminal.QuitError, + R + >, ) => offer.pipe( Effect.catchTags({ - QuitError: () => Effect.succeed(false), + QuitError: () => Effect.succeed("skipped" as const), BootServiceUnsupportedError: (error) => - Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)), + Console.log(`Skipping background setup: ${error.message}`).pipe( + Effect.as("skipped" as const), + ), BootServiceCommandError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + Console.warn(`Background setup did not finish: ${error.message}`).pipe( + Effect.as("skipped" as const), + ), BootServiceInstallError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + Console.warn(`Background setup did not finish: ${error.message}`).pipe( + Effect.as("skipped" as const), + ), BootServicePrerequisiteError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + Console.warn(`Background setup did not finish: ${error.message}`).pipe( + Effect.as("skipped" as const), + ), BootServiceUpdatePendingError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + Console.warn(`Background setup did not finish: ${error.message}`).pipe( + Effect.as("skipped" as const), + ), BootServiceDowngradeRefusedError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + Console.warn(`Background setup did not finish: ${error.message}`).pipe( + Effect.as("skipped" as const), + ), }), ); diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 5a67ba267965..d0a141cd27da 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -550,13 +550,14 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { // 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; + let serviceHandedOff: BootService.BootServicePlan | undefined; if (serviceInstalled && !serviceCurrent) { yield* Console.log( restartService ? "Restarting the background service..." : "Updating the background service...", ); - yield* BootService.BootService.pipe( + const plan = yield* BootService.BootService.pipe( Effect.flatMap((target) => target.install({ allowDowngrade: input.allowDowngrade, start: restartService }), ), @@ -574,7 +575,8 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { }), ), ); - serviceUpdated = restartService; + if (plan.handedOff === true) serviceHandedOff = plan; + else serviceUpdated = restartService; } progress.success(`Installed T3 Code ${targetVersion}`); @@ -583,7 +585,11 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { } else { yield* Console.log(` Run ${runtime.entryPath}\n`); } - if (serviceUpdated) { + if (serviceHandedOff !== undefined) { + yield* Console.log( + ` Background service switching to ${targetVersion} from a separate systemd unit, since this session runs inside it and disconnects when it restarts. Result: ${serviceHandedOff.logPath}`, + ); + } else if (serviceUpdated) { yield* Console.log(` Background service restarted on ${targetVersion}`); } else if (serviceInstalled && serviceCurrent) { yield* Console.log(` Background service already on ${targetVersion}`); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index b7f254c012fa..19c9152acddd 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -522,7 +522,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(status.problems).toContain("restart-pending"); commands.length = 0; - expect(yield* newer.restart).toBe(true); + expect(yield* newer.restart).toBe("restarted"); expect((yield* newer.status).problems).not.toContain("restart-pending"); expect((yield* newer.status).current).toBe(true); }), @@ -576,11 +576,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { 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); + expect(yield* service.restart).toBe("skipped"); yield* service.install(); commands.length = 0; - expect(yield* service.restart).toBe(true); + expect(yield* service.restart).toBe("restarted"); expect( commands.filter( (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), @@ -603,7 +603,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { 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(yield* other.restart).toBe("skipped"); expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([]); }), ); @@ -640,8 +640,12 @@ it.layer(NodeServices.layer)("boot service install", (it) => { const baseDir = path.dirname(path.dirname(statePath)); const newer = yield* makeService(undefined, "1.2.4", undefined, true); - yield* newer.install({ allowDowngrade: true }); + const handedOff = yield* newer.install({ allowDowngrade: true }); + // Nobody waits on it, so the caller learns it was handed off and where + // the outcome lands. + expect(handedOff.handedOff).toBe(true); + expect(plan.handedOff).toBeUndefined(); expect( commands.filter( (command) => @@ -649,7 +653,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { !command.includes("show-environment"), ), ).toEqual([ - `systemd-run --user --collect --quiet --setenv=T3_BOOT_SERVICE_UNIT= ${pinnedRuntimePaths(path, baseDir, "1.2.4", "linux").entryPath} service install --base-dir ${baseDir} --allow-downgrade`, + `systemd-run --user --collect --quiet --property=StandardOutput=append:${plan.logPath} --property=StandardError=append:${plan.logPath} --setenv=T3_BOOT_SERVICE_UNIT= ${pinnedRuntimePaths(path, baseDir, "1.2.4", "linux").entryPath} service install --base-dir ${baseDir} --allow-downgrade`, ]); // The transient unit writes them after stopping the launcher. expect(yield* fs.readFileString(statePath)).toBe(state); @@ -657,7 +661,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); - it.effect("keeps a literal $ in the base dir it hands to systemd-run", () => + it.effect("hands paths to systemd-run in the form systemd reads them", () => Effect.gen(function* () { const { service, commands, makeService } = yield* makeHarness(); const fs = yield* FileSystem.FileSystem; @@ -665,16 +669,20 @@ it.layer(NodeServices.layer)("boot service install", (it) => { yield* service.install(); commands.length = 0; - const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dollar-" }); - const baseDir = path.join(home, "${UNSET}", ".t3"); + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-special-" }); + const baseDir = path.join(home, "100%", "${UNSET}", ".t3"); const inside = yield* makeService(undefined, "1.2.4", baseDir, true); - yield* inside.install(); + const { logPath } = yield* inside.install(); + // systemd expands ${VAR} in arguments only; property values and the + // executable path arrive literally, % included. const handoff = commands.find((command) => command.startsWith("systemd-run ")); + expect(logPath).toContain("100%/${UNSET}"); + expect(handoff).toContain(`--property=StandardOutput=append:${logPath} `); expect(handoff).toContain( `${pinnedRuntimePaths(path, baseDir, "1.2.4", "linux").entryPath} service install`, ); - expect(handoff).toContain(`--base-dir ${path.join(home, "$${UNSET}", ".t3")}`); + expect(handoff).toContain(`--base-dir ${path.join(home, "100%", "$${UNSET}", ".t3")}`); }), ); @@ -685,7 +693,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { commands.length = 0; const inside = yield* makeService(undefined, "1.2.3", undefined, true); - expect(yield* inside.restart).toBe(true); + expect(yield* inside.restart).toBe("queued"); expect( commands.filter( (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 368749654e6c..4b440f4802a9 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -91,6 +91,11 @@ export interface BootServicePlan { readonly baseDir: string; readonly logPath: string; readonly unitPath: string; + /** + * Set by an install run inside the service: a transient unit carries it out + * after this call returns and appends its outcome to `logPath`. + */ + readonly handedOff?: boolean; } /** Pure renderer: service units cannot rely on the user's shell or PATH. */ @@ -564,10 +569,11 @@ export class BootService extends Context.Service< /** * 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. + * another home's service is left alone, which resolves `skipped`. From + * inside the service the restart can only be `queued` with the service + * manager, since the stop ends this process. */ - readonly restart: Effect.Effect; + readonly restart: Effect.Effect<"restarted" | "queued" | "skipped", BootServiceError>; readonly uninstall: Effect.Effect; readonly status: Effect.Effect; } @@ -862,6 +868,11 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { "--user", "--collect", "--quiet", + // Nobody waits on the transient unit; its outcome goes where + // `t3 triage` and the CLI already point. Property values reach + // systemd literally, unlike the unit file, so no specifier escaping. + `--property=StandardOutput=append:${logPath}`, + `--property=StandardError=append:${logPath}`, `--setenv=${BOOT_SERVICE_UNIT_ENV}=`, runtimePaths.entryPath, "service", @@ -871,7 +882,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { input.baseDir.replaceAll("$", () => "$$"), ...(options?.allowDowngrade === true ? ["--allow-downgrade"] : []), ]); - return plan; + return { ...plan, handedOff: true }; } if (installed && start) { yield* runSteps(manager.stop); @@ -953,19 +964,19 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { 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; + if (Option.isNone(unit)) return "skipped" as const; const installedBaseDir = bootServiceBaseDirOf(unit.value); if ( installedBaseDir === undefined || path.resolve(installedBaseDir) !== path.resolve(input.baseDir) ) { - return false; + return "skipped" as const; } // Writes no state, and the launcher lets an update transition in flight // finish before the queued stop tears it down. if (runningInsideService && manager.handoff !== undefined) { yield* runSteps(manager.handoff); - return true; + return "queued" as const; } yield* runSteps(manager.stop); yield* runSteps(manager.activate).pipe( @@ -974,7 +985,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { Effect.tapError(() => runSteps(manager.restart).pipe(Effect.ignore)), ); yield* fs.remove(restartPendingPath, { force: true }); - return true; + return "restarted" as const; }).pipe( Effect.mapError((cause) => cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause, From 54920e464cb6e4284894ca88814c19b3b5cafda1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 11:27:55 +0000 Subject: [PATCH 5/6] test(server): focus in-service command handoff coverage --- apps/server/src/cli/connect.test.ts | 87 +++++++++--- apps/server/src/cli/service.test.ts | 54 +++++--- apps/server/src/cli/update.test.ts | 92 ++++++++++++- apps/server/src/cloud/bootService.test.ts | 156 ++++++++++------------ 4 files changed, 258 insertions(+), 131 deletions(-) diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index 68186e4992be..72a17f56e93e 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as RelayClient from "@t3tools/shared/relayClient"; import { assert, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; @@ -8,6 +9,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as References from "effect/References"; import * as Terminal from "effect/Terminal"; @@ -18,7 +20,7 @@ import { reportCloudDisconnectResults, reportServiceOnboarding, } from "./connect.ts"; -import { recoverServiceOnboardingOffer } from "./service.ts"; +import { offerServiceDuringOnboarding, recoverServiceOnboardingOffer } from "./service.ts"; const readHeadlessSessionConfig = (env: Record) => headlessSessionConfig.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))); @@ -55,28 +57,69 @@ it.effect("keeps a successful connection when a remote service update is pending }), ); -it.effect("does not announce a background service that is still switching over", () => { - const lines: Array = []; - const testConsole = { - ...globalThis.console, - log: (...args: ReadonlyArray) => { - lines.push(args.map(String).join(" ")); - }, - } satisfies Console.Console; - - return reportServiceOnboarding("pending").pipe( - Effect.provideService(Console.Console, testConsole), - Effect.provide(NodeServices.layer), - Effect.tap(() => - Effect.sync(() => { - const output = lines.join("\n"); - assert.notInclude(output, "ready"); - assert.notInclude(output, "Start the server"); - assert.include(output, "Background service restarting"); +it.effect("reports pending onboarding until the handed-off service is ready", () => + Effect.gen(function* () { + const input = yield* Queue.make(); + yield* Queue.offer(input, { + input: Option.some("\r"), + key: { name: "return", ctrl: false, meta: false, shift: false }, + }); + const terminal = Terminal.make({ + columns: Effect.succeed(80), + rows: Effect.succeed(24), + readInput: Effect.succeed(Queue.asDequeue(input)), + readLine: Effect.die("unexpected line input"), + display: () => Effect.void, + }); + const plan = { + program: ["/test/t3", "__service-launcher"], + baseDir: "/test", + unitPath: "/test/t3code.service", + logPath: "/test/boot-service.log", + handedOff: true, + }; + let current = false; + const service = BootService.BootService.of({ + status: Effect.sync(() => ({ + supported: true, + installed: true, + current, + unitPath: plan.unitPath, + logPath: plan.logPath, + })), + install: () => Effect.succeed(plan), + restart: Effect.die("unexpected restart"), + uninstall: Effect.die("unexpected uninstall"), + withStopped: () => Effect.die("unexpected maintenance"), + }); + const lines: string[] = []; + const onboard = recoverServiceOnboardingOffer(offerServiceDuringOnboarding).pipe( + Effect.flatMap(reportServiceOnboarding), + Effect.provideService(BootService.BootService, service), + Effect.provideService(Terminal.Terminal, terminal), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(Console.Console, { + ...globalThis.console, + log: (...args) => { + lines.push(args.map(String).join(" ")); + }, }), - ), - ); -}); + ); + + yield* onboard; + const output = lines.join("\n"); + assert.notInclude(output, "Background service ready"); + assert.notInclude(output, "Start the server"); + assert.include(output, "Background service restarting"); + assert.include(output, plan.logPath); + + current = true; + lines.length = 0; + yield* onboard; + assert.include(lines.join("\n"), "Background service ready"); + assert.notInclude(lines.join("\n"), "restarting"); + }).pipe(Effect.provide(NodeServices.layer)), +); it.effect("does not install the relay client when the user declines the managed download", () => Effect.gen(function* () { diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 143ff644b9d1..dbd0653647ba 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -14,7 +14,6 @@ import { afterEach, vi } from "vite-plus/test"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; import { - formatServiceChange, formatServiceStatus, offerServiceDuringOnboarding, reconcileService, @@ -44,24 +43,6 @@ it("reports the installed service version and host paths", () => { ); }); -it("does not claim a handed-off install has finished", () => { - const plan = { - program: ["/home/me/.t3/runtime/versions/0.0.30/t3", "__service-launcher"], - baseDir: "/home/me/.t3", - logPath: status.logPath, - unitPath: status.unitPath, - }; - expect(formatServiceChange({ changed: true, previouslyInstalled: true, plan }, "0.0.30")).toBe( - `Updated T3 Code service with t3@0.0.30.\nLogs: ${status.logPath}`, - ); - const handedOff = formatServiceChange( - { changed: true, previouslyInstalled: true, plan: { ...plan, handedOff: true } }, - "0.0.30", - ); - expect(handedOff).not.toContain("Updated"); - expect(handedOff).toContain(`Result: ${status.logPath}`); -}); - it("gives a direct repair command for a stale service", () => { assert.include( formatServiceStatus({ ...status, current: false }, "0.0.29"), @@ -202,6 +183,41 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands }), ); + it.effect("install reports a handed-off switch as pending", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); + const { service } = makeTestService({ ...status, current: false }); + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed(BootService.BootService, { + ...service, + install: (options) => + service.install(options).pipe(Effect.map((plan) => ({ ...plan, handedOff: true }))), + }), + ); + const lines: string[] = []; + yield* Command.runWith(serviceCommand, { version: packageJson.version })([ + "install", + "--base-dir", + baseDir, + ]).pipe( + Effect.provideService(HostProcess.Environment, {}), + Effect.provideService(Console.Console, { + ...globalThis.console, + log: (...args) => { + lines.push(args.map(String).join(" ")); + }, + }), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), + ); + + const output = lines.join("\n"); + expect(output).not.toMatch(/(?:Installed|Updated) T3 Code service/); + expect(output).toContain("separate systemd unit"); + expect(output).toContain(status.logPath); + }), + ); + it.effect.each(["install", "update"] as const)( "%s refuses a downgrade before changing the service", (command) => diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index 4d784f04c42c..67ae3bfa5321 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -1,12 +1,23 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { assert, expect, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as HostProcess from "@t3tools/shared/HostProcess"; +import { Command } from "effect/cli"; +import { afterEach, vi } from "vite-plus/test"; -import { repointLauncher, resolveLauncherPath } from "./update.ts"; +import packageJson from "../../package.json" with { type: "json" }; +import * as BootService from "../cloud/bootService.ts"; +import { pinnedRuntimePaths } from "../cloud/pinnedRuntime.ts"; +import { repointLauncher, resolveLauncherPath, updateCommand } from "./update.ts"; + +afterEach(() => vi.restoreAllMocks()); it.layer(NodeServices.layer)("t3 update launcher", (it) => { it.effect("repoints a symlink that lives in a runtime versions tree", () => @@ -101,3 +112,78 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), ); }); + +it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 update service", (it) => { + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "reports the service switch as pending after installing the target runtime", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-service-" }); + const targetVersion = "999.0.0"; + const runtime = pinnedRuntimePaths(path, baseDir, targetVersion, "linux"); + yield* fs.makeDirectory(runtime.versionDir, { recursive: true }); + yield* fs.writeFileString( + runtime.entryPath, + `#!/bin/sh\nprintf 't3 v${targetVersion}\\n'\n`, + ); + yield* fs.chmod(runtime.entryPath, 0o755); + yield* fs.writeFileString(runtime.sentinelPath, `${targetVersion}\n`); + const logPath = path.join(baseDir, "userdata/logs/boot-service.log"); + const unitPath = path.join(baseDir, "t3code.service"); + const installs: Array[0]> = []; + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed(BootService.BootService, { + status: Effect.succeed({ + supported: true, + installed: true, + current: true, + installedVersion: packageJson.version, + installedBaseDir: baseDir, + logPath, + unitPath, + }), + install: (options) => + Effect.sync(() => { + installs.push(options); + return { + program: [runtime.entryPath], + baseDir, + logPath, + unitPath, + handedOff: true, + }; + }), + restart: Effect.die("unexpected restart"), + uninstall: Effect.die("unexpected uninstall"), + withStopped: () => Effect.die("unexpected maintenance"), + }), + ); + const lines: string[] = []; + yield* Command.runWith(updateCommand, { version: packageJson.version })([ + targetVersion, + "--yes", + "--base-dir", + baseDir, + ]).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.IsExecutable, false), + Effect.provideService(HostProcess.Environment, {}), + Effect.provideService(Console.Console, { + ...globalThis.console, + log: (...args) => { + lines.push(args.map(String).join(" ")); + }, + }), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), + ); + + expect(installs).toEqual([{ allowDowngrade: false, start: true }]); + const output = lines.join("\n"); + expect(output).not.toContain("Background service restarted"); + expect(output).toContain(`Background service switching to ${targetVersion}`); + expect(output).toContain(logPath); + }), + ); +}); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index f16a1927ffaa..a64a1b9f4bfb 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -140,6 +140,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n"); const commands: string[] = []; + const invocations: ProcessRunner.ProcessRunInput[] = []; const timeouts = new Map(); const control: { failCommand: string | undefined; @@ -159,17 +160,13 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( ) { const command = `${input.command} ${input.args.join(" ")}`; commands.push(command); + invocations.push(input); timeouts.set(command, input.timeout); const failed = command === control.failCommand; if (!failed && command === "loginctl enable-linger --no-ask-password 501") control.linger = "yes"; if (!failed && command === "systemctl --user enable t3code.service") control.enabled = true; - if ( - !failed && - (command === "systemctl --user restart t3code.service" || - command === "systemctl --user restart --no-block t3code.service") - ) - control.active = true; + if (!failed && command === "systemctl --user restart t3code.service") control.active = true; // A stop that fails (a timeout, say) has still stopped the service. if (command === "systemctl --user stop t3code.service") control.active = false; if ( @@ -249,7 +246,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( ), ); const service = yield* makeService(); - return { service, makeService, fs, statePath, commands, timeouts, control, runtime }; + return { service, makeService, fs, statePath, commands, invocations, timeouts, control, runtime }; }); it.layer(NodeServices.layer)("boot service install", (it) => { @@ -685,100 +682,85 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); - it.effect("hands an install from inside the service to a transient unit", () => + it.effect("hands the whole install to systemd without changing the running service", () => Effect.gen(function* () { - const { service, fs, statePath, commands, makeService } = yield* makeHarness(); + const { fs, commands, invocations, control, makeService } = yield* makeHarness(); const path = yield* Path.Path; - const plan = yield* service.install(); - const unit = yield* fs.readFileString(plan.unitPath); + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-handoff-" }); + const baseDir = path.join(home, "T3 Data 100%", "${UNSET}", "$literal"); + const outside = yield* makeService(undefined, "1.2.3", baseDir); + const plan = yield* outside.install(); + const statePath = path.join(baseDir, "runtime", "service-state.json"); const state = yield* fs.readFileString(statePath); + const unit = yield* fs.readFileString(plan.unitPath); + const inside = yield* makeService(undefined, "1.2.4", baseDir, true); commands.length = 0; + invocations.length = 0; - const baseDir = path.dirname(path.dirname(statePath)); - const newer = yield* makeService(undefined, "1.2.4", undefined, true); - const handedOff = yield* newer.install({ allowDowngrade: true }); + const result = yield* inside.install({ allowDowngrade: true }); - // Nobody waits on it, so the caller learns it was handed off and where - // the outcome lands. - expect(handedOff.handedOff).toBe(true); - expect(plan.handedOff).toBeUndefined(); - expect( - commands.filter( - (command) => - (command.startsWith("systemctl ") || command.startsWith("systemd-run ")) && - !command.includes("show-environment"), - ), - ).toEqual([ - `systemd-run --user --collect --quiet --property=StandardOutput=append:${plan.logPath} --property=StandardError=append:${plan.logPath} --setenv=T3_BOOT_SERVICE_UNIT= ${pinnedRuntimePaths(path, baseDir, "1.2.4", "linux").entryPath} service install --base-dir ${baseDir} --allow-downgrade`, - ]); - // The transient unit writes them after stopping the launcher. expect(yield* fs.readFileString(statePath)).toBe(state); expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); - }), - ); - - it.effect("hands paths to systemd-run in the form systemd reads them", () => - Effect.gen(function* () { - const { service, commands, makeService } = yield* makeHarness(); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* service.install(); - commands.length = 0; + expect(commands).not.toContain("systemctl --user stop t3code.service"); + expect(result).toMatchObject({ handedOff: true, logPath: plan.logPath }); + const handoffs = invocations.filter(({ command }) => command === "systemd-run"); + expect(handoffs).toHaveLength(1); + expect(handoffs[0]?.args).toEqual([ + "--user", + "--collect", + "--quiet", + `--property=StandardOutput=append:${plan.logPath}`, + `--property=StandardError=append:${plan.logPath}`, + "--setenv=T3_BOOT_SERVICE_UNIT=", + pinnedRuntimePaths(path, baseDir, "1.2.4", "linux").entryPath, + "service", + "install", + "--base-dir", + path.join(home, "T3 Data 100%", "$${UNSET}", "$$literal"), + "--allow-downgrade", + ]); - const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-special-" }); - const baseDir = path.join(home, "100%", "${UNSET}", ".t3"); - const inside = yield* makeService(undefined, "1.2.4", baseDir, true); - const { logPath } = yield* inside.install(); - - // systemd expands ${VAR} in arguments only; property values and the - // executable path arrive literally, % included. - const handoff = commands.find((command) => command.startsWith("systemd-run ")); - expect(logPath).toContain("100%/${UNSET}"); - expect(handoff).toContain(`--property=StandardOutput=append:${logPath} `); - expect(handoff).toContain( - `${pinnedRuntimePaths(path, baseDir, "1.2.4", "linux").entryPath} service install`, - ); - expect(handoff).toContain(`--base-dir ${path.join(home, "100%", "$${UNSET}", ".t3")}`); + const handoff = handoffs[0]!; + control.failCommand = `${handoff.command} ${handoff.args.join(" ")}`; + const error = yield* inside.install({ allowDowngrade: true }).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "BootServiceCommandError", exitCode: 1 }); + expect(yield* fs.readFileString(statePath)).toBe(state); + expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); + expect(control.active).toBe(true); + expect(commands).not.toContain("systemctl --user stop t3code.service"); }), ); - it.effect("restarts from inside the service by queuing the restart", () => - Effect.gen(function* () { - const { service, commands, makeService } = yield* makeHarness(); - yield* service.install(); - commands.length = 0; - - const inside = yield* makeService(undefined, "1.2.3", undefined, true); - expect(yield* inside.restart).toBe("queued"); - expect( - commands.filter( - (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), - ), - ).toEqual([ - "systemctl --user daemon-reload", - "systemctl --user enable t3code.service", - "systemctl --user restart --no-block t3code.service", - ]); - }), - ); + it.effect( + "queues an in-service restart without stopping the caller, even on handoff failure", + () => + Effect.gen(function* () { + const { service, fs, statePath, commands, control, makeService } = yield* makeHarness(); + const plan = yield* service.install(); + const state = yield* fs.readFileString(statePath); + const marker = `${plan.baseDir}/runtime/${SERVICE_RESTART_PENDING_FILE}`; + yield* fs.writeFileString(marker, "1.2.3\n"); + const inside = yield* makeService(undefined, "1.2.3", undefined, true); + commands.length = 0; + control.failCommand = "systemctl --user daemon-reload"; - it.effect("leaves the service running when a restart handoff from inside it fails", () => - Effect.gen(function* () { - const { service, commands, control, makeService } = yield* makeHarness(); - yield* service.install(); - commands.length = 0; - control.failCommand = "systemctl --user daemon-reload"; + const error = yield* inside.restart.pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "BootServiceCommandError", exitCode: 1 }); + expect(commands).toEqual(["systemctl --user daemon-reload"]); + expect(control.active).toBe(true); - const inside = yield* makeService(undefined, "1.2.3", undefined, true); - const error = yield* inside.restart.pipe(Effect.flip); - expect(error._tag).toBe("BootServiceCommandError"); - // Nothing was stopped, so there is nothing to bring back. - expect( - commands.filter( - (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), - ), - ).toEqual(["systemctl --user daemon-reload"]); - }), + control.failCommand = undefined; + commands.length = 0; + const outcome = yield* inside.restart; + expect(commands).toEqual([ + "systemctl --user daemon-reload", + "systemctl --user enable t3code.service", + "systemctl --user restart --no-block t3code.service", + ]); + expect(outcome).toBe("queued"); + expect(yield* fs.readFileString(statePath)).toBe(state); + expect(yield* fs.readFileString(marker)).toBe("1.2.3\n"); + }), ); it.effect("restarts an installed service when repair fails", () => From a4c9601be0fcf668b8d8fb582f6f4f02de139ca8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 11:33:50 +0000 Subject: [PATCH 6/6] fix(server): simplify in-service command handoffs --- apps/server/src/cli/connect.ts | 10 ++---- apps/server/src/cli/service.ts | 49 +++++++++---------------- apps/server/src/cli/update.ts | 19 ++++------ apps/server/src/cloud/bootService.ts | 53 +++++++++------------------- 4 files changed, 42 insertions(+), 89 deletions(-) diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 917e829bfed0..9cc59335115b 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -45,11 +45,6 @@ import * as ExternalLauncher from "../process/externalLauncher.ts"; import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; import { resolveCliCommand } from "./invocation.ts"; -import { - offerServiceDuringOnboarding, - recoverServiceOnboardingOffer, - type ServiceOnboardingOutcome, -} from "./service.ts"; import * as CliService from "./service.ts"; const jsonFlag = Flag.Boolean("json").pipe( @@ -689,9 +684,8 @@ const connectLogoutCommand = Command.make("logout", { ), ); -/** What `t3 connect` tells the user once background setup has settled. */ export const reportServiceOnboarding = Effect.fn("cli.connect.report_service_onboarding")( - function* (outcome: ServiceOnboardingOutcome) { + function* (outcome: CliService.ServiceOnboardingOutcome) { if (outcome === "pending") { yield* Console.log( "\nBackground service restarting\n\nThe server establishes the T3 Connect link once it is back. Check the log above if it does not come back.", @@ -731,7 +725,7 @@ export const connectCommand = Command.make("connect", { yield* Console.log(`✓ Authorized${connectedAs(linked.identity)}`); yield* reportServiceOnboarding( - yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding), + yield* CliService.recoverServiceOnboardingOffer(CliService.offerServiceDuringOnboarding), ); }), ), diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 1b0f1e9b14b8..352fa86756c9 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -58,15 +58,13 @@ export const reconcileService = Effect.fn("cli.service.reconcile")(function* (op } satisfies ServiceReconcileResult; }); -/** What a changed reconcile did, for the commands that report it. */ -export function formatServiceChange( +function formatServiceChange( result: Extract, - cliVersion: string, ): string { if (result.plan.handedOff === true) { - return `Handed the T3 Code service switch to t3@${cliVersion} to a separate systemd unit, since this session runs inside the service and disconnects when it restarts.\nResult: ${result.plan.logPath}`; + return `Handed the T3 Code service switch to t3@${packageJson.version} to a separate systemd unit, since this session runs inside the service and disconnects when it restarts.\nResult: ${result.plan.logPath}`; } - return `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${cliVersion}.\nLogs: ${result.plan.logPath}`; + return `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`; } export function formatServiceStatus( @@ -137,7 +135,7 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip ); return; } - yield* Console.log(formatServiceChange(result, packageJson.version)); + yield* Console.log(formatServiceChange(result)); }), ), ), @@ -158,7 +156,7 @@ const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`); return; } - yield* Console.log(formatServiceChange(result, packageJson.version)); + yield* Console.log(formatServiceChange(result)); }), ), ), @@ -218,11 +216,6 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( ), ); -/** - * `pending`: an install run inside the service was handed to systemd and - * finishes after this session ends. `skipped`: the server has to be started - * by hand. - */ export type ServiceOnboardingOutcome = "ready" | "pending" | "skipped"; export const offerServiceDuringOnboarding = Effect.gen(function* () { @@ -269,7 +262,7 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { if (result.changed) { yield* Console.log( result.plan.handedOff === true - ? formatServiceChange(result, packageJson.version) + ? formatServiceChange(result) : `Background service ${result.previouslyInstalled ? "updated" : "installed"}. Logs: ${result.plan.logPath}`, ); } @@ -278,6 +271,11 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { : ("ready" as const); }); +const reportServiceSetupFailure = (error: BootService.BootServiceError) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe( + Effect.as("skipped" as const), + ); + export const recoverServiceOnboardingOffer = ( offer: Effect.Effect< ServiceOnboardingOutcome, @@ -292,26 +290,11 @@ export const recoverServiceOnboardingOffer = ( Console.log(`Skipping background setup: ${error.message}`).pipe( Effect.as("skipped" as const), ), - BootServiceCommandError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe( - Effect.as("skipped" as const), - ), - BootServiceInstallError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe( - Effect.as("skipped" as const), - ), - BootServicePrerequisiteError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe( - Effect.as("skipped" as const), - ), - BootServiceUpdatePendingError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe( - Effect.as("skipped" as const), - ), - BootServiceDowngradeRefusedError: (error) => - Console.warn(`Background setup did not finish: ${error.message}`).pipe( - Effect.as("skipped" as const), - ), + BootServiceCommandError: reportServiceSetupFailure, + BootServiceInstallError: reportServiceSetupFailure, + BootServicePrerequisiteError: reportServiceSetupFailure, + BootServiceUpdatePendingError: reportServiceSetupFailure, + BootServiceDowngradeRefusedError: reportServiceSetupFailure, }), ); diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 5a93bec2236a..3d18f8015a29 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -490,19 +490,16 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { targetEntryPath: runtime.entryPath, }); - // 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. - let serviceUpdated = false; - let serviceHandedOff: BootService.BootServicePlan | undefined; + // Use today's service manager for the target version. Inside the service it hands off to + // the target CLI, so stopping this process cannot interrupt the switch. + let servicePlan: BootService.BootServicePlan | undefined; if (serviceInstalled && !serviceCurrent) { yield* Console.log( restartService ? "Restarting the background service..." : "Updating the background service...", ); - const plan = yield* BootService.BootService.pipe( + servicePlan = yield* BootService.BootService.pipe( Effect.flatMap((target) => target.install({ allowDowngrade: input.allowDowngrade, start: restartService }), ), @@ -520,8 +517,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { }), ), ); - if (plan.handedOff === true) serviceHandedOff = plan; - else serviceUpdated = restartService; } progress.success(`Installed T3 Code ${targetVersion}`); @@ -530,11 +525,11 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { } else { yield* Console.log(` Run ${runtime.entryPath}\n`); } - if (serviceHandedOff !== undefined) { + if (servicePlan?.handedOff === true) { yield* Console.log( - ` Background service switching to ${targetVersion} from a separate systemd unit, since this session runs inside it and disconnects when it restarts. Result: ${serviceHandedOff.logPath}`, + ` Background service switching to ${targetVersion} from a separate systemd unit, since this session runs inside it and disconnects when it restarts. Result: ${servicePlan.logPath}`, ); - } else if (serviceUpdated) { + } else if (servicePlan !== undefined && restartService) { yield* Console.log(` Background service restarted on ${targetVersion}`); } else if (serviceInstalled && serviceCurrent) { yield* Console.log(` Background service already on ${targetVersion}`); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index abc223168ae0..5824de4a9481 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -75,10 +75,7 @@ export interface BootServicePlan { readonly baseDir: string; readonly logPath: string; readonly unitPath: string; - /** - * Set by an install run inside the service: a transient unit carries it out - * after this call returns and appends its outcome to `logPath`. - */ + /** The manager accepted a detached install; its outcome is written to `logPath`. */ readonly handedOff?: boolean; } @@ -211,11 +208,7 @@ export interface BootServiceManager { readonly stop: ReadonlyArray; readonly activate: ReadonlyArray; readonly restart: ReadonlyArray; - /** - * Replaces stop + activate for `restart` when the caller runs inside the - * service, where a stop would kill it before the start. The last entry - * queues the restart with the service manager instead of waiting for it. - */ + /** Queue a restart owned by the manager, since stopping would kill the caller. */ readonly handoff?: ReadonlyArray; readonly deactivate: ReadonlyArray; readonly finalize: ReadonlyArray; @@ -232,6 +225,18 @@ function systemdManager(input: { "user", BOOT_SERVICE_UNIT_FILE, ); + const prepareActivation = [ + { + step: "reloading systemd user units", + command: "systemctl", + args: ["--user", "daemon-reload"], + }, + { + step: "enabling the service", + command: "systemctl", + args: ["--user", "enable", BOOT_SERVICE_UNIT_FILE], + }, + ]; return { kind: "systemd", unitPath, @@ -245,16 +250,7 @@ function systemdManager(input: { }, ], activate: [ - { - step: "reloading systemd user units", - command: "systemctl", - args: ["--user", "daemon-reload"], - }, - { - step: "enabling the service", - command: "systemctl", - args: ["--user", "enable", BOOT_SERVICE_UNIT_FILE], - }, + ...prepareActivation, { step: "starting the service", command: "systemctl", @@ -269,18 +265,7 @@ function systemdManager(input: { }, ], handoff: [ - { - step: "reloading systemd user units", - command: "systemctl", - args: ["--user", "daemon-reload"], - }, - { - step: "enabling the service", - command: "systemctl", - args: ["--user", "enable", BOOT_SERVICE_UNIT_FILE], - }, - // The user manager owns the queued job and finishes it after the stop - // has killed this process along with the rest of the unit. + ...prepareActivation, { step: "restarting the service", command: "systemctl", @@ -564,8 +549,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ); const homeDir = yield* Config.String("HOME").pipe(Config.withDefault("")); const installerPath = yield* Config.String("PATH").pipe(Config.withDefault("")); - // The unit exports its name to everything it runs, so this is set for agent - // tool calls and terminals started by the service. const runningInsideService = (yield* Config.String(BOOT_SERVICE_UNIT_ENV).pipe(Config.withDefault(""))) === BOOT_SERVICE_UNIT_FILE; @@ -825,9 +808,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { "--user", "--collect", "--quiet", - // Nobody waits on the transient unit; its outcome goes where - // `t3 triage` and the CLI already point. Property values reach - // systemd literally, unlike the unit file, so no specifier escaping. + // Transient properties reach systemd literally, unlike unit-file specifiers. `--property=StandardOutput=append:${logPath}`, `--property=StandardError=append:${logPath}`, `--setenv=${BOOT_SERVICE_UNIT_ENV}=`,