Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b4ef4e6
fix(server): t3 update and t3 service restart run from inside T3 no l…
rookmate Sep 30, 2026
1d31934
fix(server): run an in-service install from a transient unit
rookmate Sep 30, 2026
5589ddf
fix(server): escape $ in the base dir handed to systemd-run
rookmate Sep 30, 2026
f4e9d6c
fix(server): report handed-off installs and queued restarts as pending
rookmate Sep 30, 2026
6ca7d57
Merge branch 'main' into fix/cli-service-commands-inside-service
rookmate Oct 1, 2026
9374ea9
Merge branch 'main' into fix/cli-service-commands-inside-service
rookmate Oct 3, 2026
ac79a27
Merge branch 'main' into fix/cli-service-commands-inside-service
rookmate Oct 6, 2026
da5dd60
Merge branch 'main' into fix/cli-service-commands-inside-service
rookmate Oct 7, 2026
246214a
Merge branch 'main' into fix/cli-service-commands-inside-service
rookmate Oct 9, 2026
6050b7d
Merge branch 'main' into fix/cli-service-commands-inside-service
rookmate Oct 12, 2026
4a40367
Merge branch 'main' into fix/cli-service-commands-inside-service
rookmate Oct 13, 2026
8d80261
Merge branch 'main' into fix/cli-service-commands-inside-service
rookmate Oct 16, 2026
9ae6f5b
Merge branch 'main' into fix/cli-service-commands-inside-service
rookmate Oct 16, 2026
6df1f95
Merge remote-tracking branch 'origin/main' into fix/cli-service-comma…
juliusmarminge Oct 19, 2026
54920e4
test(server): focus in-service command handoff coverage
juliusmarminge Oct 19, 2026
a4c9601
fix(server): simplify in-service command handoffs
juliusmarminge Oct 19, 2026
0e6744e
Merge remote-tracking branch 'origin/main' into L39-14361
juliusmarminge Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion apps/server/src/cloud/bootService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" ||
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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" } : {}),
},
}),
),
Expand Down Expand Up @@ -622,6 +629,73 @@ it.layer(NodeServices.layer)("boot service install", (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;
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);
yield* newer.install({ allowDowngrade: true });

expect(
commands.filter(
(command) =>
(command.startsWith("systemctl ") || command.startsWith("systemd-run ")) &&
!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`,
]);
// 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("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 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 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"]);
}),
);

it.effect("restarts an installed service when repair fails", () =>
Effect.gen(function* () {
const { service, commands, control } = yield* makeHarness();
Expand Down
54 changes: 54 additions & 0 deletions apps/server/src/cloud/bootService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,12 @@ export interface BootServiceManager {
readonly activate: ReadonlyArray<BootServiceStep>;
/** Best-effort recovery after a failed repair of an installed service. */
readonly restart: ReadonlyArray<BootServiceStep>;
/**
* 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<BootServiceStep>;
/** Uninstall, before the unit file is removed. */
readonly deactivate: ReadonlyArray<BootServiceStep>;
/** Uninstall, after the unit file is removed. */
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -824,6 +854,24 @@ 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 && 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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
...(options?.allowDowngrade === true ? ["--allow-downgrade"] : []),
]);
return plan;
}
if (installed && start) {
yield* runSteps(manager.stop);
}
Expand Down Expand Up @@ -912,6 +960,12 @@ 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;
}
yield* runSteps(manager.stop);
yield* runSteps(manager.activate).pipe(
// Same recovery as a failed repair: a service that was running should
Expand Down
Loading