G5: F05 — track fire-and-forget asyncio.create_task references

Why: Python's asyncio holds only weak references to tasks created via
create_task(). Under GC pressure (especially Python < 3.11), unretained
tasks can be silently cancelled mid-execution, and any exceptions are
swallowed as "Task exception was never retrieved." Seven call sites
across TCPConnection, BLEConnection, SerialConnection, and
EventDispatcher used fire-and-forget create_task with no stored
reference. Fix: introduce _background_tasks set and _spawn_background()
helper on each class, following the standard pattern from the asyncio
docs (task.add_done_callback(set.discard)).

Refs: Forensics report finding F05
This commit is contained in:
Matthew Wolter 2026-04-12 03:56:09 -07:00
parent fbf84cbdac
commit 26141d0353
4 changed files with 39 additions and 7 deletions

View file

@ -51,6 +51,14 @@ class BLEConnection:
self.pin = pin
self.rx_char = None
self._disconnect_callback = None
self._background_tasks: set[asyncio.Task] = set()
def _spawn_background(self, coro) -> asyncio.Task:
"""Create a tracked background task (prevents GC of fire-and-forget tasks)."""
task = asyncio.create_task(coro)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
return task
async def connect(self):
"""
@ -155,7 +163,7 @@ class BLEConnection:
self.device = self._user_provided_device
if self._disconnect_callback:
asyncio.create_task(self._disconnect_callback("ble_disconnect"))
self._spawn_background(self._disconnect_callback("ble_disconnect"))
def set_disconnect_callback(self, callback):
"""Set callback to handle disconnections."""
@ -166,7 +174,7 @@ class BLEConnection:
def handle_rx(self, _: BleakGATTCharacteristic, data: bytearray):
if self.reader is not None:
asyncio.create_task(self.reader.handle_rx(data))
self._spawn_background(self.reader.handle_rx(data))
async def send(self, data):
if not self.client:

View file

@ -134,6 +134,14 @@ class EventDispatcher:
self.subscriptions: List[Subscription] = []
self.running = False
self._task = None
self._background_tasks: set[asyncio.Task] = set()
def _spawn_background(self, coro) -> asyncio.Task:
"""Create a tracked background task (prevents GC of fire-and-forget tasks)."""
task = asyncio.create_task(coro)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
return task
def subscribe(
self,
@ -197,7 +205,7 @@ class EventDispatcher:
# returns - avoids the race where create_task schedules the callback after
# the waiter has already timed out with done=set().
if asyncio.iscoroutinefunction(subscription.callback):
asyncio.create_task(self._execute_callback(subscription.callback, event.clone()))
self._spawn_background(self._execute_callback(subscription.callback, event.clone()))
else:
try:
subscription.callback(event.clone())

View file

@ -20,11 +20,19 @@ class SerialConnection:
self._disconnect_callback = None
self.cx_dly = cx_dly
self._connected_event = asyncio.Event()
self._background_tasks: set[asyncio.Task] = set()
self.frame_expected_size = 0
self.inframe = b""
self.header = b""
def _spawn_background(self, coro) -> asyncio.Task:
"""Create a tracked background task (prevents GC of fire-and-forget tasks)."""
task = asyncio.create_task(coro)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
return task
class MCSerialClientProtocol(asyncio.Protocol):
def __init__(self, cx):
self.cx = cx
@ -44,7 +52,7 @@ class SerialConnection:
self.cx._connected_event.clear()
if self.cx._disconnect_callback:
asyncio.create_task(self.cx._disconnect_callback("serial_disconnect"))
self.cx._spawn_background(self.cx._disconnect_callback("serial_disconnect"))
def pause_writing(self):
logger.debug("pause writing")
@ -114,7 +122,7 @@ class SerialConnection:
data = data[upbound:]
if self.reader is not None:
# feed meshcore reader
asyncio.create_task(self.reader.handle_rx(self.inframe))
self._spawn_background(self.reader.handle_rx(self.inframe))
# reset inframe
self.inframe = b""
self.header = b""

View file

@ -24,6 +24,14 @@ class TCPConnection:
self.frame_expected_size = 0
self.header = b""
self.inframe = b""
self._background_tasks: set[asyncio.Task] = set()
def _spawn_background(self, coro) -> asyncio.Task:
"""Create a tracked background task (prevents GC of fire-and-forget tasks)."""
task = asyncio.create_task(coro)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
return task
class MCClientProtocol(asyncio.Protocol):
def __init__(self, cx):
@ -47,7 +55,7 @@ class TCPConnection:
def connection_lost(self, exc):
logger.debug("TCP server closed the connection")
if self.cx._disconnect_callback:
asyncio.create_task(self.cx._disconnect_callback("tcp_disconnect"))
self.cx._spawn_background(self.cx._disconnect_callback("tcp_disconnect"))
async def connect(self):
"""
@ -108,7 +116,7 @@ class TCPConnection:
data = data[upbound:]
if self.reader is not None:
# feed meshcore reader
asyncio.create_task(self.reader.handle_rx(self.inframe))
self._spawn_background(self.reader.handle_rx(self.inframe))
# reset inframe
self.inframe = b""
self.header = b""