diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 814d470cad410..33b95e7bc9d5e 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -120,11 +120,18 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( linger: string; enabled: boolean; active: boolean; + // launchd can load a bootstrapped job yet leave its RunAtLoad spawn pending. + bootstrapSpawns: boolean; + launchdLoaded: boolean; + launchdRunning: boolean; } = { failCommand: undefined, linger: "yes", enabled: true, active: true, + bootstrapSpawns: true, + launchdLoaded: false, + launchdRunning: false, }; const runner = ProcessRunner.ProcessRunner.of({ run: Effect.fn("test.run_boot_service_command")(function* ( @@ -134,13 +141,27 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( commands.push(command); invocations.push(input); timeouts.set(command, input.timeout); - const failed = command === control.failCommand; + const launchdVerb = input.command === "launchctl" ? input.args[0] : undefined; + // launchd rejects bootstrap of a loaded job and kickstart of an unloaded one. + const failed = + command === control.failCommand || + (launchdVerb === "bootstrap" && control.launchdLoaded) || + (launchdVerb === "kickstart" && !control.launchdLoaded); 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; // A timed-out stop may still stop the service. if (command === "systemctl --user stop t3code.service") control.active = false; + if (launchdVerb === "bootout") { + control.launchdRunning = false; + if (!failed) control.launchdLoaded = false; + } + if (!failed && launchdVerb === "bootstrap") { + control.launchdLoaded = true; + control.launchdRunning = control.bootstrapSpawns; + } + if (!failed && launchdVerb === "kickstart") control.launchdRunning = true; if ( control.stateAfterStop !== undefined && (command === "systemctl --user stop t3code.service" || @@ -497,6 +518,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { : [ "launchctl bootout --wait gui/501/com.t3tools.t3code.service", `launchctl bootstrap gui/501 ${plan.unitPath}`, + "launchctl kickstart gui/501/com.t3tools.t3code.service", ], ); }), @@ -951,6 +973,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { Effect.gen(function* () { const { service, control } = yield* makeHarness("darwin"); yield* service.install(); + control.launchdLoaded = false; control.failCommand = "launchctl bootout --wait gui/501/com.t3tools.t3code.service"; yield* service.install(); @@ -983,4 +1006,25 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect((yield* service.status).current).toBe(true); }), ); + + // regression: #29522 + it.effect.each(["install", "withStopped after a failed stop"] as const)( + "runs a launch agent that launchd leaves pending after bootstrap via %s", + (operation) => + Effect.gen(function* () { + const { service, control } = yield* makeHarness("darwin"); + yield* service.install(); + control.bootstrapSpawns = false; + + if (operation === "install") { + yield* service.install(); + } else { + // The bootout timed out with the job still loaded, so recovery's bootstrap is rejected. + control.failCommand = "launchctl bootout --wait gui/501/com.t3tools.t3code.service"; + yield* service.withStopped(Effect.void).pipe(Effect.flip); + } + + expect(control.launchdRunning, "launch agent is loaded but not running").toBe(true); + }), + ); }); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 88d477974ae0a..8da8dc10d88d6 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -296,7 +296,7 @@ function launchdManager(input: { ); const domainTarget = `gui/${input.uid}`; const serviceTarget = `${domainTarget}/${BOOT_SERVICE_LAUNCHD_LABEL}`; - // Unloaded jobs may reject bootout/enable. Bootstrap starts RunAtLoad/KeepAlive jobs; kickstart would restart them. + // Unloaded jobs may reject bootout/enable. Bootstrap can leave RunAtLoad jobs pending; plain kickstart starts them. return { kind: "launchd", unitPath, @@ -328,12 +328,25 @@ function launchdManager(input: { command: "launchctl", args: ["bootstrap", domainTarget, unitPath], }, + { + step: "starting the launch agent", + command: "launchctl", + args: ["kickstart", serviceTarget], + }, ], + // A failed stop can leave the job loaded, so bootstrap may be rejected; kickstart still + // starts it, and fails on its own when bootstrap left nothing loaded. restart: [ { step: "restarting the service after a failed update", command: "launchctl", args: ["bootstrap", domainTarget, unitPath], + optional: true, + }, + { + step: "starting the launch agent after a failed update", + command: "launchctl", + args: ["kickstart", serviceTarget], }, ], // Persisted launchctl disable overrides would block reinstall.