fix(feishu): queue inbound events when adapter loop not ready (#5499) (#11372)

Inbound Feishu messages arriving during brief windows when the adapter
loop is unavailable (startup/restart transitions, network-flap reconnect)
were silently dropped with a WARNING log. This matches the symptom in
issue #5499 — and users have reported seeing only a subset of their
messages reach the agent.

Fix: queue pending events in a thread-safe list and spawn a single
drainer thread that replays them once the loop becomes ready. Covers
these scenarios:

  * Queue events instead of dropping when loop is None/closed
  * Single drainer handles the full queue (not thread-per-event)
  * Thread-safe with threading.Lock on the queue and schedule flag
  * Handles mid-drain bursts (new events arrive while drainer is working)
  * Handles RuntimeError if loop closes between check and submit
  * Depth cap (1000) prevents unbounded growth during extended outages
  * Drops queue cleanly on disconnect rather than holding forever
  * Safety timeout (120s) prevents infinite retention on broken adapters

Based on the approach proposed in #4789 by milkoor, rewritten for
thread-safety and correctness.

Test plan:
  * 5 new unit tests (TestPendingInboundQueue) — all passing
  * E2E test with real asyncio loop + fake WS thread: 10-event burst
    before loop ready → all 10 delivered in order
  * E2E concurrent burst test: 20 events queued, 20 more arrive during
    drainer dispatch → all 40 delivered, no loss, no duplicates
  * All 111 existing feishu tests pass

Related: #5499, #4789

Co-authored-by: milkoor <milkoor@users.noreply.github.com>
This commit is contained in:
Teknium
2026-04-16 20:36:59 -07:00
committed by GitHub
parent 01906e99dd
commit 7af9bf3a54
3 changed files with 287 additions and 3 deletions

View File

@@ -2536,6 +2536,152 @@ class TestAdapterBehavior(unittest.TestCase):
)
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
class TestPendingInboundQueue(unittest.TestCase):
"""Tests for the loop-not-ready race (#5499): inbound events arriving
before or during adapter loop transitions must be queued for replay
rather than silently dropped."""
@patch.dict(os.environ, {}, clear=True)
def test_event_queued_when_loop_not_ready(self):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter
adapter = FeishuAdapter(PlatformConfig())
adapter._loop = None # Simulate "before start()" or "during reconnect"
with patch("gateway.platforms.feishu.threading.Thread") as thread_cls:
adapter._on_message_event(SimpleNamespace(tag="evt-1"))
adapter._on_message_event(SimpleNamespace(tag="evt-2"))
adapter._on_message_event(SimpleNamespace(tag="evt-3"))
# All three queued, none dropped.
self.assertEqual(len(adapter._pending_inbound_events), 3)
# Only ONE drainer thread scheduled, not one per event.
self.assertEqual(thread_cls.call_count, 1)
# Drain scheduled flag set.
self.assertTrue(adapter._pending_drain_scheduled)
@patch.dict(os.environ, {}, clear=True)
def test_drainer_replays_queued_events_when_loop_becomes_ready(self):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter
adapter = FeishuAdapter(PlatformConfig())
adapter._loop = None
adapter._running = True
class _ReadyLoop:
def is_closed(self):
return False
# Queue three events while loop is None (simulate the race).
events = [SimpleNamespace(tag=f"evt-{i}") for i in range(3)]
with patch("gateway.platforms.feishu.threading.Thread"):
for ev in events:
adapter._on_message_event(ev)
self.assertEqual(len(adapter._pending_inbound_events), 3)
# Now the loop becomes ready; run the drainer inline (not as a thread)
# to verify it replays the queue.
adapter._loop = _ReadyLoop()
future = SimpleNamespace(add_done_callback=lambda *_a, **_kw: None)
submitted: list = []
def _submit(coro, _loop):
submitted.append(coro)
coro.close()
return future
with patch(
"gateway.platforms.feishu.asyncio.run_coroutine_threadsafe",
side_effect=_submit,
) as submit:
adapter._drain_pending_inbound_events()
# All three events dispatched to the loop.
self.assertEqual(submit.call_count, 3)
# Queue emptied.
self.assertEqual(len(adapter._pending_inbound_events), 0)
# Drain flag reset so a future race can schedule a new drainer.
self.assertFalse(adapter._pending_drain_scheduled)
@patch.dict(os.environ, {}, clear=True)
def test_drainer_drops_queue_when_adapter_shuts_down(self):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter
adapter = FeishuAdapter(PlatformConfig())
adapter._loop = None
adapter._running = False # Shutdown state
with patch("gateway.platforms.feishu.threading.Thread"):
adapter._on_message_event(SimpleNamespace(tag="evt-lost"))
self.assertEqual(len(adapter._pending_inbound_events), 1)
# Drainer should drop the queue immediately since _running is False.
adapter._drain_pending_inbound_events()
self.assertEqual(len(adapter._pending_inbound_events), 0)
self.assertFalse(adapter._pending_drain_scheduled)
@patch.dict(os.environ, {}, clear=True)
def test_queue_cap_evicts_oldest_beyond_max_depth(self):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter
adapter = FeishuAdapter(PlatformConfig())
adapter._loop = None
adapter._pending_inbound_max_depth = 3 # Shrink for test
with patch("gateway.platforms.feishu.threading.Thread"):
for i in range(5):
adapter._on_message_event(SimpleNamespace(tag=f"evt-{i}"))
# Only the last 3 should remain; evt-0 and evt-1 dropped.
self.assertEqual(len(adapter._pending_inbound_events), 3)
tags = [getattr(e, "tag", None) for e in adapter._pending_inbound_events]
self.assertEqual(tags, ["evt-2", "evt-3", "evt-4"])
@patch.dict(os.environ, {}, clear=True)
def test_normal_path_unchanged_when_loop_ready(self):
"""When the loop is ready, events should dispatch directly without
ever touching the pending queue."""
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter
adapter = FeishuAdapter(PlatformConfig())
class _ReadyLoop:
def is_closed(self):
return False
adapter._loop = _ReadyLoop()
future = SimpleNamespace(add_done_callback=lambda *_a, **_kw: None)
def _submit(coro, _loop):
coro.close()
return future
with patch(
"gateway.platforms.feishu.asyncio.run_coroutine_threadsafe",
side_effect=_submit,
) as submit, patch(
"gateway.platforms.feishu.threading.Thread"
) as thread_cls:
adapter._on_message_event(SimpleNamespace(tag="evt"))
self.assertEqual(submit.call_count, 1)
self.assertEqual(len(adapter._pending_inbound_events), 0)
self.assertFalse(adapter._pending_drain_scheduled)
# No drainer thread spawned when the happy path runs.
self.assertEqual(thread_cls.call_count, 0)
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
class TestWebhookSecurity(unittest.TestCase):
"""Tests for webhook signature verification, rate limiting, and body size limits."""