fix(daemon): confirm launchd stop state before success

This commit is contained in:
Nimrod Gutman
2026-04-10 21:31:50 +03:00
committed by Peter Steinberger
parent 23d9a100c4
commit c0ddcf6630
2 changed files with 77 additions and 12 deletions

View File

@@ -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(),

View File

@@ -472,25 +472,45 @@ function formatLaunchctlResultDetail(res: {
return (res.stderr || res.stdout).trim();
}
async function isLaunchAgentProcessRunning(serviceTarget: string): Promise<boolean> {
type LaunchAgentProbeResult =
| { state: "running" }
| { state: "stopped" }
| { state: "not-loaded" }
| { state: "unknown"; detail?: string };
async function probeLaunchAgentState(serviceTarget: string): Promise<LaunchAgentProbeResult> {
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<boolean> {
async function waitForLaunchAgentStopped(serviceTarget: string): Promise<LaunchAgentProbeResult> {
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<void> {
@@ -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;
}