{"repo":"NousResearch/hermes-agent","public_id":"34445","display_label":"Feishu chat locks dict LRU eviction fix","summary":"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.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":34836,"merged_at":"2026-05-29T20:18:15+00:00"},"resolution":{"state":"resolved","by":"pr-34836"},"freshness":{"state":"verified","last_verified_at":"2026-05-29T20:18:15+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":34445,"pr_number":34836,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-29T20:18:15+00:00"}],"members":[{"kind":"issue","number":34445,"title":"Feishu _chat_locks dict grows unbounded — no LRU eviction","url":null,"state":"closed"},{"kind":"pr","number":34469,"title":"fix(feishu): add LRU eviction to _chat_locks dict","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"91b174038c7e2bf6cde056da05f8f90673e8c87a","number":0},{"kind":"pr","value":"#34836","number":34836},{"kind":"pr","value":"#34469","number":34469}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":34836,"title":"fix(feishu): bound _chat_locks with LRU eviction","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/feishu.py","tests/gateway/test_feishu.py"],"fix_hunks":[{"file":"gateway/platforms/feishu.py","hunks":["@@ -48,6 +48,7 @@\n from __future__ import annotations\n \n import asyncio\n+import collections\n import hashlib\n import hmac\n import itertools","@@ -1408,6 +1409,8 @@ class FeishuAdapter(BasePlatformAdapter):\n     \"\"\"Feishu/Lark bot adapter.\"\"\"\n \n     MAX_MESSAGE_LENGTH = 8000\n+    # Max distinct chat IDs retained in _chat_locks before LRU eviction kicks in.\n+    CHAT_LOCK_MAX_SIZE: int = 1000\n     # Threshold for detecting Feishu client-side message splits.\n     # When a chunk is near the ~4096-char practical limit, a continuation\n     # is almost certain.","@@ -1445,7 +1448,7 @@ def __init__(self, config: PlatformConfig):\n         self._pending_inbound_lock = threading.Lock()\n         self._pending_drain_scheduled = False\n         self._pending_inbound_max_depth = 1000  # cap queue; drop oldest beyond\n-        self._chat_locks: Dict[str, asyncio.Lock] = {}  # chat_id → lock (per-chat serial processing)\n+        self._chat_locks: \"collections.OrderedDict[str, asyncio.Lock]\" = collections.OrderedDict()  # chat_id → lock (per-chat serial processing, LRU-bounded)\n         self._sent_message_ids_to_chat: Dict[str, str] = {}  # message_id → chat_id (for reaction routing)\n         self._sent_message_id_order: List[str] = []  # LRU order for _sent_message_ids_to_chat\n         self._chat_info_cache: Dict[str, Dict[str, Any]] = {}","@@ -2835,11 +2838,28 @@ async def _handle_card_action_event(self, data: Any) -> None:\n     # =========================================================================\n \n     def _get_chat_lock(self, chat_id: str) -> asyncio.Lock:\n-        \"\"\"Return (creating if needed) the per-chat asyncio.Lock for serial message processing.\"\"\"\n+        \"\"\"Return (creating if needed) the per-chat asyncio.Lock for serial message processing.\n+\n+        Bounded with LRU eviction so a long-running gateway that sees many\n+        distinct chats does not grow ``_chat_locks`` without limit. Locks that\n+        are currently held are never evicted; if every entry is locked we fall\n+        back to dropping the least-recently-used one.\n+        \"\"\"\n         lock = self._chat_locks.get(chat_id)\n-        if lock is None:\n-            lock = asyncio.Lock()\n-            self._chat_locks[chat_id] = lock\n+        if lock is not None:\n+            self._chat_locks.move_to_end(chat_id)\n+            return lock\n+        if len(self._chat_locks) >= self.CHAT_LOCK_MAX_SIZE:\n+            evicted = False\n+            for key in list(self._chat_locks):\n+                if not self._chat_locks[key].locked():\n+                    self._chat_locks.pop(key)\n+                    evicted = True\n+                    break\n+            if not evicted:\n+                self._chat_locks.pop(next(iter(self._chat_locks)))\n+        lock = asyncio.Lock()\n+        self._chat_locks[chat_id] = lock\n         return lock\n \n     async def _handle_message_with_guards(self, event: MessageEvent) -> None:"]},{"file":"tests/gateway/test_feishu.py","hunks":["@@ -4883,3 +4883,62 @@ def test_scenario_post_bot_plus_alice_filters_self_from_hint(self):\n         # Body: leading @Hermes stripped, Alice preserved, trailing text intact.\n         self.assertIn(\"@Alice review the spec with Alice\", event.text)\n         self.assertNotIn(\"@Hermes @Alice\", event.text)\n+\n+\n+class TestChatLockEviction(unittest.TestCase):\n+    \"\"\"_get_chat_lock is LRU-bounded so _chat_locks cannot grow unbounded.\"\"\"\n+\n+    def _make_adapter(self, max_size=5):\n+        import collections as _collections\n+\n+        from gateway.platforms.feishu import FeishuAdapter\n+\n+        adapter = object.__new__(FeishuAdapter)\n+        adapter._chat_locks = _collections.OrderedDict()\n+        adapter.CHAT_LOCK_MAX_SIZE = max_size\n+        return adapter\n+\n+    def test_chat_locks_is_ordered_dict(self):\n+        import collections as _collections\n+\n+        adapter = self._make_adapter()\n+        self.assertIsInstance(adapter._chat_locks, _collections.OrderedDict)\n+\n+    def test_same_id_returns_same_lock_and_stays_bounded(self):\n+        adapter = self._make_adapter(max_size=5)\n+        locks = [adapter._get_chat_lock(f\"c{i}\") for i in range(5)]\n+        self.assertEqual(len(adapter._chat_locks), 5)\n+        # Re-requesting an existing id returns the identical lock, no growth.\n+        self.assertIs(adapter._get_chat_lock(\"c2\"), locks[2])\n+        self.assertEqual(len(adapter._chat_locks), 5)\n+\n+    def test_lru_eviction_respects_recent_access(self):\n+        adapter = self._make_adapter(max_size=5)\n+        for i in range(5):\n+            adapter._get_chat_lock(f\"c{i}\")\n+        # Touch c0 so it is no longer the LRU entry, then add a new chat.\n+        adapter._get_chat_lock(\"c0\")\n+        adapter._get_chat_lock(\"c_new\")\n+        self.assertEqual(len(adapter._chat_locks), 5)\n+        self.assertNotIn(\"c1\", adapter._chat_locks)  # c1 was the true LRU\n+        self.assertIn(\"c0\", adapter._chat_locks)\n+        self.assertIn(\"c_new\", adapter._chat_locks)\n+\n+    def test_eviction_skips_held_locks(self):\n+        adapter = self._make_adapter(max_size=3)\n+\n+        async def _run():\n+            held = adapter._get_chat_lock(\"held\")\n+            await held.acquire()\n+            try:\n+                adapter._get_chat_lock(\"x\")\n+                adapter._get_chat_lock(\"y\")\n+                # At capacity; \"held\" is LRU but locked, so \"x\" should go instead.\n+                adapter._get_chat_lock(\"z\")\n+                self.assertIn(\"held\", adapter._chat_locks)\n+                self.assertNotIn(\"x\", adapter._chat_locks)\n+                self.assertEqual(len(adapter._chat_locks), 3)\n+            finally:\n+                held.release()\n+\n+        asyncio.run(_run())\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}