diff --git a/src/meshcore/commands/base.py b/src/meshcore/commands/base.py index 9e0f00e..6831ad4 100644 --- a/src/meshcore/commands/base.py +++ b/src/meshcore/commands/base.py @@ -58,17 +58,32 @@ def _validate_destination(dst: DestinationType, prefix_length: int = 6) -> bytes class CommandHandlerBase: + """Base class for command handlers. + + .. note:: + The internal ``asyncio.Lock`` is created lazily on first access + so that it binds to the correct running event loop (required for + Python 3.9/3.10 compatibility). + """ + DEFAULT_TIMEOUT = 5.0 def __init__(self, default_timeout: Optional[float] = None): self._sender_func: Optional[Callable[[bytes], Coroutine[Any, Any, None]]] = None self._reader: Optional[MessageReader] = None self.dispatcher: Optional[EventDispatcher] = None - self._mesh_request_lock = asyncio.Lock() + self.__mesh_request_lock: Optional[asyncio.Lock] = None self.default_timeout = ( default_timeout if default_timeout is not None else self.DEFAULT_TIMEOUT ) + @property + def _mesh_request_lock(self) -> asyncio.Lock: + """Lazy-init lock so it binds to the running loop, not import-time.""" + if self.__mesh_request_lock is None: + self.__mesh_request_lock = asyncio.Lock() + return self.__mesh_request_lock + def set_connection(self, connection: Any) -> None: async def sender(data: bytes) -> None: await connection.send(data) diff --git a/src/meshcore/events.py b/src/meshcore/events.py index dbf7056..c9c0b6a 100644 --- a/src/meshcore/events.py +++ b/src/meshcore/events.py @@ -129,8 +129,17 @@ class Subscription: class EventDispatcher: + """Event dispatch engine. + + .. note:: + ``start()`` must be called before dispatching or processing events. + The internal ``asyncio.Queue`` is created lazily inside ``start()`` + so that it binds to the correct running event loop (required for + Python 3.9/3.10 compatibility). + """ + def __init__(self): - self.queue: asyncio.Queue[Event] = asyncio.Queue() + self.queue: Optional[asyncio.Queue[Event]] = None self.subscriptions: List[Subscription] = [] self.running = False self._task = None @@ -174,6 +183,10 @@ class EventDispatcher: self.subscriptions.remove(subscription) async def dispatch(self, event: Event): + if self.queue is None: + raise RuntimeError( + "EventDispatcher.start() must be called before dispatching events" + ) await self.queue.put(event) async def _process_events(self): @@ -228,6 +241,8 @@ class EventDispatcher: async def start(self): if not self.running: + if self.queue is None: + self.queue = asyncio.Queue() self.running = True self._task = asyncio.create_task(self._process_events()) @@ -235,7 +250,8 @@ class EventDispatcher: if self.running: self.running = False if self._task: - await self.queue.join() + if self.queue is not None: + await self.queue.join() # Wait for any in-flight async callbacks to complete before # tearing down (F07: task_done fires before callbacks finish). if self._background_tasks: