fix: avoid false macOS gateway restart failures (#109955)

* fix: wait through launchd KeepAlive restart throttling

* ci: retrigger checks

* test: make update restart test platform-independent

---------

Co-authored-by: Josh Lehman <josh@martian.engineering>
This commit is contained in:
DaigoSoup
2026-07-18 08:19:23 +08:00
committed by GitHub
parent 13c0e7492a
commit 025cecf1f9
8 changed files with 139 additions and 2 deletions

View File

@@ -51,6 +51,10 @@ type UpdateCommandServiceTestApi = {
health: GatewayRestartSnapshot;
launchAgentRecovery: PostUpdateLaunchAgentRecoveryResult | null;
}>;
hasLoadedLaunchdKeepAliveSupervisor(params: {
service: GatewayService;
env?: NodeJS.ProcessEnv;
}): Promise<boolean>;
shouldUseLegacyProcessRestartAfterUpdate(params: {
updateMode: UpdateRunResult["mode"];
}): boolean;
@@ -81,6 +85,12 @@ export async function recoverLaunchAgentAndRecheckGatewayHealth(
return await getTestApi().recoverLaunchAgentAndRecheckGatewayHealth(params);
}
export async function hasLoadedLaunchdKeepAliveSupervisor(
params: Parameters<UpdateCommandServiceTestApi["hasLoadedLaunchdKeepAliveSupervisor"]>[0],
): Promise<boolean> {
return await getTestApi().hasLoadedLaunchdKeepAliveSupervisor(params);
}
export function shouldUseLegacyProcessRestartAfterUpdate(
params: Parameters<UpdateCommandServiceTestApi["shouldUseLegacyProcessRestartAfterUpdate"]>[0],
): boolean {

View File

@@ -202,10 +202,23 @@ async function recoverLaunchAgentAndRecheckGatewayHealth(params: {
port: params.port,
expectedVersion: params.expectedVersion,
env: params.env,
supervisorKeepsAlive: true,
});
return { health, launchAgentRecovery };
}
async function hasLoadedLaunchdKeepAliveSupervisor(params: {
service: GatewayService;
env?: NodeJS.ProcessEnv;
}): Promise<boolean> {
if (process.platform !== "darwin") {
return false;
}
// OpenClaw's loaded LaunchAgent has canonical KeepAlive policy. Read this once before
// polling so an unloaded agent can still reach the existing recovery path promptly.
return await params.service.isLoaded({ env: params.env }).catch(() => false);
}
function formatPostUpdateGatewayRecoveryLine(platform: NodeJS.Platform): string {
const restartCommand = replaceCliName(formatCliCommand("openclaw gateway restart"), CLI_NAME);
const installCommand = replaceCliName(
@@ -248,6 +261,7 @@ if (process.env.VITEST || process.env.NODE_ENV === "test") {
formatPostUpdateGatewayRecoveryInstructions,
recoverInstalledLaunchAgentAfterUpdate,
recoverLaunchAgentAndRecheckGatewayHealth,
hasLoadedLaunchdKeepAliveSupervisor,
shouldUseLegacyProcessRestartAfterUpdate,
};
}
@@ -1206,12 +1220,17 @@ export async function maybeRestartService(params: {
}
};
const service = resolveGatewayService();
let supervisorKeepsAlive = await hasLoadedLaunchdKeepAliveSupervisor({
service,
env: params.serviceEnv,
});
let health = await waitForGatewayHealthyRestart({
service,
port: params.gatewayPort,
expectedVersion: expectedGatewayVersion,
env: params.serviceEnv,
requireRunningService: opts.requireRunningService,
supervisorKeepsAlive,
});
if (!health.healthy && health.staleGatewayPids.length > 0) {
if (!params.opts.json) {
@@ -1223,12 +1242,17 @@ export async function maybeRestartService(params: {
}
await terminateStaleGatewayPids(health.staleGatewayPids);
await restartAfterStaleCleanup();
supervisorKeepsAlive = await hasLoadedLaunchdKeepAliveSupervisor({
service,
env: params.serviceEnv,
});
health = await waitForGatewayHealthyRestart({
service,
port: params.gatewayPort,
expectedVersion: expectedGatewayVersion,
env: params.serviceEnv,
requireRunningService: opts.requireRunningService,
supervisorKeepsAlive,
});
}

View File

@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
import type { GatewayService } from "../../daemon/service.js";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import { updatePluginsAfterCoreUpdate } from "./update-command-plugins.js";
import {
@@ -20,6 +21,7 @@ import {
} from "./update-command-service.js";
import {
formatPostUpdateGatewayRecoveryInstructions,
hasLoadedLaunchdKeepAliveSupervisor,
recoverInstalledLaunchAgentAfterUpdate,
recoverLaunchAgentAndRecheckGatewayHealth,
shouldUseLegacyProcessRestartAfterUpdate,
@@ -760,6 +762,7 @@ describe("recoverLaunchAgentAndRecheckGatewayHealth", () => {
port: 18790,
expectedVersion: "2026.5.3",
env: { OPENCLAW_PROFILE: "stomme", OPENCLAW_PORT: "18790" },
supervisorKeepsAlive: true,
});
});
@@ -798,6 +801,36 @@ describe("recoverLaunchAgentAndRecheckGatewayHealth", () => {
});
});
describe("hasLoadedLaunchdKeepAliveSupervisor", () => {
it("requires a loaded LaunchAgent before extending restart health", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
const isLoaded = vi.fn().mockResolvedValue(false);
const service = { isLoaded } as unknown as GatewayService;
await expect(
hasLoadedLaunchdKeepAliveSupervisor({ service, env: { OPENCLAW_PROFILE: "work" } }),
).resolves.toBe(false);
isLoaded.mockResolvedValue(true);
await expect(hasLoadedLaunchdKeepAliveSupervisor({ service })).resolves.toBe(true);
platformSpy.mockRestore();
});
it("does not inspect KeepAlive supervision outside macOS", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("linux");
const isLoaded = vi.fn().mockResolvedValue(true);
await expect(
hasLoadedLaunchdKeepAliveSupervisor({
service: { isLoaded } as unknown as GatewayService,
}),
).resolves.toBe(false);
expect(isLoaded).not.toHaveBeenCalled();
platformSpy.mockRestore();
});
});
describe("resolvePostCoreUpdateChildStdio", () => {
it('returns "pipe" on Windows so the child never inherits the parent console handles', () => {
// On Windows, stdio:"inherit" passes the parent's console HANDLE to the child process.