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)