mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 17:51:22 +00:00
fix(daemon): confirm launchd stop state before success
This commit is contained in:
committed by
Peter Steinberger
parent
23d9a100c4
commit
c0ddcf6630
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user