From b726214cf3c3be2185bc072d3431ecbaf5b5859b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 3 May 2026 20:59:18 +0100 Subject: [PATCH] fix: avoid fresh launchd repair kickstart --- CHANGELOG.md | 1 + src/daemon/launchd.integration.e2e.test.ts | 66 ++++++++++++++++++++++ src/daemon/launchd.test.ts | 29 +++++----- src/daemon/launchd.ts | 11 +++- 4 files changed, 91 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec5474bd7bf..b19abd294e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai - Gateway/update: recover an installed-but-unloaded macOS LaunchAgent after package updates, rerun Gateway health/version/channel readiness checks, and print restart, reinstall, and rollback guidance before reporting update failure. (#76790) Thanks @jonathanlindsay. - CLI/plugins: explain when a missing plugin command alias belongs to a bundled plugin that is disabled by default, including the `openclaw plugins enable ` repair command. (#76835) - Gateway/Bonjour: auto-start LAN multicast discovery only on macOS hosts while preserving explicit `openclaw plugins enable bonjour` startup elsewhere, so Linux servers and containers that do not need LAN discovery avoid default mDNS probing and watchdog churn. Refs #74209. +- Gateway/macOS: stop `doctor` and LaunchAgent recovery from running `launchctl kickstart -k` after a fresh bootstrap, avoiding an immediate SIGTERM of the just-started gateway while still nudging already-loaded launchd jobs. Fixes #76261. Thanks @solosage1. - Google Meet: route stateful CLI session commands through the gateway-owned runtime so joined realtime sessions survive after the starting CLI process exits. Fixes #76344. Thanks @coltonharris-wq. - Memory/status: split builtin sqlite-vec store readiness from embedding-provider readiness in `memory status --deep` and `openclaw status`, so local vector-store failures no longer look like provider failures and provider failures no longer hide a healthy local vector store. - CLI/doctor: trust a ready gateway memory probe when CLI-side active memory backend resolution is unavailable, preventing false "No active memory plugin is registered" warnings for healthy runtime setups. Fixes #76792. Thanks @som-686. diff --git a/src/daemon/launchd.integration.e2e.test.ts b/src/daemon/launchd.integration.e2e.test.ts index a3999cf12ff..b8a1c7d8f24 100644 --- a/src/daemon/launchd.integration.e2e.test.ts +++ b/src/daemon/launchd.integration.e2e.test.ts @@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { installLaunchAgent, readLaunchAgentRuntime, + repairLaunchAgentBootstrap, restartLaunchAgent, resolveLaunchAgentPlistPath, stopLaunchAgent, @@ -37,6 +38,10 @@ function canRunLaunchdIntegration(): boolean { const describeLaunchdIntegration = canRunLaunchdIntegration() ? describe : describe.skip; +function resolveGuiDomain(): string { + return `gui/${process.getuid?.() ?? 501}`; +} + async function withTimeout(params: { run: () => Promise; timeoutMs: number; @@ -133,6 +138,29 @@ async function initializeLaunchdRuntime(launchEnv: GatewayServiceEnv, stdout: Pa }); } +async function writeLaunchAgentProbeScript(params: { + eventsPath: string; + scriptPath: string; +}): Promise { + await fs.writeFile( + params.scriptPath, + [ + 'const fs = require("node:fs");', + `const eventsPath = ${JSON.stringify(params.eventsPath)};`, + "fs.appendFileSync(eventsPath, `start ${process.pid}\\n`);", + 'for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {', + " process.on(signal, () => {", + " fs.appendFileSync(eventsPath, `${signal} ${process.pid}\\n`);", + " process.exit(0);", + " });", + "}", + "setInterval(() => {}, 1000);", + "", + ].join("\n"), + "utf8", + ); +} + async function expectRuntimePidReplaced(params: { env: GatewayServiceEnv; previousPid: number; @@ -231,4 +259,42 @@ describeLaunchdIntegration("launchd integration", () => { await restartLaunchAgent({ env: launchEnv, stdout }); await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid }); }, 60_000); + + it("repairs a missing bootstrap without kickstarting the fresh LaunchAgent", async () => { + const launchEnv = launchEnvOrThrow(env); + const eventsPath = path.join(homeDir, "repair-probe.events.log"); + const scriptPath = path.join(homeDir, "repair-probe.cjs"); + await writeLaunchAgentProbeScript({ eventsPath, scriptPath }); + await installLaunchAgent({ + env: launchEnv, + stdout, + programArguments: [process.execPath, scriptPath], + }); + await waitForRunningRuntime({ env: launchEnv }); + const bootout = spawnSync( + "launchctl", + ["bootout", resolveGuiDomain(), resolveLaunchAgentPlistPath(launchEnv)], + { encoding: "utf8" }, + ); + expect(bootout.status).toBe(0); + await waitForNotRunningRuntime({ env: launchEnv }); + await fs.access(resolveLaunchAgentPlistPath(launchEnv)); + await fs.writeFile(eventsPath, "", "utf8"); + + const repair = await withTimeout({ + run: async () => repairLaunchAgentBootstrap({ env: launchEnv }), + timeoutMs: STARTUP_TIMEOUT_MS, + message: "Timed out repairing launchd integration runtime", + }); + expect(repair).toEqual({ ok: true, status: "repaired" }); + await waitForRunningRuntime({ env: launchEnv }); + + await new Promise((resolve) => { + setTimeout(resolve, 1_500); + }); + const events = await fs.readFile(eventsPath, "utf8"); + const lines = events.trim().split(/\r?\n/).filter(Boolean); + expect(lines.filter((line) => line.startsWith("start "))).toHaveLength(1); + expect(lines.some((line) => /^(SIGHUP|SIGINT|SIGTERM) /.test(line))).toBe(false); + }, 60_000); }); diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index c426624c334..d9bd69c6025 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -394,40 +394,41 @@ describe("launchctl list detection", () => { }); describe("launchd bootstrap repair", () => { - it("enables, bootstraps, and kickstarts the resolved label", async () => { + it("enables and bootstraps the resolved label without kickstarting the fresh agent", async () => { const env = createDefaultLaunchdEnv(); const repair = await repairLaunchAgentBootstrap({ env }); expect(repair).toEqual({ ok: true, status: "repaired" }); - const { serviceId, bootstrapIndex } = expectLaunchctlEnableBootstrapOrder(env); - const kickstartIndex = state.launchctlCalls.findIndex( - (c) => c[0] === "kickstart" && c[1] === "-k" && c[2] === serviceId, - ); - - expect(kickstartIndex).toBeGreaterThanOrEqual(0); - expect(bootstrapIndex).toBeLessThan(kickstartIndex); + expectLaunchctlEnableBootstrapOrder(env); + expect(state.launchctlCalls.some((call) => call[0] === "kickstart")).toBe(false); }); - it("treats bootstrap exit 130 as success", async () => { + it("treats bootstrap exit 130 as success and nudges the already-loaded service", async () => { state.bootstrapError = "Service already loaded"; state.bootstrapCode = 130; const env = createDefaultLaunchdEnv(); const repair = await repairLaunchAgentBootstrap({ env }); + const { serviceId } = expectLaunchctlEnableBootstrapOrder(env); expect(repair).toEqual({ ok: true, status: "already-loaded" }); - expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toHaveLength(1); + expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toEqual([ + ["kickstart", serviceId], + ]); }); - it("treats 'already exists in domain' bootstrap failures as success", async () => { + it("treats 'already exists in domain' bootstrap failures as success and nudges the service", async () => { state.bootstrapError = "Could not bootstrap service: 5: Input/output error: already exists in domain for gui/501"; const env = createDefaultLaunchdEnv(); const repair = await repairLaunchAgentBootstrap({ env }); + const { serviceId } = expectLaunchctlEnableBootstrapOrder(env); expect(repair).toEqual({ ok: true, status: "already-loaded" }); - expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toHaveLength(1); + expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toEqual([ + ["kickstart", serviceId], + ]); }); it("keeps genuine bootstrap failures as failures", async () => { @@ -444,7 +445,9 @@ describe("launchd bootstrap repair", () => { expect(state.launchctlCalls.some((call) => call[0] === "kickstart")).toBe(false); }); - it("returns a typed kickstart failure", async () => { + it("returns a typed kickstart failure when already-loaded recovery cannot nudge the service", async () => { + state.bootstrapError = "Service already loaded"; + state.bootstrapCode = 130; state.kickstartError = "launchctl kickstart failed: permission denied"; state.kickstartFailuresRemaining = 1; const env = createDefaultLaunchdEnv(); diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 4b35b3f3c6c..0d864222e45 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -444,9 +444,10 @@ export async function repairLaunchAgentBootstrap(args: { const domain = resolveGuiDomain(); const label = resolveLaunchAgentLabel({ env }); const plistPath = resolveLaunchAgentPlistPath(env); - await execLaunchctl(["enable", `${domain}/${label}`]); + const serviceTarget = `${domain}/${label}`; + await execLaunchctl(["enable", serviceTarget]); const boot = await execLaunchctl(["bootstrap", domain, plistPath]); - let repairStatus: LaunchAgentBootstrapRepairResult["status"] = "repaired"; + let repairStatus: "repaired" | "already-loaded" = "repaired"; if (boot.code !== 0) { const detail = (boot.stderr || boot.stdout).trim(); const normalized = normalizeLowercaseStringOrEmpty(detail); @@ -456,7 +457,11 @@ export async function repairLaunchAgentBootstrap(args: { } repairStatus = "already-loaded"; } - const kick = await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]); + if (repairStatus === "repaired") { + return { ok: true, status: repairStatus }; + } + + const kick = await execLaunchctl(["kickstart", serviceTarget]); if (kick.code !== 0) { return { ok: false,