fix(daemon): resilient launchctl bootstrap with retry and load fallback
When `launchctl bootstrap` fails because the service is already registered (exit code 5), bootout the stale registration and retry once before giving up. When the gui/ domain is unavailable (SSH, headless, or sudo), fall back to the deprecated but universal `launchctl load -w` command. Only throw the GUI-session error when both approaches fail. Fixes #8619
This commit is contained in:
parent
4c60956d8e
commit
af64ccadd4
@ -18,6 +18,8 @@ const state = vi.hoisted(() => ({
|
|||||||
listOutput: "",
|
listOutput: "",
|
||||||
printOutput: "",
|
printOutput: "",
|
||||||
bootstrapError: "",
|
bootstrapError: "",
|
||||||
|
bootstrapFailuresRemaining: Infinity,
|
||||||
|
loadError: "",
|
||||||
kickstartError: "",
|
kickstartError: "",
|
||||||
kickstartFailuresRemaining: 0,
|
kickstartFailuresRemaining: 0,
|
||||||
dirs: new Set<string>(),
|
dirs: new Set<string>(),
|
||||||
@ -74,9 +76,13 @@ vi.mock("./exec-file.js", () => ({
|
|||||||
if (call[0] === "print") {
|
if (call[0] === "print") {
|
||||||
return { stdout: state.printOutput, stderr: "", code: 0 };
|
return { stdout: state.printOutput, stderr: "", code: 0 };
|
||||||
}
|
}
|
||||||
if (call[0] === "bootstrap" && state.bootstrapError) {
|
if (call[0] === "bootstrap" && state.bootstrapError && state.bootstrapFailuresRemaining > 0) {
|
||||||
|
state.bootstrapFailuresRemaining -= 1;
|
||||||
return { stdout: "", stderr: state.bootstrapError, code: 1 };
|
return { stdout: "", stderr: state.bootstrapError, code: 1 };
|
||||||
}
|
}
|
||||||
|
if (call[0] === "load" && state.loadError) {
|
||||||
|
return { stdout: "", stderr: state.loadError, code: 1 };
|
||||||
|
}
|
||||||
if (call[0] === "kickstart" && state.kickstartError && state.kickstartFailuresRemaining > 0) {
|
if (call[0] === "kickstart" && state.kickstartError && state.kickstartFailuresRemaining > 0) {
|
||||||
state.kickstartFailuresRemaining -= 1;
|
state.kickstartFailuresRemaining -= 1;
|
||||||
return { stdout: "", stderr: state.kickstartError, code: 1 };
|
return { stdout: "", stderr: state.kickstartError, code: 1 };
|
||||||
@ -152,6 +158,8 @@ beforeEach(() => {
|
|||||||
state.listOutput = "";
|
state.listOutput = "";
|
||||||
state.printOutput = "";
|
state.printOutput = "";
|
||||||
state.bootstrapError = "";
|
state.bootstrapError = "";
|
||||||
|
state.bootstrapFailuresRemaining = Infinity;
|
||||||
|
state.loadError = "";
|
||||||
state.kickstartError = "";
|
state.kickstartError = "";
|
||||||
state.kickstartFailuresRemaining = 0;
|
state.kickstartFailuresRemaining = 0;
|
||||||
state.dirs.clear();
|
state.dirs.clear();
|
||||||
@ -438,6 +446,7 @@ describe("launchd install", () => {
|
|||||||
|
|
||||||
it("shows actionable guidance when launchctl gui domain does not support bootstrap", async () => {
|
it("shows actionable guidance when launchctl gui domain does not support bootstrap", async () => {
|
||||||
state.bootstrapError = "Bootstrap failed: 125: Domain does not support specified action";
|
state.bootstrapError = "Bootstrap failed: 125: Domain does not support specified action";
|
||||||
|
state.loadError = "Could not find domain for user gui: 1000";
|
||||||
const env = createDefaultLaunchdEnv();
|
const env = createDefaultLaunchdEnv();
|
||||||
let message = "";
|
let message = "";
|
||||||
try {
|
try {
|
||||||
@ -452,6 +461,40 @@ describe("launchd install", () => {
|
|||||||
expect(message).toContain("logged-in macOS GUI session");
|
expect(message).toContain("logged-in macOS GUI session");
|
||||||
expect(message).toContain("wrong user (including sudo)");
|
expect(message).toContain("wrong user (including sudo)");
|
||||||
expect(message).toContain("https://docs.openclaw.ai/gateway");
|
expect(message).toContain("https://docs.openclaw.ai/gateway");
|
||||||
|
// Verify that `load -w` was attempted as a fallback before giving up.
|
||||||
|
expect(state.launchctlCalls.some((c) => c[0] === "load")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to launchctl load when gui domain is unavailable", async () => {
|
||||||
|
state.bootstrapError = "Bootstrap failed: 125: Domain does not support specified action";
|
||||||
|
// loadError is empty → launchctl load succeeds
|
||||||
|
const env = createDefaultLaunchdEnv();
|
||||||
|
const stdout = new PassThrough();
|
||||||
|
await installLaunchAgent({
|
||||||
|
env,
|
||||||
|
stdout,
|
||||||
|
programArguments: defaultProgramArguments,
|
||||||
|
});
|
||||||
|
expect(state.launchctlCalls.some((c) => c[0] === "load" && c[1] === "-w")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries bootstrap after bootout when service is already registered", async () => {
|
||||||
|
state.bootstrapError = "Bootstrap failed: 5: Input/output error";
|
||||||
|
state.bootstrapFailuresRemaining = 1; // first bootstrap fails, retry succeeds
|
||||||
|
const env = createDefaultLaunchdEnv();
|
||||||
|
const stdout = new PassThrough();
|
||||||
|
await installLaunchAgent({
|
||||||
|
env,
|
||||||
|
stdout,
|
||||||
|
programArguments: defaultProgramArguments,
|
||||||
|
});
|
||||||
|
// Verify bootout was called between the two bootstrap attempts.
|
||||||
|
const bootoutAfterBootstrap = state.launchctlCalls.findIndex(
|
||||||
|
(c, i) =>
|
||||||
|
c[0] === "bootout" &&
|
||||||
|
i > state.launchctlCalls.findIndex((b) => b[0] === "bootstrap"),
|
||||||
|
);
|
||||||
|
expect(bootoutAfterBootstrap).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("surfaces generic bootstrap failures without GUI-specific guidance", async () => {
|
it("surfaces generic bootstrap failures without GUI-specific guidance", async () => {
|
||||||
|
|||||||
@ -201,7 +201,32 @@ async function bootstrapLaunchAgentOrThrow(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const detail = (boot.stderr || boot.stdout).trim();
|
const detail = (boot.stderr || boot.stdout).trim();
|
||||||
if (isUnsupportedGuiDomain(detail)) {
|
|
||||||
|
// If the service is already registered (e.g. re-install without prior uninstall),
|
||||||
|
// bootout the stale registration and retry once.
|
||||||
|
if (isAlreadyBootstrapped(detail)) {
|
||||||
|
await execLaunchctl(["bootout", params.domain, params.plistPath]);
|
||||||
|
const retry = await execLaunchctl(["bootstrap", params.domain, params.plistPath]);
|
||||||
|
if (retry.code === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const retryDetail = (retry.stderr || retry.stdout).trim();
|
||||||
|
if (isUnsupportedGuiDomain(retryDetail)) {
|
||||||
|
// Fall through to the legacy load fallback below.
|
||||||
|
} else if (!isAlreadyBootstrapped(retryDetail)) {
|
||||||
|
throw new Error(`launchctl bootstrap failed: ${retryDetail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the gui/ domain is unavailable (SSH, headless, or sudo), try the
|
||||||
|
// deprecated-but-universal `launchctl load` as a last resort before giving up.
|
||||||
|
if (isUnsupportedGuiDomain(detail) || isAlreadyBootstrapped(detail)) {
|
||||||
|
const load = await execLaunchctl(["load", "-w", params.plistPath]);
|
||||||
|
if (load.code === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// `launchctl load` also failed — throw the original gui-domain error so the
|
||||||
|
// user gets the actionable hint about desktop sessions.
|
||||||
throwBootstrapGuiSessionError({
|
throwBootstrapGuiSessionError({
|
||||||
detail,
|
detail,
|
||||||
domain: params.domain,
|
domain: params.domain,
|
||||||
@ -448,6 +473,18 @@ function isUnsupportedGuiDomain(detail: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAlreadyBootstrapped(detail: string): boolean {
|
||||||
|
const normalized = detail.toLowerCase();
|
||||||
|
// launchctl bootstrap returns exit code 5 ("Input/output error") or the
|
||||||
|
// explicit "already loaded" / "already bootstrapped" messages when the
|
||||||
|
// service target is already registered in the domain.
|
||||||
|
return (
|
||||||
|
normalized.includes("bootstrap failed: 5") ||
|
||||||
|
normalized.includes("already loaded") ||
|
||||||
|
normalized.includes("already bootstrapped")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function stopLaunchAgent({ stdout, env }: GatewayServiceControlArgs): Promise<void> {
|
export async function stopLaunchAgent({ stdout, env }: GatewayServiceControlArgs): Promise<void> {
|
||||||
const domain = resolveGuiDomain();
|
const domain = resolveGuiDomain();
|
||||||
const label = resolveLaunchAgentLabel({ env });
|
const label = resolveLaunchAgentLabel({ env });
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user