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