fix(gateway): stop launchd osascript idle CPU spin

(cherry picked from commit ef6f22810341601bb8de58c9fbba5423361fbe23)
This commit is contained in:
IntrepidBytes
2026-09-22 13:24:49 -07:00
committed by kshitij
parent 61a2148370
commit 8682d5791b
3 changed files with 58 additions and 22 deletions

View File

@@ -220,20 +220,27 @@ def launchd_program_arguments(command: list[str], stdout_log: Path, stderr_log:
venv Python has no application ID and is not platform-entitled, so every LAN connect from the
launchd gateway dies with ``EHOSTUNREACH`` while the same code works from Terminal (whose grant it
inherits). An ad-hoc-signed helper .app does not help: nehelper never prompts for it and denies
(#57812 dead-end table, re-verified live on macOS 26.3). ``/usr/bin/osascript``'s ``do shell script``
spawns its child as osascript-responsible — an Apple platform binary — so the child is exempt;
``/bin/sh -c exec …`` and ``/usr/bin/time`` wrappers are NOT (the launchd job identity is the
non-entitled first executable). ``do shell script`` buffers the child's stdout/stderr until it exits,
so the command appends both to the same files the plist's ``StandardOutPath``/``StandardErrorPath``
name (those keys stay: they are where osascript's own output lands — an empty result line per exit
and an un-timestamped ``execution error`` line on non-zero exit); ``exec`` keeps the
gateway a direct child in the job's process group, so ``launchctl bootout`` / ``kickstart -k`` still
deliver SIGTERM to it and KeepAlive's ``SuccessfulExit`` semantics are preserved (osascript exits 0
exactly when the shell did).
(#57812 dead-end table, re-verified live on macOS 26.3). ``/usr/bin/osascript`` spawning the child
makes it osascript-responsible — an Apple platform binary — so the child is exempt; ``/bin/sh -c
exec …`` and ``/usr/bin/time`` wrappers are NOT (the launchd job identity is the non-entitled first
executable).
Standard Additions' ``do shell script`` polls WindowServer for a user-cancel event while it waits.
That is appropriate for a short interactive script but burns CPU for the gateway's process lifetime.
JXA calling libc ``system()`` waits in the kernel instead while retaining osascript as the responsible
process. The shell's ``exec`` keeps the gateway in the launchd job's process group, so ``launchctl
bootout`` / ``kickstart -k`` still deliver SIGTERM to it. stdout/stderr are appended inside the shell
command because ``system()`` otherwise inherits osascript's plist log handles. The encoded wait status
is translated back to a process exit code so KeepAlive's ``SuccessfulExit`` semantics are preserved.
"""
shell = f"exec {shlex.join(command)} >> {shlex.quote(str(stdout_log))} 2>> {shlex.quote(str(stderr_log))}"
applescript = shell.replace("\\", "\\\\").replace('"', '\\"')
return ["/usr/bin/osascript", "-e", f'do shell script "{applescript}"']
javascript = (
'ObjC.import("stdlib"); '
f"const status=$.system({json.dumps(shell)}); "
"const signal=status & 127; "
"$.exit(status === -1 ? 1 : signal === 0 ? (status >> 8) & 255 : 128 + signal);"
)
return ["/usr/bin/osascript", "-l", "JavaScript", "-e", javascript]
def _timestamped_stderr_gateway_command(error_log: Path, *, external_supervisor: bool = False) -> list[str]:

View File

@@ -6,6 +6,7 @@ import plistlib
import re
import shlex
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
@@ -16,6 +17,7 @@ pwd = pytest.importorskip("pwd")
grp = pytest.importorskip("grp")
import hermes_cli.gateway as gateway_cli
from hermes_cli.gateway_launchd import launchd_program_arguments
from gateway import status
from gateway.restart import (
DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT,
@@ -27,13 +29,17 @@ from gateway.restart import (
def _osascript_exec_argv(program_args: list[str]) -> list[str]:
"""The argv a launchd ``ProgramArguments`` of ``/usr/bin/osascript -e <script>`` hands to ``exec`` —
undoing the AppleScript string escaping, then POSIX shell quoting, the way osascript and /bin/sh will."""
assert program_args[:2] == ["/usr/bin/osascript", "-e"] and len(program_args) == 3, program_args
script = program_args[2]
prefix, suffix = 'do shell script "', '"'
assert script.startswith(prefix) and script.endswith(suffix), script
shell = re.sub(r"\\(.)", r"\1", script[len(prefix):-len(suffix)])
"""The argv the launchd JXA wrapper's libc ``system()`` hands to ``exec``."""
assert program_args[:4] == ["/usr/bin/osascript", "-l", "JavaScript", "-e"], program_args
assert len(program_args) == 5, program_args
script = program_args[4]
match = re.fullmatch(
r'ObjC\.import\("stdlib"\); const status=\$\.system\((.+)\); const signal=status & 127; '
r'\$\.exit\(status === -1 \? 1 : signal === 0 \? \(status >> 8\) & 255 : 128 \+ signal\);',
script,
)
assert match, script
shell = json.loads(match.group(1))
exec_, *argv = shlex.split(shell)
assert exec_ == "exec", shell
return argv
@@ -1745,7 +1751,8 @@ class TestProfileArg:
program_args = plistlib.loads(plist.encode("utf-8"))["ProgramArguments"]
# The job is launched through osascript so macOS Local Network Privacy attributes the
# gateway's sockets to a platform binary (#71206); the real command is the exec'd child,
# gateway's sockets to a platform binary (#71206); JXA system() waits without the
# Standard Additions user-cancel polling loop. The real command is the exec'd child,
# whose python runs through the PM installation launcher (-I -c bootstrap ...).
exec_argv = _osascript_exec_argv(program_args)
assert exec_argv[-4:] == [">>", str(profile_dir / "logs" / "gateway.log"),
@@ -1759,7 +1766,7 @@ class TestProfileArg:
assert "--replace" not in program_args
def test_launchd_osascript_wrapper_round_trips_shell_hostile_paths(self, tmp_path, monkeypatch):
"""A home with spaces, quotes and a backslash survives shlex + AppleScript + plist quoting."""
"""A home with spaces, quotes and a backslash survives shlex + JXA + plist quoting."""
profile_dir = tmp_path / 'my "odd" dir \\ here' / ".hermes"
profile_dir.mkdir(parents=True)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
@@ -1775,6 +1782,28 @@ class TestProfileArg:
# The wrapper's own ps line must never be taken for the gateway (stop/status would signal osascript).
assert status.looks_like_gateway_command_line(" ".join(program_args)) is False
@pytest.mark.skipif(sys.platform != "darwin", reason="osascript is macOS-only")
def test_launchd_osascript_wrapper_preserves_process_group_and_exit_status(self, tmp_path):
"""The non-polling JXA wait keeps lifecycle signals and KeepAlive failure semantics intact."""
stdout_log = tmp_path / "stdout.log"
stderr_log = tmp_path / "stderr.log"
command = [
sys.executable,
"-c",
"import os, sys; print(os.getpgrp(), os.getpgid(os.getppid())); sys.exit(23)",
]
result = subprocess.run(
launchd_program_arguments(command, stdout_log, stderr_log),
check=False,
timeout=10,
)
assert result.returncode == 23
child_group, wrapper_group = stdout_log.read_text().split()
assert child_group == wrapper_group
assert stderr_log.read_text() == ""
def test_launchd_plist_path_uses_real_user_home_not_profile_home(self, tmp_path, monkeypatch):
profile_dir = tmp_path / ".hermes" / "profiles" / "orcha"
profile_dir.mkdir(parents=True)

View File

@@ -676,7 +676,7 @@ The plist sets `RunAtLoad`, so loading it starts the gateway. `hermes gateway in
:::
:::info Local Network access (LAN devices fail with "No route to host")
macOS Local Network Privacy attributes a socket to the executable launchd spawned for the job. A bare venv Python has no application identity, so a launchd-run gateway could not reach LAN hosts (Home Assistant, local model servers) — every connect failed with `errno 65 No route to host` while the same URL worked from Terminal, and no prompt was ever shown to grant it. The generated plist therefore runs the gateway through `/usr/bin/osascript` (`do shell script "exec …"`), whose children macOS treats as osascript's own — an Apple platform binary, exempt from the check. `ps` shows `osascript → stderr_timestamp → gateway run`; stop/restart/KeepAlive behave exactly as before. A plist installed by an older Hermes is refreshed by `hermes gateway install` (or on the next `hermes gateway start`).
macOS Local Network Privacy attributes a socket to the executable launchd spawned for the job. A bare venv Python has no application identity, so a launchd-run gateway could not reach LAN hosts (Home Assistant, local model servers) — every connect failed with `errno 65 No route to host` while the same URL worked from Terminal, and no prompt was ever shown to grant it. The generated plist therefore runs the gateway through `/usr/bin/osascript`; a JXA `system()` call starts the gateway without an interactive event-polling loop, and macOS treats its children as osascript's own — an Apple platform binary, exempt from the check. `ps` shows `osascript → stderr_timestamp → gateway run`; stop/restart/KeepAlive behave exactly as before. A plist installed by an older Hermes is refreshed by `hermes gateway install` (or on the next `hermes gateway start`).
:::
:::tip Picking up new credentials after `hermes auth add` / `hermes auth reset`