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
This commit is contained in:
liuhao1024 2026-06-09 15:57:23 +08:00 committed by Teknium
parent 2b72e06662
commit 6f50c5607b
2 changed files with 64 additions and 1 deletions

View file

@ -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)

View file

@ -1753,3 +1753,55 @@ class TestLaunchdPlistRespawnGovernance:
assert "<key>ThrottleInterval</key>" in plist
assert "<key>ExitTimeOut</key>" in plist
assert "<key>KeepAlive</key>" 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