Skip to content

Commit 0905300

Browse files
vitalyiegorovclaude
andcommitted
fix(server): keep a failed service start out of the current state
On launchd, `t3 service install` writes the plist and state before `launchctl bootstrap`. When bootstrap fails, nothing on disk records it, so the next `t3 service install` reports "already installed" while the previous launcher keeps running. Write the existing `.restart-pending` marker on every install, not only for `start: false`. A successful start still removes it; a failed one now leaves `status` reporting `restart-pending`, so the retry repairs the service. Fixes #12197 Co-Authored-By: Claude Fable 5.1 <[email protected]>
1 parent 611132c commit 0905300

2 files changed

Lines changed: 46 additions & 7 deletions

File tree

‎apps/server/src/cloud/bootService.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
359359
current: true,
360360
installedVersion: "1.2.3",
361361
});
362+
// A started install that reached the end removes its own marker.
363+
const path = yield* Path.Path;
364+
expect(
365+
yield* fs.exists(path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE)),
366+
).toBe(false);
362367
const pendingState = JSON.stringify({
363368
protocol: SERVICE_LAUNCHER_PROTOCOL,
364369
activeVersion: "1.2.3",
@@ -800,6 +805,40 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
800805
}),
801806
);
802807

808+
it.effect("keeps a launch agent install that failed to start out of the current state", () =>
809+
Effect.gen(function* () {
810+
const { service, fs, statePath, commands, control } = yield* makeHarness("darwin");
811+
const path = yield* Path.Path;
812+
const plan = yield* service.install();
813+
const markerPath = path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE);
814+
expect(yield* fs.exists(markerPath)).toBe(false);
815+
control.failCommand = `launchctl bootstrap gui/501 ${plan.unitPath}`;
816+
817+
expect((yield* service.install().pipe(Effect.flip))._tag).toBe("BootServiceCommandError");
818+
819+
// A failed bootstrap leaves nothing else on disk to tell the difference
820+
// from a healthy install, so without the marker `t3 service install`
821+
// would call itself already installed and never retry.
822+
expect(yield* fs.readFileString(markerPath)).toBe("1.2.3\n");
823+
const failed = yield* service.status;
824+
expect(failed.problems).toEqual(["restart-pending"]);
825+
expect(failed.current).toBe(false);
826+
827+
// What `reconcileService` does with a status that is not current: install again.
828+
control.failCommand = undefined;
829+
commands.length = 0;
830+
yield* service.install();
831+
832+
expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([
833+
"launchctl bootout --wait gui/501/com.t3tools.t3code.service",
834+
"launchctl enable gui/501/com.t3tools.t3code.service",
835+
`launchctl bootstrap gui/501 ${plan.unitPath}`,
836+
]);
837+
expect(yield* fs.exists(markerPath)).toBe(false);
838+
expect((yield* service.status).current).toBe(true);
839+
}),
840+
);
841+
803842
it.effect("restarts without overwriting a pending remote update on macOS", () =>
804843
Effect.gen(function* () {
805844
const { service, fs, statePath, commands } = yield* makeHarness("darwin");

‎apps/server/src/cloud/bootService.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -853,13 +853,13 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
853853
yield* fs
854854
.makeDirectory(path.dirname(unitPath), { recursive: true })
855855
.pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
856-
if (!start && installed) {
857-
// Written first: once the files below name the new version, the
858-
// running service is behind them, and a failure between the two
859-
// writes must not leave it looking current. The launcher removes the
860-
// marker when it starts, `restart` and a started install do too.
861-
yield* fs.writeFileString(restartPendingPath, `${input.cliVersion}\n`, { mode: 0o600 });
862-
}
856+
// Written first, on every install: once the files below name the new
857+
// version, whatever is running is behind them. A started install removes
858+
// the marker after `activate`, and so do `restart` and the launcher when
859+
// it comes up on that version, so a failed start leaves `status`
860+
// reporting `restart-pending` and the next install repairs it instead
861+
// of calling itself current (#12197).
862+
yield* fs.writeFileString(restartPendingPath, `${input.cliVersion}\n`, { mode: 0o600 });
863863
yield* writeDurably(
864864
statePath,
865865
// @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document.

0 commit comments

Comments
 (0)