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:
+17
-5
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user