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:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user