Gateway stale lock detection broken on macOS/Windows
Cluster 16376 · gateway. Data as of
1 open item. Fix merged in #68650 on 21 Jul 2026. Check it covers your case.
The short answer
- Known?
- Yes. 25 related items grouped here, 7 issues already closed.
- Fix event
- A fix was merged to main in PR #68650. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #24500. That is separate from the fix event. - Still open
- 1 issue and 0 PRs.
Next step: read #68650, then check whether your report is the one still open.
Read #68650Pick up a Lab taskThese issues and fixes center on the gateway's stale lock detection failing on non-Linux platforms because process start-time retrieval relied on Linux-only mechanisms, causing PID reuse to go undetected and leaving stale locks in place. The work involves adding cross-platform fallbacks (psutil, ps, cmdline inspection) to correctly identify and evict stale scoped and platform locks.
Open issues1
- Issue#31890macOS gateway scoped locks can block adapters after PID reuse when start_time is null
Open PRs0
Fixes on record
-
#24500 fix(gateway): detect stale scoped locks via cmdline when start_time is absent on macOS
files:
gateway/status.py,scripts/release.py,tests/gateway/test_status.pyShow the diff
-
gateway/status.py@@ -124,16 +124,33 @@ def get_process_start_time(pid: int) -> Optional[int]: def _read_process_cmdline(pid: int) -> Optional[str]: - """Return the process command line as a space-separated string.""" + """Return the process command line as a space-separated string. + + On Linux, reads /proc/<pid>/cmdline directly. On macOS and other + platforms without /proc, falls back to ``ps -p <pid> -o command=``. + """ cmdline_path = Path(f"/proc/{pid}/cmdline") try: raw = cmdline_path.read_bytes() except (FileNotFoundError, PermissionError, OSError): - return None + pass + else: + if raw: + return raw.replace(b"\x00", b" ").decode("utf-8", errors="ignore").strip() - if not raw: - return None - return raw.replace(b"\x00", b" ").decode("utf-8", errors="ignore").strip() + try: + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "command="], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except (OSError, subprocess.TimeoutExpired): + pass + + return None def _looks_like_gateway_process(pid: int) -> bool: @@ -594,6 +611,17 @@ def acquire_scoped_lock(scope: str, identity: str, metadata: Optional[dict[str, and current_start != existing.get("start_time") ): stale = True + # When start_time comparison is unavailable (macOS / Windows + # have no /proc, so both sides are None), fall back to + # checking the live process command line. If the PID was + # reused by an unrelated process the lock is stale. + if ( + not stale + and existing.get("start_time") is None + and current_start is None + and not _looks_like_gateway_process(existing_pid) + ): + stale = True # Check if process is stopped (Ctrl+Z / SIGTSTP) — stopped # processes still appear alive to _pid_exists but are not # actually running. Treat them as stale so --replace works. -
scripts/release.py@@ -58,6 +58,7 @@ "[redacted:email]": "hrkzogw", "[redacted:email]": "dandacompany", "[redacted:email]": "TreyDong", + "[redacted:email]": "pkyanam", "[email protected]": "teknium1", "[redacted:email]": "Hugo-SEQUIER", "[email protected]": "Gutslabs", -
tests/gateway/test_status.py@@ -444,6 +444,59 @@ def test_acquire_scoped_lock_rejects_live_other_process(self, tmp_path, monkeypa assert acquired is False assert existing["pid"] == 99999 + def test_acquire_scoped_lock_replaces_pid_reused_by_unrelated_process(self, tmp_path, monkeypatch): + """macOS regression: PID reused by an unrelated process with start_time=None. + + On macOS /proc is unavailable, so both the lock record and the live + process report start_time=None. The live PID is alive (os.kill + succeeds) but belongs to a completely different program. The lock + must be treated as stale. + """ + monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks")) + lock_path = tmp_path / "locks" / "telegram-bot-token-2bb80d537b1da3e3.lock" + lock_path.parent.mkdir(parents=True, exist_ok=True) + lock_path.write_text(json.dumps({ + "pid": 873, + "start_time": None, + "kind": "hermes-gateway", + "argv": ["/Users/user/.hermes/hermes-agent/hermes_cli/main.py", "gateway", "run", "--replace"], + })) + + # Post-#21561 the liveness probe routes through + # ``gateway.status._pid_exists`` (psutil-first, safe on Windows), + # not ``os.kill``. + monkeypatch.setattr(status, "_pid_exists", lambda pid: True) + monkeypatch.setattr(status, "_get_process_start_time", lambda pid: None) + monkeypatch.setattr(status, "_looks_like_gateway_process", lambda pid: False) + + acquired, existing = status.acquire_scoped_lock("telegram-bot-token", "secret", metadata={"platform": "telegram"}) + + assert acquired is True + payload = json.loads(lock_path.read_text()) + assert payload["pid"] == os.getpid() + assert payload["metadata"]["platform"] == "telegram" + + def test_acquire_scoped_lock_keeps_lock_when_pid_reused_by_gateway(self, tmp_path, monkeypatch): + """When start_time is None but the live PID still looks like a gateway, keep the lock.""" + monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks")) + lock_path = tmp_path / "locks" / "telegram-bot-token-2bb80d537b1da3e3.lock" + lock_path.parent.mkdir(parents=True, exist_ok=True) + lock_path.write_text(json.dumps({ + "pid": 99999, + "start_time": None, + "kind": "hermes-gateway", + "argv": ["/Users/user/.hermes/hermes-agent/hermes_cli/main.py", "gateway", "run", "--replace"], + })) + + monkeypatch.setattr(status, "_pid_exists", lambda pid: True) + monkeypatch.setattr(status, "_get_process_start_time", lambda pid: None) + monkeypatch.setattr(status, "_looks_like_gateway_process", lambda pid: True) + + acquired, existing = status.acquire_scoped_lock("telegram-bot-token", "secret", metadata={"platform": "telegram"}) + + assert acquired is False + assert existing["pid"] == 99999 + def test_acquire_scoped_lock_replaces_stale_record(self, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks")) lock_path = tmp_path / "locks" / "telegram-bot-token-2bb80d537b1da3e3.lock" @@ -811,3 +864,46 @@ def raise_oserror(*args, **kwargs): ok = status.write_planned_stop_marker(target_pid=12345) assert ok is False + + +class TestReadProcessCmdlinePsFallback: + """Tests for _read_process_cmdline falling back to ps on non-Linux.""" + + def test_ps_fallback_when_proc_unavailable(self, monkeypatch): + monkeypatch.setattr(status.Path, "read_bytes", lambda self: (_ for _ in ()).throw(FileNotFoundError)) + monkeypatch.setattr( + status.subprocess, "run", + lambda args, **kwargs: SimpleNamespace(returncode=0, stdout="/usr/libexec/bluetoothuserd\n"), + ) + result = status._read_process_cmdline(873) + assert result == "/usr/libexec/bluetoothuserd" + + def test_ps_fallback_returns_none_on_failure(self, monkeypatch): + monkeypatch.setattr(status.Path, "read_bytes", lambda self: (_ for _ in ()).throw(FileNotFoundError)) + monkeypatch.setattr( + status.subprocess, "run", + lambda args, **kwargs: SimpleNamespace(returncode=1, stdout=""), + ) + result = status._read_process_cmdline(99999) + assert result is None + + def test_proc_cmdline_takes_priority_over_ps(self, monkeypatch): + calls = [] + + def fake_read_bytes(self): + calls.append("proc") + return b"python\x00hermes_cli/main.py\x00gateway\x00" + + monkeypatch.setattr(status.Path, "read_bytes", fake_read_bytes) + result = status._read_process_cmdline(12345) + assert "hermes_cli/main.py" in result + assert calls == ["proc"] + + def test_ps_fallback_used_when_proc_returns_empty(self, monkeypatch): + monkeypatch.setattr(status.Path, "read_bytes", lambda self: b"") + monkeypatch.setattr( + status.subprocess, "run", + lambda args, **kwargs: SimpleNamespace(returncode=0, stdout="python hermes_cli/main.py gateway run\n"), + ) + result = status._read_process_cmdline(12345) + assert "hermes_cli/main.py" in result
-
-
#68650 fix(gateway): stale scoped-lock detection for mixed-None start times + guarded lock reads + atomic removal
files:
gateway/status.py,tests/gateway/test_status.py
15 PRs closed without merging
From the close comments: duplicate 4 · merged via another change 4 · done elsewhere 3 · superseded by another PR 2 · closed without comment 1 · other 1
-
#16423 fix(gateway): detect stale scoped locks via cmdline when start_time is absent · done elsewhere →
653d304,#23953 -
#16432 fix(gateway): detect stale scoped lock when PID is reused on macOS (#16376) · duplicate →
#16423,#15144,#15584 -
#16655 fix(gateway): detect stale lock files on macOS via `ps -o lstart=` · superseded by another PR →
#15144 -
#16942 fix(gateway): add macOS/BSD fallback for process start-time and cmdline detection in lock files · duplicate →
#16432,#12448,#15144,#16423 -
#16966 fix(scoped_lock): treat null start_time lock as stale when live cmdline lacks hermes · duplicate →
#16432,#16376,#16423,#15144
Show 10 more
-
#21018 fix(gateway): detect recycled macOS lock PIDs · done elsewhere →
653d304,#16586 - #22490 fix(gateway): use psutil for cross-platform process start time · other
-
#22564 fix(gateway): macOS fallback for _get_process_start_time · duplicate →
#22490,#22564 -
#23953 fix(gateway): detect stale scoped locks via cmdline when start_time is absent on macOS · merged via another change →
653d304,#24500,#21561 - #24595 fix(gateway): use psutil for cross-platform process start_time + cmdline detection · closed without comment
-
#31923 fix(gateway): detect scoped-lock PID reuse on macOS when start_time is null (#31890) · superseded by another PR →
012f40c,#012,#31890 -
#35625 fix(gateway): cross-platform process start-time for scoped-lock PID-reuse guard · done elsewhere →
012f40c -
#53767 fix(gateway): detect stale lock when macOS psutil returns valid start_time for recycled PID · merged via another change →
#68650,#42689 -
#54279 fix(gateway): treat null-start scoped locks as stale · merged via another change →
#68650,#53767 -
#56919 fix(gateway): honor cmdline fallback for null-start_time locks when live start_time resolves · merged via another change →
#68650,#53767
Closed issues · 7
- Closed#16376[Bug]: macOS stale Telegram token lock can block gateway after PID reuse
- Closed#16586Stale gateway lock files never cleared on macOS — process-start-time check is Linux-only
- Closed#18778gateway: scoped lock PID-reuse guard is a no-op on macOS/Windows — stale lockfiles permanently block startup
- Closed#21596Stale platform-lock detection broken on macOS: PID reuse causes permanent startup failure
- Closed#21613macOS: Telegram gateway lock always has start_time=null, causing PID reuse false positives
- Closed#24067[Bug]: macOS PID lock check fails — system process occupies same PID, causing zombie lock on gateway restart
- Closed#53763Bug: Stale lock cleanup fails on macOS due to PID recycling
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.10: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab