G3: F02 — inject reconnect callback for send_appstart after reconnect

ConnectionManager._attempt_reconnect called self.connection.connect()
directly, bypassing MeshCore.connect() which runs send_appstart().
Firmware requires CMD_APP_START after every transport-level connection
to initialize the session.  Without it, the reconnected transport has
no active session — sends go unanswered, tcp_no_response fires after
5 attempts, handle_disconnect re-enters _attempt_reconnect, and the
reconnect storm begins.

Fix: add an optional reconnect_callback parameter to
ConnectionManager.__init__.  MeshCore passes self._on_reconnect which
calls send_appstart() after the transport reconnects.  The callback
is invoked inside _attempt_reconnect immediately after a successful
connect(), before the CONNECTED event is emitted.  Callback failures
are logged as warnings but do not break the reconnect — the transport
is up regardless.  Default None keeps the API backwards-compatible
for direct ConnectionManager users.

Refs: Forensics report finding F02
This commit is contained in:
Matthew Wolter 2026-04-11 19:47:49 -07:00
parent ab4c27dcae
commit ae0aa33dc8
2 changed files with 30 additions and 2 deletions

View file

@ -48,11 +48,13 @@ class ConnectionManager:
event_dispatcher=None,
auto_reconnect: bool = False,
max_reconnect_attempts: int = 3,
reconnect_callback: Optional[Callable[[], Awaitable[None]]] = None,
):
self.connection = connection
self.event_dispatcher = event_dispatcher
self.auto_reconnect = auto_reconnect
self.max_reconnect_attempts = max_reconnect_attempts
self._reconnect_callback = reconnect_callback
self._reconnect_attempts = 0
self._is_connected = False
@ -139,6 +141,16 @@ class ConnectionManager:
if result is not None:
self._is_connected = True
self._reconnect_attempts = 0
# Invoke reconnect callback (e.g. send_appstart) if provided
if self._reconnect_callback is not None:
try:
await self._reconnect_callback()
except Exception as cb_err:
logger.warning(
f"Reconnect callback failed: {cb_err}"
)
await self._emit_event(
EventType.CONNECTED,
{"connection_info": result, "reconnected": True},

View file

@ -28,10 +28,17 @@ class MeshCore:
auto_reconnect: bool = False,
max_reconnect_attempts: int = 3,
):
# Wrap connection with ConnectionManager
# Wrap connection with ConnectionManager.
# The reconnect callback ensures send_appstart() runs after every
# transport-level reconnect, which is required by firmware to
# initialize the session (F02).
self.dispatcher = EventDispatcher()
self.connection_manager = ConnectionManager(
cx, self.dispatcher, auto_reconnect, max_reconnect_attempts
cx,
self.dispatcher,
auto_reconnect,
max_reconnect_attempts,
reconnect_callback=self._on_reconnect,
)
self.cx = self.connection_manager # For backward compatibility
@ -174,6 +181,15 @@ class MeshCore:
return None
return mc
async def _on_reconnect(self):
"""Callback invoked by ConnectionManager after a successful reconnect.
Firmware requires CMD_APP_START after every transport-level connection
to initialize the session. MeshCore.connect() does this on the initial
connection; this callback ensures it also happens on reconnects (F02).
"""
await self.commands.send_appstart()
async def connect(self):
await self.dispatcher.start()
result = await self.connection_manager.connect()