diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index caa0b6133..28ef9cfb6 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -4082,6 +4082,11 @@ def launchd_restart(): print("↻ launchd job was unloaded; reloading") plist_path = get_launchd_plist_path() try: + subprocess.run( + ["launchctl", "bootout", target], + check=False, + timeout=90, + ) subprocess.run( ["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index fa7d2e982..55753f3e1 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -1077,6 +1077,46 @@ class TestLaunchdServiceRecovery: assert spawned == [True] assert gateway_cli._launchd_unsupported_marker_exists() + def test_launchd_restart_boots_out_stale_registration_before_bootstrap( + self, tmp_path, monkeypatch + ): + 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() + domain = gateway_cli._launchd_domain() + target = f"{domain}/{label}" + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 5.0) + monkeypatch.setattr(gateway_cli, "_request_gateway_self_restart", lambda pid: False) + monkeypatch.setattr( + gateway_cli, "_wait_for_gateway_exit", lambda timeout, force_after=None: True + ) + monkeypatch.setattr(gateway_cli, "terminate_pid", lambda pid, force=False: None) + monkeypatch.setattr("gateway.status.get_running_pid", lambda: 321) + + calls = [] + + def fake_run(cmd, check=False, **kwargs): + if cmd and cmd[0] == "launchctl": + calls.append(cmd) + if cmd == ["launchctl", "kickstart", "-k", target]: + raise gateway_cli.subprocess.CalledProcessError( + 3, cmd, stderr="Could not find service" + ) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + gateway_cli.launchd_restart() + + assert calls == [ + ["launchctl", "kickstart", "-k", target], + ["launchctl", "bootout", target], + ["launchctl", "bootstrap", domain, str(plist_path)], + ["launchctl", "kickstart", target], + ] + def test_launchd_stop_tolerates_domain_unsupported_bootout(self, monkeypatch, capsys): """bootout exit 125 (macOS 26) must fall through to PID-based kill, not raise.""" def fake_run(cmd, check=False, **kwargs):