fix(gateway): defer macOS launchd reload when run inside the gateway tree (#47842)

When refresh_launchd_plist_if_needed() runs from inside the gateway's own
launchd process tree (agent-initiated self-update via the terminal tool), a
direct launchctl bootout tears down the service's process group — including
the CLI doing the refresh — before the follow-up bootstrap can run. The
gateway is left unloaded and KeepAlive can't revive it (#43842).

Detect in-service execution via gateway.status.get_running_pid() +
_is_pid_ancestor_of_current_process(), and delegate the bootout->bootstrap to
a detached (start_new_session=True) helper that survives the process-group
teardown. The normal out-of-tree CLI path is unchanged.

Fixes #43842.
This commit is contained in:
Teknium 2026-06-17 05:19:21 -07:00 committed by GitHub
parent 4d39a603d1
commit e48803daec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 155 additions and 2 deletions

View file

@ -7,6 +7,7 @@ Handles: hermes gateway [run|start|stop|restart|status|install|uninstall|setup]
import asyncio
import logging
import os
import shlex
import shutil
import signal
import subprocess
@ -3467,14 +3468,60 @@ def refresh_launchd_plist_if_needed() -> bool:
plist_path.write_text(new_plist, encoding="utf-8")
label = get_launchd_label()
domain = _launchd_domain()
target = f"{domain}/{label}"
# If this refresh is running INSIDE the gateway's own launchd process tree
# (e.g. the agent triggered a self-update via its terminal tool), a direct
# `launchctl bootout` tears down the service's process group — which
# includes THIS CLI — before the follow-up `bootstrap` can run. The gateway
# then stays unloaded and KeepAlive can't revive it (#43842). Detect that
# case and hand the reload to a detached session that survives the bootout.
gateway_pid = None
try:
from gateway.status import get_running_pid
gateway_pid = get_running_pid()
except Exception:
gateway_pid = None
if (
gateway_pid is not None
and _is_pid_ancestor_of_current_process(gateway_pid)
and hasattr(os, "setsid") # POSIX-only; launchd is macOS so always true here
):
# Delegate to a new session: `start_new_session=True` detaches the
# helper from the gateway's process group, so the bootout that kills
# the gateway (and us) does not kill the helper before it bootstraps.
reload_script = (
f"sleep 2; "
f"launchctl bootout {shlex.quote(target)} 2>/dev/null; "
f"sleep 1; "
f"launchctl bootstrap {shlex.quote(domain)} {shlex.quote(str(plist_path))} 2>/dev/null"
)
try:
subprocess.Popen(
["/bin/bash", "-c", reload_script],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception as e:
logger.warning("Deferred launchd reload could not be spawned: %s", e)
return False
print(
"↻ Updated gateway launchd service definition; reload deferred to a "
"detached helper (refresh ran inside the gateway process tree)"
)
return True
# Bootout/bootstrap so launchd picks up the new definition
subprocess.run(
["launchctl", "bootout", f"{_launchd_domain()}/{label}"],
["launchctl", "bootout", target],
check=False,
timeout=90,
)
subprocess.run(
["launchctl", "bootstrap", _launchd_domain(), str(plist_path)],
["launchctl", "bootstrap", domain, str(plist_path)],
check=False,
timeout=30,
)