Files
hermes-agent/tests/hermes_cli/test_kanban_init_lock_bounded.py
T
Teknium 28834a2098 test: raise tight wall-clock bounds that flaked on loaded CI runners
Seven test files asserted sub-2s wall-clock bounds (elapsed < 0.5/1.0s,
stop(timeout=1.0), event waits of 0.5-2s). Under CI load these fired on
healthy code: main run 33455779041 alone flaked 6 of them in one pass
(observed 1.01s vs 0.5, 1.20s vs 1.0, 3.61s vs 3.0, 1.55s vs 1.0,
stop(1.0) returning False, lease TTL 0.1s expiring before the authority
change was observed).

Per the AGENTS.md flake policy (waits >= 2s), bounds are raised to 5s+
while keeping their teeth: every hang path they guard blocks for 10s+
(release.wait holds), so the loosened bounds still distinguish bounded
from unbounded behavior. The authority-loss test gets a 30s lease TTL so
lease expiry can no longer preempt the authority-change assertion.
2026-09-01 10:52:42 -07:00

93 lines
3.2 KiB
Python

"""Tests for the bounded kanban init lock (issue #36644).
`connect()` wrapped its entire body in an unbounded blocking `flock(LOCK_EX)`
on every call. A single process stalled inside the critical section blocked the
long-lived gateway dispatcher's next-tick `connect()` forever — no timeout, no
recovery, board silently stops being worked.
Two fixes, both covered here:
1. Fast path: once a path is initialized in this process, `connect()` skips the
cross-process init lock entirely (nothing left to serialize), so a held lock
cannot block a steady-state connect.
2. Bounded acquire: even on first-init, `_cross_process_init_lock` retries a
non-blocking acquire up to a deadline, then proceeds (with a WARNING) rather
than hanging.
"""
from __future__ import annotations
import threading
import time
from pathlib import Path
import pytest
from hermes_cli import kanban_db as kb
@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_KANBAN_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
db_path = kb.kanban_db_path(board="default")
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
return home
def _hold_init_lock(db_path: Path):
"""Return (start_event, release_event, thread) holding the init lock."""
holding = threading.Event()
release = threading.Event()
def _holder():
with kb._cross_process_init_lock(db_path):
holding.set()
release.wait(timeout=10)
t = threading.Thread(target=_holder, daemon=True)
t.start()
assert holding.wait(timeout=5), "holder thread never acquired the lock"
return release, t
def test_initialized_path_connect_skips_init_lock(kanban_home):
"""A connect to an already-initialized path must not block on the init lock."""
db_path = kb.kanban_db_path(board="default")
# Initialize once.
kb.connect().close()
assert str(db_path.resolve()) in kb._INITIALIZED_PATHS
# Hold the init lock; a fast-path connect must return promptly anyway.
release, t = _hold_init_lock(db_path)
try:
start = time.monotonic()
kb.connect().close()
elapsed = time.monotonic() - start
assert elapsed < 5.0, f"fast-path connect blocked on the init lock ({elapsed:.2f}s)"
finally:
release.set()
t.join(timeout=5)
def test_first_init_connect_is_bounded_when_lock_held(kanban_home, monkeypatch):
"""First-init connect must time out the cross-process lock and proceed,
not hang forever, when another holder owns it."""
monkeypatch.setattr(kb, "_INIT_LOCK_TIMEOUT_SECONDS", 0.6)
db_path = kb.kanban_db_path(board="default")
release, t = _hold_init_lock(db_path)
try:
start = time.monotonic()
conn = kb.connect() # path NOT yet initialized — must take the bounded path
conn.close()
elapsed = time.monotonic() - start
# Proceeded within roughly the timeout window (not unbounded).
assert 0.4 <= elapsed < 8.0, f"expected bounded ~0.6s acquire, got {elapsed:.2f}s"
assert str(db_path.resolve()) in kb._INITIALIZED_PATHS
finally:
release.set()
t.join(timeout=5)