fix(whatsapp): reuse persistent aiohttp session across requests (#3818)
Replace per-request aiohttp.ClientSession() in every WhatsApp adapter method with a single persistent self._http_session, matching the pattern used by Mattermost, HomeAssistant, and SMS adapters. Changes: - Create self._http_session in connect(), close in disconnect() - All bridge HTTP calls (send, edit, send-media, typing, get_chat_info, poll_messages) now use the shared session - Explicitly cancel _poll_task on disconnect() instead of relying solely on self._running = False - Health-check sessions in connect() remain ephemeral (persistent session not yet created at that point) - Remove per-method ImportError guards for aiohttp (always available when gateway runs via [messaging] extras) Salvaged from PR #1851 by Himess. The _poll_task storage was already on main from PR #3267; this adds the disconnect cancellation and the persistent session. Tests: 4 new tests for session close, already-closed skip, poll task cancellation, and done-task skip.
This commit is contained in:
@@ -63,6 +63,7 @@ def _make_adapter():
|
||||
adapter._background_tasks = set()
|
||||
adapter._auto_tts_disabled_chats = set()
|
||||
adapter._message_queue = asyncio.Queue()
|
||||
adapter._http_session = None
|
||||
return adapter
|
||||
|
||||
|
||||
@@ -219,6 +220,7 @@ class TestBridgeRuntimeFailure:
|
||||
fatal_handler = AsyncMock()
|
||||
adapter.set_fatal_error_handler(fatal_handler)
|
||||
adapter._running = True
|
||||
adapter._http_session = MagicMock() # Persistent session active
|
||||
mock_fh = MagicMock()
|
||||
adapter._bridge_log_fh = mock_fh
|
||||
|
||||
@@ -242,6 +244,7 @@ class TestBridgeRuntimeFailure:
|
||||
fatal_handler = AsyncMock()
|
||||
adapter.set_fatal_error_handler(fatal_handler)
|
||||
adapter._running = True
|
||||
adapter._http_session = MagicMock() # Persistent session active
|
||||
mock_fh = MagicMock()
|
||||
adapter._bridge_log_fh = mock_fh
|
||||
|
||||
@@ -417,3 +420,83 @@ class TestKillPortProcess:
|
||||
with patch("gateway.platforms.whatsapp._IS_WINDOWS", True), \
|
||||
patch("gateway.platforms.whatsapp.subprocess.run", side_effect=OSError("no netstat")):
|
||||
_kill_port_process(3000) # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistent HTTP session lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHttpSessionLifecycle:
|
||||
"""Verify persistent aiohttp.ClientSession is created and cleaned up."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_closed_on_disconnect(self):
|
||||
"""disconnect() should close self._http_session."""
|
||||
adapter = _make_adapter()
|
||||
mock_session = AsyncMock()
|
||||
mock_session.closed = False
|
||||
adapter._http_session = mock_session
|
||||
adapter._poll_task = None
|
||||
adapter._bridge_process = None
|
||||
adapter._running = True
|
||||
adapter._session_lock_identity = None
|
||||
|
||||
await adapter.disconnect()
|
||||
|
||||
mock_session.close.assert_called_once()
|
||||
assert adapter._http_session is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_not_closed_when_already_closed(self):
|
||||
"""disconnect() should skip close() when session is already closed."""
|
||||
adapter = _make_adapter()
|
||||
mock_session = AsyncMock()
|
||||
mock_session.closed = True
|
||||
adapter._http_session = mock_session
|
||||
adapter._poll_task = None
|
||||
adapter._bridge_process = None
|
||||
adapter._running = True
|
||||
adapter._session_lock_identity = None
|
||||
|
||||
await adapter.disconnect()
|
||||
|
||||
mock_session.close.assert_not_called()
|
||||
assert adapter._http_session is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_task_cancelled_on_disconnect(self):
|
||||
"""disconnect() should cancel the poll task."""
|
||||
adapter = _make_adapter()
|
||||
mock_task = MagicMock()
|
||||
mock_task.done.return_value = False
|
||||
mock_task.cancel = MagicMock()
|
||||
mock_future = asyncio.Future()
|
||||
mock_future.set_exception(asyncio.CancelledError())
|
||||
mock_task.__await__ = mock_future.__await__
|
||||
adapter._poll_task = mock_task
|
||||
adapter._http_session = None
|
||||
adapter._bridge_process = None
|
||||
adapter._running = True
|
||||
adapter._session_lock_identity = None
|
||||
|
||||
await adapter.disconnect()
|
||||
|
||||
mock_task.cancel.assert_called_once()
|
||||
assert adapter._poll_task is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_skips_done_poll_task(self):
|
||||
"""disconnect() should not cancel an already-done poll task."""
|
||||
adapter = _make_adapter()
|
||||
mock_task = MagicMock()
|
||||
mock_task.done.return_value = True
|
||||
adapter._poll_task = mock_task
|
||||
adapter._http_session = None
|
||||
adapter._bridge_process = None
|
||||
adapter._running = True
|
||||
adapter._session_lock_identity = None
|
||||
|
||||
await adapter.disconnect()
|
||||
|
||||
mock_task.cancel.assert_not_called()
|
||||
assert adapter._poll_task is None
|
||||
|
||||
Reference in New Issue
Block a user