Skip to content

Commit 28eb4bc

Browse files
authored
fix(desktop): auto-clear launchd disabled override on bootstrap (#836)
Users who previously ran `launchctl unload -w` to clean up zombie OpenClaw processes get a persistent "disabled" flag in launchd's database. This causes all subsequent `launchctl bootstrap` calls to fail with error 5 (Input/output error), even when the plist and all referenced paths are valid. Add isServiceDisabled() check + enableService() call before bootstrap, and as a fallback in the error 5 retry path. Closes #780
1 parent 5d125d4 commit 28eb4bc

1 file changed

Lines changed: 54 additions & 1 deletion

File tree

apps/desktop/main/services/launchd-manager.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,14 @@ export class LaunchdManager {
2626
private readonly plistDir: string;
2727
private readonly uid: number;
2828
private readonly domain: string;
29+
private readonly log: (message: string) => void;
2930

30-
constructor(opts?: { plistDir?: string }) {
31+
constructor(opts?: { plistDir?: string; log?: (message: string) => void }) {
3132
this.plistDir =
3233
opts?.plistDir ?? path.join(os.homedir(), "Library/LaunchAgents");
3334
this.uid = os.userInfo().uid;
3435
this.domain = `gui/${this.uid}`;
36+
this.log = opts?.log ?? console.log;
3537
}
3638

3739
/**
@@ -72,6 +74,16 @@ export class LaunchdManager {
7274

7375
await fs.writeFile(plistPath, plistContent, "utf8");
7476

77+
// Clear any persistent "disabled" override left by legacy `launchctl unload -w`.
78+
// Without this, bootstrap fails with error 5 (Input/output error).
79+
const disabled = await this.isServiceDisabled(label);
80+
if (disabled) {
81+
this.log(
82+
`installService: ${label} has disabled override, clearing with launchctl enable`,
83+
);
84+
await this.enableService(label);
85+
}
86+
7587
// Bootstrap with retry: "Input/output error" (code 5) means launchd
7688
// has stale state for this label. Bootout to clear it, then retry.
7789
for (let attempt = 0; attempt < 2; attempt++) {
@@ -95,6 +107,8 @@ export class LaunchdManager {
95107
} catch {
96108
// may already be unregistered
97109
}
110+
// Also clear disabled override in case that's the cause
111+
await this.enableService(label).catch(() => {});
98112
await new Promise((r) => setTimeout(r, 1000));
99113
continue;
100114
}
@@ -259,6 +273,45 @@ export class LaunchdManager {
259273
return env;
260274
}
261275

276+
/**
277+
* Check if a service has a persistent "disabled" override in launchd's database.
278+
* This happens when someone runs `launchctl unload -w` which writes a sticky
279+
* disabled flag, causing all subsequent `launchctl bootstrap` calls to fail
280+
* with error 5 (Input/output error).
281+
*/
282+
async isServiceDisabled(label: string): Promise<boolean> {
283+
try {
284+
const { stdout } = await execFileAsync("launchctl", [
285+
"print-disabled",
286+
this.domain,
287+
]);
288+
// Output format: "io.nexu.controller" => true
289+
const pattern = new RegExp(
290+
`"${label.replace(/\./g, "\\.")}"\\s*=>\\s*true`,
291+
);
292+
return pattern.test(stdout);
293+
} catch {
294+
// Command failed or label not found — assume not disabled
295+
return false;
296+
}
297+
}
298+
299+
/**
300+
* Clear a persistent "disabled" override for a service.
301+
* Runs `launchctl enable gui/<uid>/<label>` so that subsequent bootstrap
302+
* calls succeed. Tolerates errors (the label may not be in the disabled database).
303+
*/
304+
async enableService(label: string): Promise<void> {
305+
try {
306+
await execFileAsync("launchctl", ["enable", `${this.domain}/${label}`]);
307+
this.log(`enableService: cleared disabled override for ${label}`);
308+
} catch (err) {
309+
this.log(
310+
`enableService: failed to clear disabled override for ${label}: ${err instanceof Error ? err.message : String(err)}`,
311+
);
312+
}
313+
}
314+
262315
/**
263316
* Check if service is registered with launchd.
264317
*/

0 commit comments

Comments
 (0)