From 96f85b03cda934f519358278ab566542903e1a13 Mon Sep 17 00:00:00 2001 From: Ayman Kamal Date: Mon, 6 Apr 2026 10:00:24 -0400 Subject: [PATCH] fix: handle launchctl kickstart exit code 113 in launchd_start() launchctl kickstart returns exit code 113 ("Could not find service") when the plist exists but the job hasn't been bootstrapped into the runtime domain. The existing recovery path only caught exit code 3 ("unloaded"), causing an unhandled CalledProcessError. Exit code 113 means the same thing practically -- the service definition needs bootstrapping before it can be kicked. Add it to the same recovery path that already handles exit 3, matching the existing pattern in launchd_stop(). Follow-up: add a unit test covering the 113 recovery path. --- hermes_cli/gateway.py | 4 ++-- tests/hermes_cli/test_gateway_service.py | 27 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 1348e3155..c99761d5c 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1121,7 +1121,7 @@ def launchd_start(): try: subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30) except subprocess.CalledProcessError as e: - if e.returncode != 3: + if e.returncode not in (3, 113): raise print("↻ launchd job was unloaded; reloading service definition") subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30) @@ -1183,7 +1183,7 @@ def launchd_restart(): subprocess.run(["launchctl", "kickstart", "-k", target], check=True, timeout=90) print("✓ Service restarted") except subprocess.CalledProcessError as e: - if e.returncode != 3: + if e.returncode not in (3, 113): raise # Job not loaded — bootstrap and start fresh print("↻ launchd job was unloaded; reloading") diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index b08fb46c3..03c9c56ec 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -205,6 +205,33 @@ class TestLaunchdServiceRecovery: ["launchctl", "kickstart", target], ] + def test_launchd_start_reloads_on_kickstart_exit_code_113(self, tmp_path, monkeypatch): + """Exit code 113 (\"Could not find service\") should also trigger bootstrap recovery.""" + plist_path = tmp_path / "ai.hermes.gateway.plist" + plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8") + label = gateway_cli.get_launchd_label() + + calls = [] + domain = gateway_cli._launchd_domain() + target = f"{domain}/{label}" + + def fake_run(cmd, check=False, **kwargs): + calls.append(cmd) + if cmd == ["launchctl", "kickstart", target] and calls.count(cmd) == 1: + raise gateway_cli.subprocess.CalledProcessError(113, cmd, stderr="Could not find service") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + gateway_cli.launchd_start() + + assert calls == [ + ["launchctl", "kickstart", target], + ["launchctl", "bootstrap", domain, str(plist_path)], + ["launchctl", "kickstart", target], + ] + def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path, monkeypatch, capsys): plist_path = tmp_path / "ai.hermes.gateway.plist" plist_path.write_text("old content", encoding="utf-8")