NousResearch/hermes-agent
Feishu chat locks dict LRU eviction fix
Cluster 34445 · gateway. Data as of
Nothing open on GitHub. Fix merged in #34836 on 29 May 2026. Check it covers your case.
The short answer
0open issues
0open PRs
3items grouped
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #34836. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #34836. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These members identify an unbounded growth issue in the Feishu _chat_locks dictionary and propose or implement LRU eviction to limit its size. The group covers both the problem report and related fix attempts.
Open issues0
Open PRs0
Fixes on record
-
#34836 fix(feishu): bound _chat_locks with LRU eviction
files:
gateway/platforms/feishu.py,tests/gateway/test_feishu.pyShow the diff
-
gateway/platforms/feishu.py@@ -48,6 +48,7 @@ from __future__ import annotations import asyncio +import collections import hashlib import hmac import itertools @@ -1408,6 +1409,8 @@ class FeishuAdapter(BasePlatformAdapter): """Feishu/Lark bot adapter.""" MAX_MESSAGE_LENGTH = 8000 + # Max distinct chat IDs retained in _chat_locks before LRU eviction kicks in. + CHAT_LOCK_MAX_SIZE: int = 1000 # Threshold for detecting Feishu client-side message splits. # When a chunk is near the ~4096-char practical limit, a continuation # is almost certain. @@ -1445,7 +1448,7 @@ def __init__(self, config: PlatformConfig): self._pending_inbound_lock = threading.Lock() self._pending_drain_scheduled = False self._pending_inbound_max_depth = 1000 # cap queue; drop oldest beyond - self._chat_locks: Dict[str, asyncio.Lock] = {} # chat_id → lock (per-chat serial processing) + self._chat_locks: "collections.OrderedDict[str, asyncio.Lock]" = collections.OrderedDict() # chat_id → lock (per-chat serial processing, LRU-bounded) self._sent_message_ids_to_chat: Dict[str, str] = {} # message_id → chat_id (for reaction routing) self._sent_message_id_order: List[str] = [] # LRU order for _sent_message_ids_to_chat self._chat_info_cache: Dict[str, Dict[str, Any]] = {} @@ -2835,11 +2838,28 @@ async def _handle_card_action_event(self, data: Any) -> None: # ========================================================================= def _get_chat_lock(self, chat_id: str) -> asyncio.Lock: - """Return (creating if needed) the per-chat asyncio.Lock for serial message processing.""" + """Return (creating if needed) the per-chat asyncio.Lock for serial message processing. + + Bounded with LRU eviction so a long-running gateway that sees many + distinct chats does not grow ``_chat_locks`` without limit. Locks that + are currently held are never evicted; if every entry is locked we fall + back to dropping the least-recently-used one. + """ lock = self._chat_locks.get(chat_id) - if lock is None: - lock = asyncio.Lock() - self._chat_locks[chat_id] = lock + if lock is not None: + self._chat_locks.move_to_end(chat_id) + return lock + if len(self._chat_locks) >= self.CHAT_LOCK_MAX_SIZE: + evicted = False + for key in list(self._chat_locks): + if not self._chat_locks[key].locked(): + self._chat_locks.pop(key) + evicted = True + break + if not evicted: + self._chat_locks.pop(next(iter(self._chat_locks))) + lock = asyncio.Lock() + self._chat_locks[chat_id] = lock return lock async def _handle_message_with_guards(self, event: MessageEvent) -> None: -
tests/gateway/test_feishu.py@@ -4883,3 +4883,62 @@ def test_scenario_post_bot_plus_alice_filters_self_from_hint(self): # Body: leading @Hermes stripped, Alice preserved, trailing text intact. self.assertIn("@Alice review the spec with Alice", event.text) self.assertNotIn("@Hermes @Alice", event.text) + + +class TestChatLockEviction(unittest.TestCase): + """_get_chat_lock is LRU-bounded so _chat_locks cannot grow unbounded.""" + + def _make_adapter(self, max_size=5): + import collections as _collections + + from gateway.platforms.feishu import FeishuAdapter + + adapter = object.__new__(FeishuAdapter) + adapter._chat_locks = _collections.OrderedDict() + adapter.CHAT_LOCK_MAX_SIZE = max_size + return adapter + + def test_chat_locks_is_ordered_dict(self): + import collections as _collections + + adapter = self._make_adapter() + self.assertIsInstance(adapter._chat_locks, _collections.OrderedDict) + + def test_same_id_returns_same_lock_and_stays_bounded(self): + adapter = self._make_adapter(max_size=5) + locks = [adapter._get_chat_lock(f"c{i}") for i in range(5)] + self.assertEqual(len(adapter._chat_locks), 5) + # Re-requesting an existing id returns the identical lock, no growth. + self.assertIs(adapter._get_chat_lock("c2"), locks[2]) + self.assertEqual(len(adapter._chat_locks), 5) + + def test_lru_eviction_respects_recent_access(self): + adapter = self._make_adapter(max_size=5) + for i in range(5): + adapter._get_chat_lock(f"c{i}") + # Touch c0 so it is no longer the LRU entry, then add a new chat. + adapter._get_chat_lock("c0") + adapter._get_chat_lock("c_new") + self.assertEqual(len(adapter._chat_locks), 5) + self.assertNotIn("c1", adapter._chat_locks) # c1 was the true LRU + self.assertIn("c0", adapter._chat_locks) + self.assertIn("c_new", adapter._chat_locks) + + def test_eviction_skips_held_locks(self): + adapter = self._make_adapter(max_size=3) + + async def _run(): + held = adapter._get_chat_lock("held") + await held.acquire() + try: + adapter._get_chat_lock("x") + adapter._get_chat_lock("y") + # At capacity; "held" is LRU but locked, so "x" should go instead. + adapter._get_chat_lock("z") + self.assertIn("held", adapter._chat_locks) + self.assertNotIn("x", adapter._chat_locks) + self.assertEqual(len(adapter._chat_locks), 3) + finally: + held.release() + + asyncio.run(_run())
-
1 PR closed without merging
From the close comments: done elsewhere 1
-
#34469 fix(feishu): add LRU eviction to _chat_locks dict · done elsewhere →
91b1740,#34836,#34469
Closed issues · 1
- Closed#34445Feishu _chat_locks dict grows unbounded — no LRU eviction
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: 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 LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #34836
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 29 May 2026