From ebcc87ad4fbb94cfa3f38b7e0eb55aa85a36403c Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:11:56 -0700 Subject: [PATCH] fix(gateway): silence expected launchctl bootout/kickstart noise on macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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] --- hermes_cli/gateway.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 0cab694892..178f91fbc0 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -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)