refactor: extract daemon launchd recovery helper

This commit is contained in:
Peter Steinberger 2026-04-05 09:16:16 +01:00
parent 92c498cf7b
commit aa497e9c52
No known key found for this signature in database
8 changed files with 183 additions and 66 deletions

View File

@ -0,0 +1,67 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const launchAgentPlistExists = vi.hoisted(() => vi.fn());
const repairLaunchAgentBootstrap = vi.hoisted(() => vi.fn());
vi.mock("../../daemon/launchd.js", () => ({
launchAgentPlistExists: (env: Record<string, string | undefined>) => launchAgentPlistExists(env),
repairLaunchAgentBootstrap: (args: { env?: Record<string, string | undefined> }) =>
repairLaunchAgentBootstrap(args),
}));
let recoverInstalledLaunchAgent: typeof import("./launchd-recovery.js").recoverInstalledLaunchAgent;
let LAUNCH_AGENT_RECOVERY_MESSAGE: typeof import("./launchd-recovery.js").LAUNCH_AGENT_RECOVERY_MESSAGE;
describe("recoverInstalledLaunchAgent", () => {
beforeAll(async () => {
({ recoverInstalledLaunchAgent, LAUNCH_AGENT_RECOVERY_MESSAGE } =
await import("./launchd-recovery.js"));
});
beforeEach(() => {
launchAgentPlistExists.mockReset();
repairLaunchAgentBootstrap.mockReset();
launchAgentPlistExists.mockResolvedValue(false);
repairLaunchAgentBootstrap.mockResolvedValue({ ok: true, status: "repaired" });
});
it("returns null outside macOS", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
await expect(recoverInstalledLaunchAgent({ result: "started" })).resolves.toBeNull();
expect(launchAgentPlistExists).not.toHaveBeenCalled();
});
it("returns null when the LaunchAgent plist is missing", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
launchAgentPlistExists.mockResolvedValue(false);
await expect(recoverInstalledLaunchAgent({ result: "started" })).resolves.toBeNull();
expect(repairLaunchAgentBootstrap).not.toHaveBeenCalled();
});
it("returns a loaded recovery result when bootstrap repair succeeds", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
launchAgentPlistExists.mockResolvedValue(true);
await expect(recoverInstalledLaunchAgent({ result: "restarted" })).resolves.toEqual({
result: "restarted",
loaded: true,
message: LAUNCH_AGENT_RECOVERY_MESSAGE,
});
expect(launchAgentPlistExists).toHaveBeenCalledWith(process.env);
expect(repairLaunchAgentBootstrap).toHaveBeenCalledWith({ env: process.env });
});
it("returns null when bootstrap repair fails", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
launchAgentPlistExists.mockResolvedValue(true);
repairLaunchAgentBootstrap.mockResolvedValue({
ok: false,
status: "kickstart-failed",
detail: "permission denied",
});
await expect(recoverInstalledLaunchAgent({ result: "started" })).resolves.toBeNull();
});
});

View File

@ -0,0 +1,40 @@
import { launchAgentPlistExists, repairLaunchAgentBootstrap } from "../../daemon/launchd.js";
const LAUNCH_AGENT_RECOVERY_MESSAGE =
"Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.";
type LaunchAgentRecoveryAction = "started" | "restarted";
type LaunchAgentRecoveryResult = {
result: LaunchAgentRecoveryAction;
loaded: true;
message: string;
};
export async function recoverInstalledLaunchAgent(params: {
result: LaunchAgentRecoveryAction;
env?: Record<string, string | undefined>;
}): Promise<LaunchAgentRecoveryResult | null> {
if (process.platform !== "darwin") {
return null;
}
const env = params.env ?? (process.env as Record<string, string | undefined>);
const plistExists = await launchAgentPlistExists(env).catch(() => false);
if (!plistExists) {
return null;
}
const repaired = await repairLaunchAgentBootstrap({ env }).catch(() => ({
ok: false as const,
status: "bootstrap-failed" as const,
}));
if (!repaired.ok) {
return null;
}
return {
result: params.result,
loaded: true,
message: LAUNCH_AGENT_RECOVERY_MESSAGE,
};
}
export { LAUNCH_AGENT_RECOVERY_MESSAGE };

View File

@ -30,14 +30,14 @@ type RestartPostCheckContext = {
fail: (message: string, hints?: string[]) => void;
};
type NotLoadedActionResult = {
type ServiceRecoveryResult = {
result: "started" | "stopped" | "restarted";
message?: string;
warnings?: string[];
loaded?: boolean;
};
type NotLoadedActionContext = {
type ServiceRecoveryContext = {
json: boolean;
stdout: Writable;
fail: (message: string, hints?: string[]) => void;
@ -187,7 +187,7 @@ export async function runServiceStart(params: {
service: GatewayService;
renderStartHints: () => string[];
opts?: DaemonLifecycleOptions;
onNotLoaded?: (ctx: NotLoadedActionContext) => Promise<NotLoadedActionResult | null>;
onNotLoaded?: (ctx: ServiceRecoveryContext) => Promise<ServiceRecoveryResult | null>;
}) {
const json = Boolean(params.opts?.json);
const { stdout, emit, fail } = createDaemonActionContext({ action: "start", json });
@ -278,7 +278,7 @@ export async function runServiceStop(params: {
serviceNoun: string;
service: GatewayService;
opts?: DaemonLifecycleOptions;
onNotLoaded?: (ctx: NotLoadedActionContext) => Promise<NotLoadedActionResult | null>;
onNotLoaded?: (ctx: ServiceRecoveryContext) => Promise<ServiceRecoveryResult | null>;
}) {
const json = Boolean(params.opts?.json);
const { stdout, emit, fail } = createDaemonActionContext({ action: "stop", json });
@ -349,12 +349,12 @@ export async function runServiceRestart(params: {
opts?: DaemonLifecycleOptions;
checkTokenDrift?: boolean;
postRestartCheck?: (ctx: RestartPostCheckContext) => Promise<GatewayServiceRestartResult | void>;
onNotLoaded?: (ctx: NotLoadedActionContext) => Promise<NotLoadedActionResult | null>;
onNotLoaded?: (ctx: ServiceRecoveryContext) => Promise<ServiceRecoveryResult | null>;
}): Promise<boolean> {
const json = Boolean(params.opts?.json);
const { stdout, emit, fail } = createDaemonActionContext({ action: "restart", json });
const warnings: string[] = [];
let handledNotLoaded: NotLoadedActionResult | null = null;
let handledRecovery: ServiceRecoveryResult | null = null;
let recoveredLoadedState: boolean | null = null;
const emitScheduledRestart = (
restartStatus: ReturnType<typeof describeGatewayServiceRestart>,
@ -397,12 +397,12 @@ export async function runServiceRestart(params: {
if (!loaded) {
try {
handledNotLoaded = (await params.onNotLoaded?.({ json, stdout, fail })) ?? null;
handledRecovery = (await params.onNotLoaded?.({ json, stdout, fail })) ?? null;
} catch (err) {
fail(`${params.serviceNoun} restart failed: ${String(err)}`);
return false;
}
if (!handledNotLoaded) {
if (!handledRecovery) {
await handleServiceNotLoaded({
serviceNoun: params.serviceNoun,
service: params.service,
@ -413,10 +413,10 @@ export async function runServiceRestart(params: {
});
return false;
}
if (handledNotLoaded.warnings?.length) {
warnings.push(...handledNotLoaded.warnings);
if (handledRecovery.warnings?.length) {
warnings.push(...handledRecovery.warnings);
}
recoveredLoadedState = handledNotLoaded.loaded ?? null;
recoveredLoadedState = handledRecovery.loaded ?? null;
}
if (loaded && params.checkTokenDrift) {
@ -486,12 +486,12 @@ export async function runServiceRestart(params: {
emit({
ok: true,
result: "restarted",
message: handledNotLoaded?.message,
message: handledRecovery?.message,
service: buildDaemonServiceSnapshot(params.service, restarted),
warnings: warnings.length ? warnings : undefined,
});
if (!json && handledNotLoaded?.message) {
defaultRuntime.log(handledNotLoaded.message);
if (!json && handledRecovery?.message) {
defaultRuntime.log(handledRecovery.message);
}
return true;
} catch (err) {

View File

@ -51,8 +51,7 @@ const probeGateway = vi.fn<
>();
const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true);
const loadConfig = vi.hoisted(() => vi.fn(() => ({})));
const launchAgentPlistExists = vi.hoisted(() => vi.fn());
const repairLaunchAgentBootstrap = vi.hoisted(() => vi.fn());
const recoverInstalledLaunchAgent = vi.hoisted(() => vi.fn());
vi.mock("../../config/config.js", () => ({
loadConfig: () => loadConfig(),
@ -84,10 +83,9 @@ vi.mock("../../daemon/service.js", () => ({
resolveGatewayService: () => service,
}));
vi.mock("../../daemon/launchd.js", () => ({
launchAgentPlistExists: (env: Record<string, string | undefined>) => launchAgentPlistExists(env),
repairLaunchAgentBootstrap: (args: { env?: Record<string, string | undefined> }) =>
repairLaunchAgentBootstrap(args),
vi.mock("./launchd-recovery.js", () => ({
recoverInstalledLaunchAgent: (args: { result: "started" | "restarted" }) =>
recoverInstalledLaunchAgent(args),
}));
vi.mock("./restart-health.js", () => ({
@ -160,8 +158,7 @@ describe("runDaemonRestart health checks", () => {
probeGateway.mockReset();
isRestartEnabled.mockReset();
loadConfig.mockReset();
launchAgentPlistExists.mockReset();
repairLaunchAgentBootstrap.mockReset();
recoverInstalledLaunchAgent.mockReset();
service.readCommand.mockResolvedValue({
programArguments: ["openclaw", "gateway", "--port", "18789"],
@ -169,8 +166,7 @@ describe("runDaemonRestart health checks", () => {
});
service.restart.mockResolvedValue({ outcome: "completed" });
runServiceStart.mockResolvedValue(undefined);
launchAgentPlistExists.mockResolvedValue(false);
repairLaunchAgentBootstrap.mockResolvedValue({ ok: true });
recoverInstalledLaunchAgent.mockResolvedValue(null);
runServiceRestart.mockImplementation(async (params: RestartParams) => {
const fail = (message: string, hints?: string[]) => {
@ -213,15 +209,18 @@ describe("runDaemonRestart health checks", () => {
it("re-bootstraps an installed LaunchAgent when start finds it not loaded", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
launchAgentPlistExists.mockResolvedValue(true);
recoverInstalledLaunchAgent.mockResolvedValue({
result: "started",
loaded: true,
message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.",
});
runServiceStart.mockImplementation(async (params: { onNotLoaded?: () => Promise<unknown> }) => {
await params.onNotLoaded?.();
});
await runDaemonStart({ json: true });
expect(launchAgentPlistExists).toHaveBeenCalledWith(process.env);
expect(repairLaunchAgentBootstrap).toHaveBeenCalledWith({ env: process.env });
expect(recoverInstalledLaunchAgent).toHaveBeenCalledWith({ result: "started" });
});
it("kills stale gateway pids and retries restart", async () => {
@ -344,21 +343,24 @@ describe("runDaemonRestart health checks", () => {
it("prefers unmanaged restart over launchd repair when a gateway listener is present", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
launchAgentPlistExists.mockResolvedValue(true);
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
mockUnmanagedRestart({ runPostRestartCheck: true });
await runDaemonRestart({ json: true });
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "SIGUSR1");
expect(repairLaunchAgentBootstrap).not.toHaveBeenCalled();
expect(recoverInstalledLaunchAgent).not.toHaveBeenCalled();
expect(waitForGatewayHealthyListener).toHaveBeenCalledTimes(1);
expect(waitForGatewayHealthyRestart).not.toHaveBeenCalled();
});
it("re-bootstraps an installed LaunchAgent on restart when no unmanaged listener exists", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
launchAgentPlistExists.mockResolvedValue(true);
recoverInstalledLaunchAgent.mockResolvedValue({
result: "restarted",
loaded: true,
message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.",
});
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([]);
runServiceRestart.mockImplementation(
async (params: RestartParams & { onNotLoaded?: () => Promise<unknown> }) => {
@ -377,7 +379,7 @@ describe("runDaemonRestart health checks", () => {
await runDaemonRestart({ json: true });
expect(repairLaunchAgentBootstrap).toHaveBeenCalledWith({ env: process.env });
expect(recoverInstalledLaunchAgent).toHaveBeenCalledWith({ result: "restarted" });
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
expect(waitForGatewayHealthyListener).not.toHaveBeenCalled();
expect(waitForGatewayHealthyRestart).toHaveBeenCalledTimes(1);

View File

@ -1,6 +1,5 @@
import { isRestartEnabled } from "../../config/commands.js";
import { readBestEffortConfig, resolveGatewayPort } from "../../config/config.js";
import { launchAgentPlistExists, repairLaunchAgentBootstrap } from "../../daemon/launchd.js";
import { resolveGatewayService } from "../../daemon/service.js";
import { probeGateway } from "../../gateway/probe.js";
import {
@ -11,6 +10,7 @@ import {
import { defaultRuntime } from "../../runtime.js";
import { theme } from "../../terminal/theme.js";
import { formatCliCommand } from "../command-format.js";
import { recoverInstalledLaunchAgent } from "./launchd-recovery.js";
import {
runServiceRestart,
runServiceStart,
@ -131,28 +131,6 @@ async function restartGatewayWithoutServiceManager(port: number) {
};
}
async function repairLaunchAgentIfInstalled(params: { result: "started" | "restarted" }) {
if (process.platform !== "darwin") {
return null;
}
const serviceEnv = process.env as Record<string, string | undefined>;
const plistExists = await launchAgentPlistExists(serviceEnv).catch(() => false);
if (!plistExists) {
return null;
}
const repaired = await repairLaunchAgentBootstrap({ env: serviceEnv }).catch(() => ({
ok: false,
}));
if (!repaired.ok) {
return null;
}
return {
result: params.result,
loaded: true,
message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.",
} as const;
}
export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) {
return await runServiceUninstall({
serviceNoun: "Gateway",
@ -170,7 +148,7 @@ export async function runDaemonStart(opts: DaemonLifecycleOptions = {}) {
renderStartHints: renderGatewayServiceStartHints,
onNotLoaded:
process.platform === "darwin"
? async () => await repairLaunchAgentIfInstalled({ result: "started" })
? async () => await recoverInstalledLaunchAgent({ result: "started" })
: undefined,
opts,
});
@ -216,7 +194,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
restartedWithoutServiceManager = true;
return handled;
}
return await repairLaunchAgentIfInstalled({ result: "restarted" });
return await recoverInstalledLaunchAgent({ result: "restarted" });
},
postRestartCheck: async ({ warnings, fail, stdout }) => {
if (restartedWithoutServiceManager) {

View File

@ -40,7 +40,7 @@ vi.mock("../daemon/launchd.js", async () => {
isLaunchAgentListed: vi.fn(async () => false),
isLaunchAgentLoaded: vi.fn(async () => false),
launchAgentPlistExists: vi.fn(async () => false),
repairLaunchAgentBootstrap: vi.fn(async () => ({ ok: true })),
repairLaunchAgentBootstrap: vi.fn(async () => ({ ok: true, status: "repaired" })),
};
});

View File

@ -247,7 +247,7 @@ describe("launchd bootstrap repair", () => {
OPENCLAW_PROFILE: "default",
};
const repair = await repairLaunchAgentBootstrap({ env });
expect(repair.ok).toBe(true);
expect(repair).toEqual({ ok: true, status: "repaired" });
const { serviceId, bootstrapIndex } = expectLaunchctlEnableBootstrapOrder(env);
const kickstartIndex = state.launchctlCalls.findIndex(
@ -268,7 +268,7 @@ describe("launchd bootstrap repair", () => {
const repair = await repairLaunchAgentBootstrap({ env });
expect(repair.ok).toBe(true);
expect(repair).toEqual({ ok: true, status: "already-loaded" });
expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toHaveLength(1);
});
@ -282,7 +282,7 @@ describe("launchd bootstrap repair", () => {
const repair = await repairLaunchAgentBootstrap({ env });
expect(repair.ok).toBe(true);
expect(repair).toEqual({ ok: true, status: "already-loaded" });
expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toHaveLength(1);
});
@ -295,10 +295,30 @@ describe("launchd bootstrap repair", () => {
const repair = await repairLaunchAgentBootstrap({ env });
expect(repair.ok).toBe(false);
expect(repair.detail).toContain("Could not find specified service");
expect(repair).toMatchObject({
ok: false,
status: "bootstrap-failed",
detail: expect.stringContaining("Could not find specified service"),
});
expect(state.launchctlCalls.some((call) => call[0] === "kickstart")).toBe(false);
});
it("returns a typed kickstart failure", async () => {
state.kickstartError = "launchctl kickstart failed: permission denied";
state.kickstartFailuresRemaining = 1;
const env: Record<string, string | undefined> = {
HOME: "/Users/test",
OPENCLAW_PROFILE: "default",
};
const repair = await repairLaunchAgentBootstrap({ env });
expect(repair).toEqual({
ok: false,
status: "kickstart-failed",
detail: "launchctl kickstart failed: permission denied",
});
});
});
describe("launchd install", () => {

View File

@ -313,9 +313,13 @@ export async function readLaunchAgentRuntime(
};
}
export type LaunchAgentBootstrapRepairResult =
| { ok: true; status: "repaired" | "already-loaded" }
| { ok: false; status: "bootstrap-failed" | "kickstart-failed"; detail?: string };
export async function repairLaunchAgentBootstrap(args: {
env?: Record<string, string | undefined>;
}): Promise<{ ok: boolean; detail?: string }> {
}): Promise<LaunchAgentBootstrapRepairResult> {
const env = args.env ?? (process.env as Record<string, string | undefined>);
const domain = resolveGuiDomain();
const label = resolveLaunchAgentLabel({ env });
@ -324,19 +328,25 @@ export async function repairLaunchAgentBootstrap(args: {
// (matches the same guard in installLaunchAgent and restartLaunchAgent).
await execLaunchctl(["enable", `${domain}/${label}`]);
const boot = await execLaunchctl(["bootstrap", domain, plistPath]);
let repairStatus: LaunchAgentBootstrapRepairResult["status"] = "repaired";
if (boot.code !== 0) {
const detail = (boot.stderr || boot.stdout).trim();
const normalized = detail.toLowerCase();
const alreadyLoaded = boot.code === 130 || normalized.includes("already exists in domain");
if (!alreadyLoaded) {
return { ok: false, detail: detail || undefined };
return { ok: false, status: "bootstrap-failed", detail: detail || undefined };
}
repairStatus = "already-loaded";
}
const kick = await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
if (kick.code !== 0) {
return { ok: false, detail: (kick.stderr || kick.stdout).trim() || undefined };
return {
ok: false,
status: "kickstart-failed",
detail: (kick.stderr || kick.stdout).trim() || undefined,
};
}
return { ok: true };
return { ok: true, status: repairStatus };
}
export type LegacyLaunchAgent = {