fix(gateway): fall back to detached launch when launchd rejects domain (macOS 26)

macOS 26+ broke launchctl management of the gui/<uid> (and user/<uid>)
domains: `bootstrap` returns error 5 and `kickstart` returns error 125
("Domain does not support specified action"), so `hermes gateway
start/install/restart` crashed with a cryptic traceback (#23387).

Detect these codes and degrade gracefully: launch the gateway as a
CLI-managed detached background process (the documented `nohup hermes
gateway run --replace` workaround), with logs to gateway.log and the PID
tracked via gateway.pid so stop/status/restart keep working. Print clear
guidance that the service won't auto-start at login or auto-restart on
crash on this macOS version. launchd_stop also tolerates 125/5 from
bootout and falls through to the PID-based kill.
This commit is contained in:
Brooklyn Nicholson 2026-06-06 10:21:27 -05:00 committed by Teknium
parent 2666638192
commit 59c273ba3a
2 changed files with 279 additions and 33 deletions

View File

@ -3003,6 +3003,99 @@ def _launchd_domain() -> str:
return f"gui/{os.getuid()}" # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows
# macOS 26+ broke launchctl management of the per-user GUI domain: `bootstrap`
# returns error 5 ("Input/output error") and `kickstart` returns error 125
# ("Domain does not support specified action"). When launchd refuses to manage
# the gateway we can't supervise it as a service, so we fall back to a detached
# background process (the documented `nohup hermes gateway run` workaround).
# See issue #23387.
_LAUNCHCTL_DOMAIN_UNSUPPORTED_CODES = frozenset({5, 125})
def _launchctl_domain_unsupported(returncode: int) -> bool:
"""True when launchctl rejected the action because the domain can't manage it.
Codes 5 and 125 are emitted by macOS 26+ for `bootstrap`/`kickstart` against
the `gui/<uid>` (and `user/<uid>`) domains, which no longer support service
management. Treat these as "launchd unavailable" and degrade gracefully.
"""
return returncode in _LAUNCHCTL_DOMAIN_UNSUPPORTED_CODES
def _gateway_run_command() -> list[str]:
"""Build the `python -m hermes_cli.main [--profile X] gateway run --replace` argv.
Profile-aware: honors the active HERMES_HOME via `_profile_arg()` so the
detached fallback launches into the same profile as the CLI invocation.
"""
cmd = [get_python_path(), "-m", "hermes_cli.main"]
profile_arg = _profile_arg()
if profile_arg:
cmd.extend(profile_arg.split())
cmd.extend(["gateway", "run", "--replace"])
return cmd
def _spawn_detached_gateway() -> bool:
"""Launch the gateway as a detached background process (launchd fallback).
Used when launchctl can no longer bootstrap/kickstart the gateway on
macOS 26+ (issue #23387). Mirrors the `nohup hermes gateway run --replace`
workaround but keeps it CLI-managed: stdout/stderr go to the profile's
gateway logs and the PID is tracked via the gateway.pid file that
`run_gateway` writes, so stop/status/restart keep working.
"""
from hermes_cli._subprocess_compat import windows_detach_popen_kwargs
log_dir = get_hermes_home() / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
out_path = log_dir / "gateway.log"
err_path = log_dir / "gateway.error.log"
try:
out = open(out_path, "ab")
err = open(err_path, "ab")
except OSError:
return False
try:
with out, err:
subprocess.Popen(
_gateway_run_command(),
stdin=subprocess.DEVNULL,
stdout=out,
stderr=err,
**windows_detach_popen_kwargs(),
)
except OSError:
return False
return True
def _launchd_fallback_to_detached(reason: str, *, exit_on_failure: bool = True) -> bool:
"""Start the gateway detached when launchd can't manage it, with guidance.
Returns True if the detached gateway was launched. When it can't be
launched, prints the manual workaround and (by default) exits non-zero so
the failure surfaces instead of silently doing nothing.
"""
from hermes_constants import display_hermes_home as _dhh
print(f"⚠ launchd cannot manage the gateway on this macOS version ({reason}).")
if _spawn_detached_gateway():
print("✓ Started gateway as a background process instead")
print(" It will NOT auto-start at login or auto-restart on crash.")
print(f" Logs: {_dhh()}/logs/gateway.log")
print(" Stop it with: hermes gateway stop")
return True
print_error("Failed to start the gateway as a background process.")
print(
f" Try manually: nohup hermes gateway run --replace "
f"> {_dhh()}/logs/gateway.log 2>&1 &"
)
if exit_on_failure:
sys.exit(1)
return False
def generate_launchd_plist() -> str:
python_path = get_python_path()
# Stable cwd anchor — never the volatile source checkout. See
@ -3154,11 +3247,17 @@ def launchd_install(force: bool = False):
print(f"Installing launchd service to: {plist_path}")
plist_path.write_text(generate_launchd_plist())
subprocess.run(
["launchctl", "bootstrap", _launchd_domain(), str(plist_path)],
check=True,
timeout=30,
)
try:
subprocess.run(
["launchctl", "bootstrap", _launchd_domain(), str(plist_path)],
check=True,
timeout=30,
)
except subprocess.CalledProcessError as e:
if not _launchctl_domain_unsupported(e.returncode):
raise
_launchd_fallback_to_detached(f"launchctl bootstrap exit {e.returncode}")
return
print()
print("✓ Service installed and loaded!")
@ -3195,16 +3294,22 @@ def launchd_start():
print("↻ launchd plist missing; regenerating service definition")
plist_path.parent.mkdir(parents=True, exist_ok=True)
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
subprocess.run(
["launchctl", "bootstrap", _launchd_domain(), str(plist_path)],
check=True,
timeout=30,
)
subprocess.run(
["launchctl", "kickstart", f"{_launchd_domain()}/{label}"],
check=True,
timeout=30,
)
try:
subprocess.run(
["launchctl", "bootstrap", _launchd_domain(), str(plist_path)],
check=True,
timeout=30,
)
subprocess.run(
["launchctl", "kickstart", f"{_launchd_domain()}/{label}"],
check=True,
timeout=30,
)
except subprocess.CalledProcessError as e:
if not _launchctl_domain_unsupported(e.returncode):
raise
_launchd_fallback_to_detached(f"launchctl exit {e.returncode}")
return
print("✓ Service started")
return
@ -3216,19 +3321,28 @@ def launchd_start():
timeout=30,
)
except subprocess.CalledProcessError as e:
if _launchctl_domain_unsupported(e.returncode):
_launchd_fallback_to_detached(f"launchctl kickstart exit {e.returncode}")
return
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,
)
subprocess.run(
["launchctl", "kickstart", f"{_launchd_domain()}/{label}"],
check=True,
timeout=30,
)
try:
subprocess.run(
["launchctl", "bootstrap", _launchd_domain(), str(plist_path)],
check=True,
timeout=30,
)
subprocess.run(
["launchctl", "kickstart", f"{_launchd_domain()}/{label}"],
check=True,
timeout=30,
)
except subprocess.CalledProcessError as e2:
if not _launchctl_domain_unsupported(e2.returncode):
raise
_launchd_fallback_to_detached(f"launchctl exit {e2.returncode}")
return
print("✓ Service started")
@ -3250,8 +3364,11 @@ def launchd_stop():
try:
subprocess.run(["launchctl", "bootout", target], check=True, timeout=90)
except subprocess.CalledProcessError as e:
if e.returncode in {3, 113}:
pass # Already unloaded — nothing to stop.
# 3/113: job already unloaded. 5/125: macOS 26+ can't manage the domain
# (issue #23387) — the gateway is a detached fallback process, so just
# fall through to the PID-based kill below.
if e.returncode in {3, 113} or _launchctl_domain_unsupported(e.returncode):
pass
else:
raise
_wait_for_gateway_exit(timeout=10.0, force_after=5.0)
@ -3335,17 +3452,29 @@ def launchd_restart():
subprocess.run(["launchctl", "kickstart", "-k", target], check=True, timeout=90)
print("✓ Service restarted")
except subprocess.CalledProcessError as e:
if _launchctl_domain_unsupported(e.returncode):
# macOS 26+ can't kickstart the domain (issue #23387). The old
# process was already drained/terminated above, so relaunch a
# fresh detached gateway.
_launchd_fallback_to_detached(f"launchctl kickstart exit {e.returncode}")
return
if e.returncode not in {3, 113}:
raise
# Job not loaded — bootstrap and start fresh
print("↻ launchd job was unloaded; reloading")
plist_path = get_launchd_plist_path()
subprocess.run(
["launchctl", "bootstrap", _launchd_domain(), str(plist_path)],
check=True,
timeout=30,
)
subprocess.run(["launchctl", "kickstart", target], check=True, timeout=30)
try:
subprocess.run(
["launchctl", "bootstrap", _launchd_domain(), str(plist_path)],
check=True,
timeout=30,
)
subprocess.run(["launchctl", "kickstart", target], check=True, timeout=30)
except subprocess.CalledProcessError as e2:
if not _launchctl_domain_unsupported(e2.returncode):
raise
_launchd_fallback_to_detached(f"launchctl exit {e2.returncode}")
return
print("✓ Service restarted")

View File

@ -679,6 +679,123 @@ class TestLaunchdServiceRecovery:
assert "stale" in output.lower()
assert "not loaded" in output.lower()
def test_launchctl_domain_unsupported_recognizes_macos26_codes(self):
# macOS 26+ rejects gui/<uid> management with these codes (issue #23387).
assert gateway_cli._launchctl_domain_unsupported(5) is True
assert gateway_cli._launchctl_domain_unsupported(125) is True
# Codes that mean "job not loaded" are NOT domain-unsupported.
assert gateway_cli._launchctl_domain_unsupported(3) is False
assert gateway_cli._launchctl_domain_unsupported(113) is False
assert gateway_cli._launchctl_domain_unsupported(0) is False
def test_launchd_start_falls_back_to_detached_on_kickstart_125(self, tmp_path, monkeypatch, capsys):
"""macOS 26 kickstart error 125 should spawn a detached gateway, not crash."""
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()
target = f"{gateway_cli._launchd_domain()}/{label}"
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
monkeypatch.setattr(gateway_cli, "refresh_launchd_plist_if_needed", lambda: False)
def fake_run(cmd, check=False, **kwargs):
if cmd == ["launchctl", "kickstart", target]:
raise gateway_cli.subprocess.CalledProcessError(
125, cmd, stderr="Domain does not support specified action"
)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
spawned = []
monkeypatch.setattr(
gateway_cli, "_spawn_detached_gateway", lambda: spawned.append(True) or True
)
gateway_cli.launchd_start()
assert spawned == [True]
out = capsys.readouterr().out.lower()
assert "background process" in out
def test_launchd_install_falls_back_to_detached_on_bootstrap_5(self, tmp_path, monkeypatch, capsys):
"""macOS 26 bootstrap error 5 should spawn a detached gateway, not crash."""
plist_path = tmp_path / "ai.hermes.gateway.plist"
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
def fake_run(cmd, check=False, **kwargs):
if cmd[:2] == ["launchctl", "bootstrap"]:
raise gateway_cli.subprocess.CalledProcessError(
5, cmd, stderr="Input/output error"
)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
spawned = []
monkeypatch.setattr(
gateway_cli, "_spawn_detached_gateway", lambda: spawned.append(True) or True
)
gateway_cli.launchd_install(force=True)
assert spawned == [True]
assert "Service installed and loaded" not in capsys.readouterr().out
def test_launchd_restart_falls_back_to_detached_on_kickstart_125(self, monkeypatch, capsys):
"""When kickstart -k returns 125, restart should relaunch detached."""
target = f"{gateway_cli._launchd_domain()}/{gateway_cli.get_launchd_label()}"
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)
def fake_run(cmd, check=False, **kwargs):
if cmd == ["launchctl", "kickstart", "-k", target]:
raise gateway_cli.subprocess.CalledProcessError(
125, cmd, stderr="Domain does not support specified action"
)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
spawned = []
monkeypatch.setattr(
gateway_cli, "_spawn_detached_gateway", lambda: spawned.append(True) or True
)
gateway_cli.launchd_restart()
assert spawned == [True]
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):
if "bootout" in cmd:
raise gateway_cli.subprocess.CalledProcessError(
125, cmd, stderr="Domain does not support specified action"
)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
monkeypatch.setattr(gateway_cli, "_wait_for_gateway_exit", lambda **kw: None)
gateway_cli.launchd_stop()
assert "stopped" in capsys.readouterr().out.lower()
def test_launchd_fallback_exits_when_spawn_fails(self, monkeypatch, capsys):
"""If the detached spawn fails, surface the manual workaround and exit 1."""
monkeypatch.setattr(gateway_cli, "_spawn_detached_gateway", lambda: False)
with pytest.raises(SystemExit) as exc:
gateway_cli._launchd_fallback_to_detached("test reason")
assert exc.value.code == 1
out = capsys.readouterr().out
assert "nohup hermes gateway run" in out
class TestGatewayServiceDetection:
def test_supports_systemd_services_requires_systemctl_binary(self, monkeypatch):