Skip to content

Commit b726214

Browse files
committed
fix: avoid fresh launchd repair kickstart
1 parent 62fb50d commit b726214

4 files changed

Lines changed: 91 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
5555
- 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.
5656
- 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)
5757
- 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.
58+
- 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.
5859
- 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.
5960
- 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.
6061
- 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.

src/daemon/launchd.integration.e2e.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
88
import {
99
installLaunchAgent,
1010
readLaunchAgentRuntime,
11+
repairLaunchAgentBootstrap,
1112
restartLaunchAgent,
1213
resolveLaunchAgentPlistPath,
1314
stopLaunchAgent,
@@ -37,6 +38,10 @@ function canRunLaunchdIntegration(): boolean {
3738

3839
const describeLaunchdIntegration = canRunLaunchdIntegration() ? describe : describe.skip;
3940

41+
function resolveGuiDomain(): string {
42+
return `gui/${process.getuid?.() ?? 501}`;
43+
}
44+
4045
async function withTimeout<T>(params: {
4146
run: () => Promise<T>;
4247
timeoutMs: number;
@@ -133,6 +138,29 @@ async function initializeLaunchdRuntime(launchEnv: GatewayServiceEnv, stdout: Pa
133138
});
134139
}
135140

141+
async function writeLaunchAgentProbeScript(params: {
142+
eventsPath: string;
143+
scriptPath: string;
144+
}): Promise<void> {
145+
await fs.writeFile(
146+
params.scriptPath,
147+
[
148+
'const fs = require("node:fs");',
149+
`const eventsPath = ${JSON.stringify(params.eventsPath)};`,
150+
"fs.appendFileSync(eventsPath, `start ${process.pid}\\n`);",
151+
'for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {',
152+
" process.on(signal, () => {",
153+
" fs.appendFileSync(eventsPath, `${signal} ${process.pid}\\n`);",
154+
" process.exit(0);",
155+
" });",
156+
"}",
157+
"setInterval(() => {}, 1000);",
158+
"",
159+
].join("\n"),
160+
"utf8",
161+
);
162+
}
163+
136164
async function expectRuntimePidReplaced(params: {
137165
env: GatewayServiceEnv;
138166
previousPid: number;
@@ -231,4 +259,42 @@ describeLaunchdIntegration("launchd integration", () => {
231259
await restartLaunchAgent({ env: launchEnv, stdout });
232260
await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid });
233261
}, 60_000);
262+
263+
it("repairs a missing bootstrap without kickstarting the fresh LaunchAgent", async () => {
264+
const launchEnv = launchEnvOrThrow(env);
265+
const eventsPath = path.join(homeDir, "repair-probe.events.log");
266+
const scriptPath = path.join(homeDir, "repair-probe.cjs");
267+
await writeLaunchAgentProbeScript({ eventsPath, scriptPath });
268+
await installLaunchAgent({
269+
env: launchEnv,
270+
stdout,
271+
programArguments: [process.execPath, scriptPath],
272+
});
273+
await waitForRunningRuntime({ env: launchEnv });
274+
const bootout = spawnSync(
275+
"launchctl",
276+
["bootout", resolveGuiDomain(), resolveLaunchAgentPlistPath(launchEnv)],
277+
{ encoding: "utf8" },
278+
);
279+
expect(bootout.status).toBe(0);
280+
await waitForNotRunningRuntime({ env: launchEnv });
281+
await fs.access(resolveLaunchAgentPlistPath(launchEnv));
282+
await fs.writeFile(eventsPath, "", "utf8");
283+
284+
const repair = await withTimeout({
285+
run: async () => repairLaunchAgentBootstrap({ env: launchEnv }),
286+
timeoutMs: STARTUP_TIMEOUT_MS,
287+
message: "Timed out repairing launchd integration runtime",
288+
});
289+
expect(repair).toEqual({ ok: true, status: "repaired" });
290+
await waitForRunningRuntime({ env: launchEnv });
291+
292+
await new Promise((resolve) => {
293+
setTimeout(resolve, 1_500);
294+
});
295+
const events = await fs.readFile(eventsPath, "utf8");
296+
const lines = events.trim().split(/\r?\n/).filter(Boolean);
297+
expect(lines.filter((line) => line.startsWith("start "))).toHaveLength(1);
298+
expect(lines.some((line) => /^(SIGHUP|SIGINT|SIGTERM) /.test(line))).toBe(false);
299+
}, 60_000);
234300
});

src/daemon/launchd.test.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -394,40 +394,41 @@ describe("launchctl list detection", () => {
394394
});
395395

396396
describe("launchd bootstrap repair", () => {
397-
it("enables, bootstraps, and kickstarts the resolved label", async () => {
397+
it("enables and bootstraps the resolved label without kickstarting the fresh agent", async () => {
398398
const env = createDefaultLaunchdEnv();
399399
const repair = await repairLaunchAgentBootstrap({ env });
400400
expect(repair).toEqual({ ok: true, status: "repaired" });
401401

402-
const { serviceId, bootstrapIndex } = expectLaunchctlEnableBootstrapOrder(env);
403-
const kickstartIndex = state.launchctlCalls.findIndex(
404-
(c) => c[0] === "kickstart" && c[1] === "-k" && c[2] === serviceId,
405-
);
406-
407-
expect(kickstartIndex).toBeGreaterThanOrEqual(0);
408-
expect(bootstrapIndex).toBeLessThan(kickstartIndex);
402+
expectLaunchctlEnableBootstrapOrder(env);
403+
expect(state.launchctlCalls.some((call) => call[0] === "kickstart")).toBe(false);
409404
});
410405

411-
it("treats bootstrap exit 130 as success", async () => {
406+
it("treats bootstrap exit 130 as success and nudges the already-loaded service", async () => {
412407
state.bootstrapError = "Service already loaded";
413408
state.bootstrapCode = 130;
414409
const env = createDefaultLaunchdEnv();
415410

416411
const repair = await repairLaunchAgentBootstrap({ env });
417412

413+
const { serviceId } = expectLaunchctlEnableBootstrapOrder(env);
418414
expect(repair).toEqual({ ok: true, status: "already-loaded" });
419-
expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toHaveLength(1);
415+
expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toEqual([
416+
["kickstart", serviceId],
417+
]);
420418
});
421419

422-
it("treats 'already exists in domain' bootstrap failures as success", async () => {
420+
it("treats 'already exists in domain' bootstrap failures as success and nudges the service", async () => {
423421
state.bootstrapError =
424422
"Could not bootstrap service: 5: Input/output error: already exists in domain for gui/501";
425423
const env = createDefaultLaunchdEnv();
426424

427425
const repair = await repairLaunchAgentBootstrap({ env });
428426

427+
const { serviceId } = expectLaunchctlEnableBootstrapOrder(env);
429428
expect(repair).toEqual({ ok: true, status: "already-loaded" });
430-
expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toHaveLength(1);
429+
expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toEqual([
430+
["kickstart", serviceId],
431+
]);
431432
});
432433

433434
it("keeps genuine bootstrap failures as failures", async () => {
@@ -444,7 +445,9 @@ describe("launchd bootstrap repair", () => {
444445
expect(state.launchctlCalls.some((call) => call[0] === "kickstart")).toBe(false);
445446
});
446447

447-
it("returns a typed kickstart failure", async () => {
448+
it("returns a typed kickstart failure when already-loaded recovery cannot nudge the service", async () => {
449+
state.bootstrapError = "Service already loaded";
450+
state.bootstrapCode = 130;
448451
state.kickstartError = "launchctl kickstart failed: permission denied";
449452
state.kickstartFailuresRemaining = 1;
450453
const env = createDefaultLaunchdEnv();

src/daemon/launchd.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -444,9 +444,10 @@ export async function repairLaunchAgentBootstrap(args: {
444444
const domain = resolveGuiDomain();
445445
const label = resolveLaunchAgentLabel({ env });
446446
const plistPath = resolveLaunchAgentPlistPath(env);
447-
await execLaunchctl(["enable", `${domain}/${label}`]);
447+
const serviceTarget = `${domain}/${label}`;
448+
await execLaunchctl(["enable", serviceTarget]);
448449
const boot = await execLaunchctl(["bootstrap", domain, plistPath]);
449-
let repairStatus: LaunchAgentBootstrapRepairResult["status"] = "repaired";
450+
let repairStatus: "repaired" | "already-loaded" = "repaired";
450451
if (boot.code !== 0) {
451452
const detail = (boot.stderr || boot.stdout).trim();
452453
const normalized = normalizeLowercaseStringOrEmpty(detail);
@@ -456,7 +457,11 @@ export async function repairLaunchAgentBootstrap(args: {
456457
}
457458
repairStatus = "already-loaded";
458459
}
459-
const kick = await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
460+
if (repairStatus === "repaired") {
461+
return { ok: true, status: repairStatus };
462+
}
463+
464+
const kick = await execLaunchctl(["kickstart", serviceTarget]);
460465
if (kick.code !== 0) {
461466
return {
462467
ok: false,

0 commit comments

Comments
 (0)