Merge pull request #78 from mwolter805/fix/asyncio-lifecycle

fix: track background tasks, defer Queue/Lock construction, use get_running_loop
This commit is contained in:
fdlamotte
2026-04-25 15:00:44 +02:00
committed by GitHub
6 changed files with 313 additions and 11 deletions

View File

@@ -0,0 +1,235 @@
"""
Verification tests for asyncio lifecycle fixes.
"""
import asyncio
import gc
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
from meshcore.events import Event, EventDispatcher, EventType
from meshcore.tcp_cx import TCPConnection
from meshcore.serial_cx import SerialConnection
from meshcore.commands.base import CommandHandlerBase
class TestBackgroundTaskTracking(unittest.TestCase):
"""Fire-and-forget create_task calls must be tracked to prevent GC."""
def test_tcp_spawn_background_retains_task(self):
"""TCP _spawn_background adds the task to _background_tasks."""
async def _run():
cx = TCPConnection("127.0.0.1", 5555)
completed = asyncio.Event()
async def dummy():
completed.set()
task = cx._spawn_background(dummy())
assert task in cx._background_tasks
await completed.wait()
# After completion, done_callback should have discarded it
await asyncio.sleep(0) # let done callback fire
assert task not in cx._background_tasks
asyncio.run(_run())
def test_serial_spawn_background_retains_task(self):
"""Serial _spawn_background adds the task to _background_tasks."""
async def _run():
with patch("meshcore.serial_cx.asyncio.Event") as mock_event:
mock_event.return_value = MagicMock()
cx = SerialConnection("/dev/null", 115200)
completed = asyncio.Event()
async def dummy():
completed.set()
task = cx._spawn_background(dummy())
assert task in cx._background_tasks
await completed.wait()
await asyncio.sleep(0)
assert task not in cx._background_tasks
asyncio.run(_run())
def test_event_dispatcher_spawn_background_retains_task(self):
"""EventDispatcher _spawn_background adds task to _background_tasks."""
async def _run():
dispatcher = EventDispatcher()
completed = asyncio.Event()
async def dummy():
completed.set()
task = dispatcher._spawn_background(dummy())
assert task in dispatcher._background_tasks
await completed.wait()
await asyncio.sleep(0)
assert task not in dispatcher._background_tasks
asyncio.run(_run())
def test_tcp_handle_rx_uses_tracked_task(self):
"""TCP handle_rx dispatches reader.handle_rx via _spawn_background."""
async def _run():
cx = TCPConnection("127.0.0.1", 5555)
reader = AsyncMock()
reader.handle_rx = AsyncMock()
cx.set_reader(reader)
# Build a minimal valid frame: 0x3e + 2-byte LE size + payload
payload = b"\x01\x02\x03"
size = len(payload).to_bytes(2, "little")
frame = b"\x3e" + size + payload
cx.handle_rx(frame)
# Task should be tracked
assert len(cx._background_tasks) == 1
# Let task complete
await asyncio.sleep(0.05)
reader.handle_rx.assert_awaited_once_with(payload)
asyncio.run(_run())
def test_tcp_connection_lost_uses_tracked_task(self):
"""TCP connection_lost dispatches disconnect callback via _spawn_background."""
async def _run():
cx = TCPConnection("127.0.0.1", 5555)
callback = AsyncMock()
cx.set_disconnect_callback(callback)
protocol = cx.MCClientProtocol(cx)
protocol.connection_lost(None)
assert len(cx._background_tasks) == 1
await asyncio.sleep(0.05)
callback.assert_awaited_once_with("tcp_disconnect")
asyncio.run(_run())
def test_gc_does_not_cancel_tracked_tasks(self):
"""Tracked tasks survive GC pressure (the whole point of tracking)."""
async def _run():
cx = TCPConnection("127.0.0.1", 5555)
result = []
async def slow_task():
await asyncio.sleep(0.05)
result.append("done")
cx._spawn_background(slow_task())
# Force GC — untracked tasks could be collected here
gc.collect()
await asyncio.sleep(0.1)
assert result == ["done"]
asyncio.run(_run())
class TestTaskDoneCorrectness(unittest.TestCase):
"""EventDispatcher.stop() must wait for in-flight async callbacks."""
def test_stop_waits_for_async_callbacks(self):
"""stop() should not return until async callbacks have completed."""
async def _run():
dispatcher = EventDispatcher()
await dispatcher.start()
callback_completed = False
async def slow_callback(event):
nonlocal callback_completed
await asyncio.sleep(0.1)
callback_completed = True
dispatcher.subscribe(EventType.OK, slow_callback)
await dispatcher.dispatch(Event(EventType.OK, {}))
# Give the dispatch loop a moment to pick up the event
await asyncio.sleep(0.02)
# stop() should wait for slow_callback to finish
await dispatcher.stop()
assert callback_completed, "stop() returned before async callback completed"
asyncio.run(_run())
class TestDeferredPrimitiveConstruction(unittest.TestCase):
"""Queue and Lock must not bind to import-time loop."""
def test_event_dispatcher_queue_is_none_before_start(self):
"""EventDispatcher.queue should be None until start() is called."""
dispatcher = EventDispatcher()
assert dispatcher.queue is None
def test_event_dispatcher_queue_created_on_start(self):
"""start() creates the queue."""
async def _run():
dispatcher = EventDispatcher()
assert dispatcher.queue is None
await dispatcher.start()
assert dispatcher.queue is not None
assert isinstance(dispatcher.queue, asyncio.Queue)
await dispatcher.stop()
asyncio.run(_run())
def test_event_dispatcher_dispatch_before_start_raises(self):
"""dispatch() before start() should raise RuntimeError."""
async def _run():
dispatcher = EventDispatcher()
with self.assertRaises(RuntimeError):
await dispatcher.dispatch(Event(EventType.OK, {}))
asyncio.run(_run())
def test_command_handler_lock_is_none_before_use(self):
"""CommandHandlerBase lock should be None until first access."""
handler = CommandHandlerBase()
assert handler._CommandHandlerBase__mesh_request_lock is None
def test_command_handler_lock_created_on_access(self):
"""Accessing _mesh_request_lock creates it lazily."""
async def _run():
handler = CommandHandlerBase()
lock = handler._mesh_request_lock
assert isinstance(lock, asyncio.Lock)
# Second access returns same instance
assert handler._mesh_request_lock is lock
asyncio.run(_run())
class TestGetRunningLoop(unittest.TestCase):
"""get_event_loop() replaced with get_running_loop() in send()."""
def test_send_uses_get_running_loop(self):
"""send() should call get_running_loop, not get_event_loop."""
async def _run():
handler = CommandHandlerBase()
dispatcher = EventDispatcher()
await dispatcher.start()
handler.set_dispatcher(dispatcher)
mock_sender = AsyncMock()
handler._sender_func = mock_sender
# Patch get_running_loop to verify it's called
with patch("meshcore.commands.base.asyncio.get_running_loop", wraps=asyncio.get_running_loop) as mock_grl:
# send with expected_events triggers the loop = asyncio.get_running_loop() path
result = await handler.send(
b"\x01",
expected_events=[EventType.OK],
timeout=0.05,
)
mock_grl.assert_called()
await dispatcher.stop()
asyncio.run(_run())
if __name__ == "__main__":
unittest.main()