From 6f50c5607b3174d50c3344e73e5fde9abfdc2768 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Tue, 9 Jun 2026 15:57:23 +0800 Subject: [PATCH] fix(gateway): handle PermissionError on stale root-owned lock file When the macOS launchd service runs in a Background session, the gateway process spawns as root and creates a root-owned gateway.lock. On restart as the normal user, open() on that file raises PermissionError, crashing the gateway immediately and entering a launchd crash loop. Catch PermissionError in is_gateway_runtime_lock_active(), remove the stale lock file, and return False so the new process can start cleanly. Fixes #42685 --- gateway/status.py | 13 ++++++++- tests/gateway/test_status.py | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/gateway/status.py b/gateway/status.py index c0f4f12fbe2..4c5f08416ea 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -834,7 +834,18 @@ def is_gateway_runtime_lock_active(lock_path: Optional[Path] = None) -> bool: if not resolved_lock_path.exists(): return False - handle = open(resolved_lock_path, "a+", encoding="utf-8") + try: + handle = open(resolved_lock_path, "a+", encoding="utf-8") + except PermissionError: + # Stale root-owned lock file from a previous launchd Background + # session that ran as root. The parent directory owner can unlink + # files even when they don't own them, so remove the stale lock + # and report inactive — the new process will create a fresh one. + try: + resolved_lock_path.unlink() + except OSError: + pass + return False try: if _try_acquire_file_lock(handle): _release_file_lock(handle) diff --git a/tests/gateway/test_status.py b/tests/gateway/test_status.py index 9ac3b50cd74..c82ebcadeeb 100644 --- a/tests/gateway/test_status.py +++ b/tests/gateway/test_status.py @@ -1753,3 +1753,55 @@ class TestLaunchdPlistRespawnGovernance: assert "ThrottleInterval" in plist assert "ExitTimeOut" in plist assert "KeepAlive" in plist + + +class TestPermissionErrorOnLockFile: + """Stale root-owned lock files from launchd Background sessions must not + crash the gateway on restart (issue #42685).""" + + def test_permission_error_on_lock_file_returns_false_and_removes(self, tmp_path, monkeypatch): + """When the lock file is not writable (root-owned), the function should + remove the stale file and report the lock as inactive.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + lock_path = tmp_path / "gateway.lock" + lock_path.write_text("stale", encoding="utf-8") + + real_open = open + + def deny_write(path, *args, **kwargs): + if str(path) == str(lock_path): + raise PermissionError(13, "Permission denied", str(path)) + return real_open(path, *args, **kwargs) + + monkeypatch.setattr("builtins.open", deny_write) + + result = status.is_gateway_runtime_lock_active(lock_path) + assert result is False + assert not lock_path.exists(), "stale root-owned lock file should be removed" + + def test_permission_error_unlink_failure_still_returns_false(self, tmp_path, monkeypatch): + """Even if unlinking the stale lock file fails (e.g. directory not writable), + the function should still return False to allow startup.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + lock_path = tmp_path / "gateway.lock" + lock_path.write_text("stale", encoding="utf-8") + + real_open = open + + def deny_write(path, *args, **kwargs): + if str(path) == str(lock_path): + raise PermissionError(13, "Permission denied", str(path)) + return real_open(path, *args, **kwargs) + + real_unlink = Path.unlink + + def deny_unlink(self, *args, **kwargs): + if str(self) == str(lock_path): + raise OSError(13, "Permission denied", str(self)) + return real_unlink(self, *args, **kwargs) + + monkeypatch.setattr("builtins.open", deny_write) + monkeypatch.setattr(Path, "unlink", deny_unlink) + + result = status.is_gateway_runtime_lock_active(lock_path) + assert result is False