mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
fix: avoid fresh launchd repair kickstart
This commit is contained in:
@@ -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 <plugin>` 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.
|
||||
|
||||
@@ -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<T>(params: {
|
||||
run: () => Promise<T>;
|
||||
timeoutMs: number;
|
||||
@@ -133,6 +138,29 @@ async function initializeLaunchdRuntime(launchEnv: GatewayServiceEnv, stdout: Pa
|
||||
});
|
||||
}
|
||||
|
||||
async function writeLaunchAgentProbeScript(params: {
|
||||
eventsPath: string;
|
||||
scriptPath: string;
|
||||
}): Promise<void> {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user