From ab4c27dcae22fa026ceb340cfc8824370f37fd36 Mon Sep 17 00:00:00 2001 From: Matthew Wolter Date: Sat, 11 Apr 2026 19:47:12 -0700 Subject: [PATCH] =?UTF-8?q?G3:=20F03=20=E2=80=94=20restructure=20=5Fattemp?= =?UTF-8?q?t=5Freconnect=20from=20tail-recursion=20to=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _attempt_reconnect previously tail-recursed via asyncio.create_task, re-assigning self._reconnect_task from inside the running coroutine. This orphaned the current task — disconnect() cancelled only the newest pointer, leaving previous-generation attempts in flight. Those orphaned tasks could set _is_connected = True after the caller thought the session was closed. Fix: replace with a single iterative while loop that holds one persistent task for the entire reconnect session. The task is created once in handle_disconnect and torn down only on success, max attempts exhausted, or disconnect() cancellation. Refs: Forensics report finding F03 --- src/meshcore/connection_manager.py | 66 ++++++++++++++---------------- 1 file changed, 31 insertions(+), 35 deletions(-) diff --git a/src/meshcore/connection_manager.py b/src/meshcore/connection_manager.py index ba8d928..ec02e72 100644 --- a/src/meshcore/connection_manager.py +++ b/src/meshcore/connection_manager.py @@ -118,45 +118,41 @@ class ConnectionManager: ) async def _attempt_reconnect(self): - """Attempt to reconnect with flat delay.""" - logger.debug( - f"Attempting reconnection ({self._reconnect_attempts + 1}/{self.max_reconnect_attempts})" - ) - self._reconnect_attempts += 1 + """Attempt to reconnect using an iterative loop. - # Flat 1 second delay for all attempts - await asyncio.sleep(1) + Runs as a single persistent task for the entire reconnect session. + Previous implementation used tail-recursion via create_task which + orphaned the running task reference — disconnect() could only cancel + the newest pointer, leaving earlier attempts in flight (F03). + """ + while self._reconnect_attempts < self.max_reconnect_attempts: + self._reconnect_attempts += 1 + logger.debug( + f"Attempting reconnection ({self._reconnect_attempts}/{self.max_reconnect_attempts})" + ) - try: - result = await self.connection.connect() - if result is not None: - self._is_connected = True - self._reconnect_attempts = 0 - await self._emit_event( - EventType.CONNECTED, - {"connection_info": result, "reconnected": True}, - ) - logger.debug("Reconnected successfully") - else: - # Reconnection failed, try again if we haven't exceeded max attempts - if self._reconnect_attempts < self.max_reconnect_attempts: - self._reconnect_task = asyncio.create_task( - self._attempt_reconnect() - ) - else: + # Flat 1 second delay for all attempts + await asyncio.sleep(1) + + try: + result = await self.connection.connect() + if result is not None: + self._is_connected = True + self._reconnect_attempts = 0 await self._emit_event( - EventType.DISCONNECTED, - {"reason": "reconnect_failed", "max_attempts_exceeded": True}, + EventType.CONNECTED, + {"connection_info": result, "reconnected": True}, ) - except Exception as e: - logger.debug(f"Reconnection attempt failed: {e}") - if self._reconnect_attempts < self.max_reconnect_attempts: - self._reconnect_task = asyncio.create_task(self._attempt_reconnect()) - else: - await self._emit_event( - EventType.DISCONNECTED, - {"reason": f"reconnect_error: {e}", "max_attempts_exceeded": True}, - ) + logger.debug("Reconnected successfully") + return + except Exception as e: + logger.debug(f"Reconnection attempt failed: {e}") + + # All attempts exhausted + await self._emit_event( + EventType.DISCONNECTED, + {"reason": "reconnect_failed", "max_attempts_exceeded": True}, + ) async def _emit_event(self, event_type: EventType, payload: dict): """Emit connection events if dispatcher is available."""