fix(gateway): stop launchd osascript idle CPU spin
(cherry picked from commit ef6f22810341601bb8de58c9fbba5423361fbe23)
This commit is contained in:
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user