Skip to content

Commit e9aeeca

Browse files
authored
fix: Track fire-and-forget flush tasks in CommandCoalescer (#65)
* fix: track fire-and-forget flush tasks in CommandCoalescer Maintain a _pending_tasks set so that background flush tasks created by asyncio.ensure_future are tracked. Tasks are added on creation and discarded via done-callbacks on completion. flush_all() now awaits any in-flight flush tasks before returning, ensuring clean shutdown without unobserved task warnings. Closes #53 * fix: snapshot _pending_tasks before gather and improve flush_all test Address review feedback: - Snapshot _pending_tasks into a list before asyncio.gather to avoid RuntimeError from set mutation during iteration by done-callbacks. - Discard only the awaited snapshot instead of clear() to preserve tasks added concurrently. - Rewrite test_flush_all_awaits_inflight_tasks to use a blocking send_fn gated by asyncio.Event, verifying flush_all genuinely awaits an in-flight background flush task.
1 parent 7de6beb commit e9aeeca

2 files changed

Lines changed: 72 additions & 1 deletion

File tree

src/actron_neo_api/actron.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def __init__(
8484
self._state_manager = state_manager
8585
self._debounce = debounce_seconds
8686
self._batches: dict[str, _PendingBatch] = {}
87+
self._pending_tasks: set[asyncio.Task[None]] = set()
8788

8889
@property
8990
def debounce_seconds(self) -> float:
@@ -133,21 +134,35 @@ async def enqueue(self, serial_number: str, command: dict[str, Any]) -> None:
133134

134135
def _schedule_flush(sn: str = serial_number) -> None:
135136
task = asyncio.ensure_future(self._flush(sn))
137+
self._pending_tasks.add(task)
138+
task.add_done_callback(self._pending_tasks.discard)
136139
task.add_done_callback(self._flush_task_done)
137140

138141
batch.timer = loop.call_later(self._debounce, _schedule_flush)
139142

140143
await future
141144

142145
async def flush_all(self) -> None:
143-
"""Flush every pending batch immediately, cancelling debounce timers."""
146+
"""Flush every pending batch immediately, cancelling debounce timers.
147+
148+
Also awaits any in-flight flush tasks so that all pending work
149+
completes before this method returns.
150+
"""
144151
serials = list(self._batches.keys())
145152
for serial in serials:
146153
batch = self._batches.get(serial)
147154
if batch and batch.timer:
148155
batch.timer.cancel()
149156
await self._flush(serial)
150157

158+
# Await any in-flight flush tasks that were scheduled before this call.
159+
# Snapshot first — done-callbacks mutate the set concurrently.
160+
if self._pending_tasks:
161+
pending = list(self._pending_tasks)
162+
await asyncio.gather(*pending, return_exceptions=True)
163+
for task in pending:
164+
self._pending_tasks.discard(task)
165+
151166
# -- internals --------------------------------------------------------------
152167

153168
def _get_or_create_batch(self, serial_number: str) -> _PendingBatch:

tests/test_coalescer.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,62 @@ async def test_stale_reads_dont_erase_prior_overrides(self) -> None:
300300
assert sent["UserAirconSettings.EnabledZones"] == [False, False, True, True]
301301

302302

303+
class TestCommandCoalescerTaskTracking:
304+
"""Tests for flush task tracking and cleanup."""
305+
306+
@pytest.mark.asyncio
307+
async def test_pending_tasks_set_initialised(self) -> None:
308+
"""The _pending_tasks set is initialised empty."""
309+
send_fn = AsyncMock()
310+
sm = StateManager()
311+
coalescer = CommandCoalescer(send_fn, sm, debounce_seconds=0.05)
312+
assert isinstance(coalescer._pending_tasks, set)
313+
assert len(coalescer._pending_tasks) == 0
314+
315+
@pytest.mark.asyncio
316+
async def test_flush_task_tracked_and_discarded(self) -> None:
317+
"""Flush tasks are added to _pending_tasks and discarded on completion."""
318+
send_fn = AsyncMock()
319+
sm = _state_manager_with_zones("abc", [True, True])
320+
coalescer = CommandCoalescer(send_fn, sm, debounce_seconds=0.05)
321+
322+
await coalescer.enqueue("abc", _make_zone_command([False, True]))
323+
324+
# Allow done-callbacks to execute
325+
await asyncio.sleep(0)
326+
327+
# After the enqueue completes the flush has finished and the
328+
# done-callback should have discarded the task from the set.
329+
assert len(coalescer._pending_tasks) == 0
330+
331+
@pytest.mark.asyncio
332+
async def test_flush_all_awaits_inflight_tasks(self) -> None:
333+
"""flush_all awaits a genuinely in-flight background flush task."""
334+
gate = asyncio.Event()
335+
336+
async def _blocking_send(serial: str, cmd: dict[str, Any]) -> None:
337+
await gate.wait()
338+
339+
sm = _state_manager_with_zones("abc", [True, True])
340+
coalescer = CommandCoalescer(_blocking_send, sm, debounce_seconds=0.02)
341+
342+
# Start enqueue — it will block on _blocking_send until gate is set
343+
enqueue_task = asyncio.create_task(
344+
coalescer.enqueue("abc", _make_zone_command([False, True]))
345+
)
346+
# Let the debounce timer fire and create the background flush task
347+
await asyncio.sleep(0.05)
348+
assert len(coalescer._pending_tasks) > 0
349+
350+
# Release the gate so the flush can finish, then flush_all
351+
gate.set()
352+
await coalescer.flush_all()
353+
await enqueue_task
354+
355+
# After flush_all, no pending tasks should remain
356+
assert len(coalescer._pending_tasks) == 0
357+
358+
303359
class TestActronAirAPISendCommandCoalescing:
304360
"""Integration tests for command coalescing through ActronAirAPI.send_command."""
305361

0 commit comments

Comments
 (0)