From c0ddcf66301607cc5a025d9cd084bec33d762e8b Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Fri, 10 Apr 2026 21:31:50 +0300 Subject: [PATCH] fix(daemon): confirm launchd stop state before success --- src/daemon/launchd.test.ts | 38 ++++++++++++++++++++++++++++ src/daemon/launchd.ts | 51 +++++++++++++++++++++++++++++--------- 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 3fb78abf8c6..a451ad101e3 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -19,6 +19,9 @@ const state = vi.hoisted(() => ({ listOutput: "", printOutput: "", printNotLoadedRemaining: 0, + printError: "", + printCode: 1, + printFailuresRemaining: 0, bootstrapError: "", bootstrapCode: 1, kickstartError: "", @@ -88,6 +91,10 @@ vi.mock("./exec-file.js", () => ({ state.printNotLoadedRemaining -= 1; return { stdout: "", stderr: "Could not find service", code: 113 }; } + if (state.printError && state.printFailuresRemaining > 0) { + state.printFailuresRemaining -= 1; + return { stdout: "", stderr: state.printError, code: state.printCode }; + } if (!state.serviceLoaded) { return { stdout: "", stderr: "Could not find service", code: 113 }; } @@ -210,6 +217,9 @@ beforeEach(() => { state.listOutput = ""; state.printOutput = ""; state.printNotLoadedRemaining = 0; + state.printError = ""; + state.printCode = 1; + state.printFailuresRemaining = 0; state.bootstrapError = ""; state.bootstrapCode = 1; state.kickstartError = ""; @@ -519,6 +529,34 @@ describe("launchd install", () => { expect(output).toContain("did not fully stop the service"); }); + it("falls back to bootout when launchctl print cannot confirm the stop state", async () => { + const env = createDefaultLaunchdEnv(); + const stdout = new PassThrough(); + let output = ""; + state.printError = "launchctl print permission denied"; + state.printFailuresRemaining = 10; + stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + + await stopLaunchAgent({ env, stdout }); + + expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(true); + expect(output).toContain("Stopped LaunchAgent (degraded)"); + expect(output).toContain("could not confirm stop"); + }); + + it("throws when launchctl print cannot confirm stop and bootout also fails", async () => { + const env = createDefaultLaunchdEnv(); + state.printError = "launchctl print permission denied"; + state.printFailuresRemaining = 10; + state.bootoutError = "launchctl bootout permission denied"; + + await expect(stopLaunchAgent({ env, stdout: new PassThrough() })).rejects.toThrow( + "launchctl print could not confirm stop: launchctl print permission denied; launchctl bootout failed: launchctl bootout permission denied", + ); + }); + it("restarts LaunchAgent with kickstart and no bootout", async () => { const env = { ...createDefaultLaunchdEnv(), diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 2f769cdf80c..e5a513b626c 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -472,25 +472,45 @@ function formatLaunchctlResultDetail(res: { return (res.stderr || res.stdout).trim(); } -async function isLaunchAgentProcessRunning(serviceTarget: string): Promise { +type LaunchAgentProbeResult = + | { state: "running" } + | { state: "stopped" } + | { state: "not-loaded" } + | { state: "unknown"; detail?: string }; + +async function probeLaunchAgentState(serviceTarget: string): Promise { const probe = await execLaunchctl(["print", serviceTarget]); if (probe.code !== 0) { - return false; + if (isLaunchctlNotLoaded(probe)) { + return { state: "not-loaded" }; + } + return { + state: "unknown", + detail: formatLaunchctlResultDetail(probe) || undefined, + }; } const runtime = parseLaunchctlPrint(probe.stdout || probe.stderr || ""); - return typeof runtime.pid === "number" && runtime.pid > 1; + if (typeof runtime.pid === "number" && runtime.pid > 1) { + return { state: "running" }; + } + return { state: "stopped" }; } -async function waitForLaunchAgentStopped(serviceTarget: string): Promise { +async function waitForLaunchAgentStopped(serviceTarget: string): Promise { + let lastUnknown: LaunchAgentProbeResult | null = null; for (let attempt = 0; attempt < 10; attempt += 1) { - if (!(await isLaunchAgentProcessRunning(serviceTarget))) { - return true; + const probe = await probeLaunchAgentState(serviceTarget); + if (probe.state === "stopped" || probe.state === "not-loaded") { + return probe; + } + if (probe.state === "unknown") { + lastUnknown = probe; } await new Promise((resolve) => { setTimeout(resolve, 100); }); } - return false; + return lastUnknown ?? { state: "running" }; } export async function stopLaunchAgent({ stdout, env }: GatewayServiceControlArgs): Promise { @@ -523,16 +543,23 @@ export async function stopLaunchAgent({ stdout, env }: GatewayServiceControlArgs throw new Error(`launchctl stop failed: ${formatLaunchctlResultDetail(stop)}`); } - if (!(await waitForLaunchAgentStopped(serviceTarget))) { + const stopState = await waitForLaunchAgentStopped(serviceTarget); + if (stopState.state !== "stopped" && stopState.state !== "not-loaded") { const bootout = await execLaunchctl(["bootout", serviceTarget]); if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) { + const reason = + stopState.state === "unknown" + ? `launchctl print could not confirm stop: ${stopState.detail ?? "unknown error"}` + : "launchctl stop left the service running"; throw new Error( - `launchctl stop left the service running and launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`, + `${reason}; launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`, ); } - stdout.write( - `${formatLine("Warning", "launchctl stop did not fully stop the service; used bootout fallback and left service unloaded")}\n`, - ); + const warning = + stopState.state === "unknown" + ? `launchctl print could not confirm stop; used bootout fallback and left service unloaded: ${stopState.detail ?? "unknown error"}` + : "launchctl stop did not fully stop the service; used bootout fallback and left service unloaded"; + stdout.write(`${formatLine("Warning", warning)}\n`); stdout.write(`${formatLine("Stopped LaunchAgent (degraded)", serviceTarget)}\n`); return; }