fix(gateway): silence expected launchctl bootout/kickstart noise on macOS

Best-effort bootout calls (unloaded-job recovery, stale-EIO retry,
plist refresh, uninstall) and the handled kickstart -k in
launchd_restart inherited the terminal's stderr, so an expected
unloaded job printed raw launchctl errors around the CLI's own lines:

  Could not find service "ai.hermes.gateway" in domain for user gui: 501
  ↻ launchd job was unloaded; reloading
  Boot-out failed: 3: No such process

Capture them with _CAPTURE_TEXT instead. The kickstart error stays
available as e.stderr for the update_cmd failure diagnostic, and the
post-bootstrap kickstart intentionally stays loud (its failure feeds
the domain-unsupported fallback). Same precedent as the reload
helper, which already runs bootout with 2>/dev/null.

[salvage: picked hermes_cli/gateway.py only; the two capture_output kwarg-assertion tests are
replaced by fd-level invariant tests with a fake launchctl in the follow-up commit]
This commit is contained in:
Davy
2026-09-08 22:11:56 -07:00
committed by Teknium
parent 79d73e8f63
commit ebcc87ad4f
+17 -5
View File
@@ -3559,7 +3559,11 @@ def _launchctl_bootstrap(domain: str, plist_path, label: str, *, timeout: int =
if exc.returncode != _LAUNCHCTL_BOOTSTRAP_EIO:
raise
# Stale registration — bootout the leftover label and bootstrap once more.
subprocess.run(["launchctl", "bootout", f"{domain}/{label}"], check=False, timeout=timeout)
# Captured: the bootout is best-effort (a drained job may already be
# unloaded), so its expected 3/113/125 stderr must not leak to the terminal.
subprocess.run(
["launchctl", "bootout", f"{domain}/{label}"],
check=False, timeout=timeout, **_CAPTURE_TEXT)
subprocess.run(bootstrap, check=True, timeout=timeout)
@@ -3941,7 +3945,8 @@ def refresh_launchd_plist_if_needed() -> bool:
# Bootout/bootstrap so launchd reads the new definition; bootstrap can fail silently under load
# during a drain, and KeepAlive can't revive an unregistered job.
subprocess.run(["launchctl", "bootout", target], check=False, timeout=90)
# Captured: best-effort (the job may already be unloaded), keep expected noise off the terminal.
subprocess.run(["launchctl", "bootout", target], check=False, timeout=90, **_CAPTURE_TEXT)
_reload_budget = _launchd_reload_budget()
# Wait out the old gateway's drain first so the budget isn't burned on guaranteed EIO ("already loaded").
if gateway_pid is not None and not _wait_for_pid_exit(gateway_pid, _reload_budget):
@@ -4001,7 +4006,10 @@ def launchd_install(force: bool = False):
def launchd_uninstall():
plist_path = get_launchd_plist_path()
subprocess.run(["launchctl", "bootout", f"{_launchd_domain()}/{get_launchd_label()}"], check=False, timeout=90)
# Captured: uninstalling an already-unloaded job is fine — don't print Boot-out failed: 3.
subprocess.run(
["launchctl", "bootout", f"{_launchd_domain()}/{get_launchd_label()}"],
check=False, timeout=90, **_CAPTURE_TEXT)
if plist_path.exists():
plist_path.unlink()
print(f"✓ Removed {plist_path}")
@@ -4158,7 +4166,9 @@ def launchd_restart():
print("⚠ launchd did not revive the gateway after its graceful exit — forcing restart")
else:
print(f"⚠ Gateway drain timed out after {wait_budget:.0f}s — forcing launchd restart")
subprocess.run(["launchctl", "kickstart", "-k", target], check=True, timeout=90)
# Captured: an unloaded job (3/113/125) is the expected case below, which
# prints its own ↻ line — and e.stderr feeds the update_cmd failure diagnostic.
subprocess.run(["launchctl", "kickstart", "-k", target], check=True, timeout=90, **_CAPTURE_TEXT)
_launchd_ok("✓ Service restarted")
except subprocess.CalledProcessError as e:
if not _launchd_error_indicates_unloaded(e):
@@ -4168,7 +4178,9 @@ def launchd_restart():
print("↻ launchd job was unloaded; reloading")
try:
# After a drain the job is usually still registered (bootstrap would hit EIO): boot it out first.
subprocess.run(["launchctl", "bootout", target], check=False, timeout=90)
# Captured: best-effort (the job may already be unloaded after the drain),
# so an expected Boot-out failed: 3 must not leak past the ↻ line below.
subprocess.run(["launchctl", "bootout", target], check=False, timeout=90, **_CAPTURE_TEXT)
plist_path = str(get_launchd_plist_path())
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), plist_path], check=True, timeout=30)
subprocess.run(["launchctl", "kickstart", target], check=True, timeout=30)