diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 4d8bae4377c4..390279675e43 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -359,6 +359,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { current: true, installedVersion: "1.2.3", }); + // A started install that reached the end removes its own marker. + const path = yield* Path.Path; + expect( + yield* fs.exists(path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE)), + ).toBe(false); // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document. const pendingState = JSON.stringify({ protocol: SERVICE_LAUNCHER_PROTOCOL, @@ -803,6 +808,40 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect("keeps a launch agent install that failed to start out of the current state", () => + Effect.gen(function* () { + const { service, fs, statePath, commands, control } = yield* makeHarness("darwin"); + const path = yield* Path.Path; + const plan = yield* service.install(); + const markerPath = path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE); + expect(yield* fs.exists(markerPath)).toBe(false); + control.failCommand = `launchctl bootstrap gui/501 ${plan.unitPath}`; + + expect((yield* service.install().pipe(Effect.flip))._tag).toBe("BootServiceCommandError"); + + // A failed bootstrap leaves nothing else on disk to tell the difference + // from a healthy install, so without the marker `t3 service install` + // would call itself already installed and never retry. + expect(yield* fs.readFileString(markerPath)).toBe("1.2.3\n"); + const failed = yield* service.status; + expect(failed.problems).toEqual(["restart-pending"]); + expect(failed.current).toBe(false); + + // What `reconcileService` does with a status that is not current: install again. + control.failCommand = undefined; + commands.length = 0; + yield* service.install(); + + expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ + "launchctl bootout --wait gui/501/com.t3tools.t3code.service", + "launchctl enable gui/501/com.t3tools.t3code.service", + `launchctl bootstrap gui/501 ${plan.unitPath}`, + ]); + expect(yield* fs.exists(markerPath)).toBe(false); + expect((yield* service.status).current).toBe(true); + }), + ); + it.effect("restarts without overwriting a pending remote update on macOS", () => Effect.gen(function* () { const { service, fs, statePath, commands } = yield* makeHarness("darwin"); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 0b102579887a..9ec544b39254 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -853,13 +853,13 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { yield* fs .makeDirectory(path.dirname(unitPath), { recursive: true }) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - if (!start && installed) { - // Written first: once the files below name the new version, the - // running service is behind them, and a failure between the two - // writes must not leave it looking current. The launcher removes the - // marker when it starts, `restart` and a started install do too. - yield* fs.writeFileString(restartPendingPath, `${input.cliVersion}\n`, { mode: 0o600 }); - } + // Written first, on every install: once the files below name the new + // version, whatever is running is behind them. A started install removes + // the marker after `activate`, and so do `restart` and the launcher when + // it comes up on that version, so a failed start leaves `status` + // reporting `restart-pending` and the next install repairs it instead + // of calling itself current (#12197). + yield* fs.writeFileString(restartPendingPath, `${input.cliVersion}\n`, { mode: 0o600 }); yield* writeDurably( statePath, // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document.