Skip to content

Commit c0ddcf6

Browse files
ngutmansteipete
authored andcommitted
fix(daemon): confirm launchd stop state before success
1 parent 23d9a10 commit c0ddcf6

2 files changed

Lines changed: 77 additions & 12 deletions

File tree

src/daemon/launchd.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ const state = vi.hoisted(() => ({
1919
listOutput: "",
2020
printOutput: "",
2121
printNotLoadedRemaining: 0,
22+
printError: "",
23+
printCode: 1,
24+
printFailuresRemaining: 0,
2225
bootstrapError: "",
2326
bootstrapCode: 1,
2427
kickstartError: "",
@@ -88,6 +91,10 @@ vi.mock("./exec-file.js", () => ({
8891
state.printNotLoadedRemaining -= 1;
8992
return { stdout: "", stderr: "Could not find service", code: 113 };
9093
}
94+
if (state.printError && state.printFailuresRemaining > 0) {
95+
state.printFailuresRemaining -= 1;
96+
return { stdout: "", stderr: state.printError, code: state.printCode };
97+
}
9198
if (!state.serviceLoaded) {
9299
return { stdout: "", stderr: "Could not find service", code: 113 };
93100
}
@@ -210,6 +217,9 @@ beforeEach(() => {
210217
state.listOutput = "";
211218
state.printOutput = "";
212219
state.printNotLoadedRemaining = 0;
220+
state.printError = "";
221+
state.printCode = 1;
222+
state.printFailuresRemaining = 0;
213223
state.bootstrapError = "";
214224
state.bootstrapCode = 1;
215225
state.kickstartError = "";
@@ -519,6 +529,34 @@ describe("launchd install", () => {
519529
expect(output).toContain("did not fully stop the service");
520530
});
521531

532+
it("falls back to bootout when launchctl print cannot confirm the stop state", async () => {
533+
const env = createDefaultLaunchdEnv();
534+
const stdout = new PassThrough();
535+
let output = "";
536+
state.printError = "launchctl print permission denied";
537+
state.printFailuresRemaining = 10;
538+
stdout.on("data", (chunk: Buffer) => {
539+
output += chunk.toString();
540+
});
541+
542+
await stopLaunchAgent({ env, stdout });
543+
544+
expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(true);
545+
expect(output).toContain("Stopped LaunchAgent (degraded)");
546+
expect(output).toContain("could not confirm stop");
547+
});
548+
549+
it("throws when launchctl print cannot confirm stop and bootout also fails", async () => {
550+
const env = createDefaultLaunchdEnv();
551+
state.printError = "launchctl print permission denied";
552+
state.printFailuresRemaining = 10;
553+
state.bootoutError = "launchctl bootout permission denied";
554+
555+
await expect(stopLaunchAgent({ env, stdout: new PassThrough() })).rejects.toThrow(
556+
"launchctl print could not confirm stop: launchctl print permission denied; launchctl bootout failed: launchctl bootout permission denied",
557+
);
558+
});
559+
522560
it("restarts LaunchAgent with kickstart and no bootout", async () => {
523561
const env = {
524562
...createDefaultLaunchdEnv(),

src/daemon/launchd.ts

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -472,25 +472,45 @@ function formatLaunchctlResultDetail(res: {
472472
return (res.stderr || res.stdout).trim();
473473
}
474474

475-
async function isLaunchAgentProcessRunning(serviceTarget: string): Promise<boolean> {
475+
type LaunchAgentProbeResult =
476+
| { state: "running" }
477+
| { state: "stopped" }
478+
| { state: "not-loaded" }
479+
| { state: "unknown"; detail?: string };
480+
481+
async function probeLaunchAgentState(serviceTarget: string): Promise<LaunchAgentProbeResult> {
476482
const probe = await execLaunchctl(["print", serviceTarget]);
477483
if (probe.code !== 0) {
478-
return false;
484+
if (isLaunchctlNotLoaded(probe)) {
485+
return { state: "not-loaded" };
486+
}
487+
return {
488+
state: "unknown",
489+
detail: formatLaunchctlResultDetail(probe) || undefined,
490+
};
479491
}
480492
const runtime = parseLaunchctlPrint(probe.stdout || probe.stderr || "");
481-
return typeof runtime.pid === "number" && runtime.pid > 1;
493+
if (typeof runtime.pid === "number" && runtime.pid > 1) {
494+
return { state: "running" };
495+
}
496+
return { state: "stopped" };
482497
}
483498

484-
async function waitForLaunchAgentStopped(serviceTarget: string): Promise<boolean> {
499+
async function waitForLaunchAgentStopped(serviceTarget: string): Promise<LaunchAgentProbeResult> {
500+
let lastUnknown: LaunchAgentProbeResult | null = null;
485501
for (let attempt = 0; attempt < 10; attempt += 1) {
486-
if (!(await isLaunchAgentProcessRunning(serviceTarget))) {
487-
return true;
502+
const probe = await probeLaunchAgentState(serviceTarget);
503+
if (probe.state === "stopped" || probe.state === "not-loaded") {
504+
return probe;
505+
}
506+
if (probe.state === "unknown") {
507+
lastUnknown = probe;
488508
}
489509
await new Promise((resolve) => {
490510
setTimeout(resolve, 100);
491511
});
492512
}
493-
return false;
513+
return lastUnknown ?? { state: "running" };
494514
}
495515

496516
export async function stopLaunchAgent({ stdout, env }: GatewayServiceControlArgs): Promise<void> {
@@ -523,16 +543,23 @@ export async function stopLaunchAgent({ stdout, env }: GatewayServiceControlArgs
523543
throw new Error(`launchctl stop failed: ${formatLaunchctlResultDetail(stop)}`);
524544
}
525545

526-
if (!(await waitForLaunchAgentStopped(serviceTarget))) {
546+
const stopState = await waitForLaunchAgentStopped(serviceTarget);
547+
if (stopState.state !== "stopped" && stopState.state !== "not-loaded") {
527548
const bootout = await execLaunchctl(["bootout", serviceTarget]);
528549
if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) {
550+
const reason =
551+
stopState.state === "unknown"
552+
? `launchctl print could not confirm stop: ${stopState.detail ?? "unknown error"}`
553+
: "launchctl stop left the service running";
529554
throw new Error(
530-
`launchctl stop left the service running and launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`,
555+
`${reason}; launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`,
531556
);
532557
}
533-
stdout.write(
534-
`${formatLine("Warning", "launchctl stop did not fully stop the service; used bootout fallback and left service unloaded")}\n`,
535-
);
558+
const warning =
559+
stopState.state === "unknown"
560+
? `launchctl print could not confirm stop; used bootout fallback and left service unloaded: ${stopState.detail ?? "unknown error"}`
561+
: "launchctl stop did not fully stop the service; used bootout fallback and left service unloaded";
562+
stdout.write(`${formatLine("Warning", warning)}\n`);
536563
stdout.write(`${formatLine("Stopped LaunchAgent (degraded)", serviceTarget)}\n`);
537564
return;
538565
}

0 commit comments

Comments
 (0)