feat(transport): per-radio serialized+paced outbound send queue (#93)

* feat(transport): per-radio serialized+paced outbound send queue

Prevents simultaneous LoRa transmissions when N events arrive at once.

## Mechanism

Two `RadioSendQueue` instances (one MT, one MC), each a FIFO asyncio.Queue
with a long-running drain task.  The MT queue drains on the main asyncio
loop; the MC queue drains on MeshCore's dedicated event-loop thread.

- MT sends: `run_in_executor` offloads the blocking `sendText` call;
  queue started in `set_message_callback`, cancelled in `disconnect`.
- MC sends: drain loop runs pure-async MC lib coroutines directly on the
  MC loop (no `_run_coro` deadlock); cross-loop callers bridge via
  `concurrent.futures.Future` + `asyncio.wrap_future`.
- Pacing: `await asyncio.sleep(pacing_seconds)` between items; read live
  from config per iteration; floor clamped to 0.25 s.
- Config knobs: `meshtastic_send_pacing_seconds` (default 2.0) and
  `meshcore_send_pacing_seconds` (default 2.0) on `ConnectionConfig`.

## Send sites rerouted

All callers now `await connector.send_message_async(...)`:
- `notifications/channels.py` — MeshBroadcast/MeshCoreBroadcast/MeshDM/
  MeshCoreDM deliver(), test_connection(), deliver_test()
- `responder.py` — DM replies in send_response()
- `transport/meshcore_transport.py` — periodic_advert_loop, telemetry
  poll loop, send_advert() → send_advert_async(), req_telemetry()
  → req_telemetry_async() (all queue-routed from main loop)
- `dashboard/api/mesh_send_routes.py` — test-send, advert, telemetry poll

## Audit accuracy

`deliver()` now returns the actual bool from the radio send (not
optimistic True), so `mesh_broadcasts_out` reflects the real result.

## Tests

17 new tests in tests/test_send_queue.py covering FIFO ordering, no drops,
pacing gap, pacing floor enforcement, event-loop non-blocking, serialization,
lifecycle, MT fallback, config round-trip.  Existing test stubs updated to
wire `send_message_async = AsyncMock(side_effect=send_message)` so prior
call_count / call_args assertions remain valid without changes.

Full suite: 2135 passed, 17 pre-existing failures (unchanged), 0 new regressions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(send-queue): resolve MC telemetry self-deadlock + resolve pending futures on teardown/reconnect; composite MC-channel kwarg; audit no-op false

BLOCKER 1 — req_telemetry_async self-deadlock (meshcore_transport.py):
_req_telemetry_async was calling _enqueue_mc_loop_send inside itself;
when _telem_job_outer ran inside the drain it nested another enqueue+await
on the same single-threaded drain — permanent deadlock on first telemetry poll.
Fix: _req_telemetry_async is now fully inline (no _enqueue_mc_loop_send).
_telemetry_poll_loop wraps its call in _enqueue_mc_loop_send for serialization.
req_telemetry_async's outer job calls _req_telemetry_async inline (safe).

BLOCKER 2 — pending futures abandoned on teardown/reconnect:
RadioSendQueue.stop() only cancelled the drain task; queue-sitting items had
their concurrent.futures.Futures left unresolved, causing wrap_future() callers
to hang indefinitely. Fix: stop() drains the remaining queue with get_nowait()
and cancels every pending cfut. _cancel_mc_queue() schedules the same drain-
and-cancel via call_soon_threadsafe. _start_mc_queue() cancels old drain task
and drains old queue cfuts before arming the new queue (reconnect path).
connector.disconnect() now .result(timeout=5) on stop() instead of fire-and-forget.

SHOULD-FIX 3 — composite passes MC channel as wrong kwarg (composite_transport.py):
_broadcast_async no-hint loop was calling send_message_async(channel=child_channel)
for the meshcore child; should be meshcore_channel=child_channel. Silent drop fixed.

NIT 5 — false success on zero-channel MC send (meshcore_transport.py):
send_message_async returned True when meshcore_channel is None (nothing sent).
Now returns False so audit does not record a success for a no-op.

NIT 7 — config comment contradiction (config.py):
meshtastic_send_pacing_seconds comment said "0 disables the floor" while
simultaneously stating "still floored at 0.25". Removed the contradiction.

Regression tests (tests/test_send_queue.py — 3 new, all in TestDeadlockRegression):
- test_telemetry_queue_no_deadlock: drives req_telemetry_async through a real
  _mc_send_queue with fake MC commands; times out on pre-fix code (deadlock).
- test_teardown_resolves_pending_futures: enqueues slow+fast jobs, stops mid-drain,
  asserts every task resolves promptly; hangs on pre-fix code.
- test_reconnect_resolves_old_futures: calls _start_mc_queue twice, asserts old
  cfuts are cancelled; pre-fix leaves them unresolved.

All 17 pre-existing send-queue tests still pass (20 total now).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-08 09:38:08 -06:00 committed by GitHub
commit a7b7f5a6a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1661 additions and 63 deletions

View file

@ -0,0 +1,611 @@
"""Tests for the per-radio serialized send queue.
Covers:
- FIFO ordering (no reordering, no drops)
- Pacing: consecutive timestamps >= pacing_seconds (0.05 s in tests)
- Event loop not blocked during burst
- Concurrent tasks make progress while queue drains
- Config floor enforced (min 0.25 s)
- Burst serialized: no parallel send overlap
- RadioSendQueue.start/stop lifecycle
- MT transport send_message_async falls back when queue not started
"""
from __future__ import annotations
import asyncio
import concurrent.futures
import time
from typing import List
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from meshai.transport.send_queue import RadioSendQueue, _PACING_FLOOR
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_queue(pacing: float = 0.05) -> RadioSendQueue:
return RadioSendQueue(pacing_fn=lambda: pacing)
async def _run_with_queue(pacing: float, jobs) -> list:
"""Run *jobs* (list of async callables) through a queue; return results in order."""
q = _make_queue(pacing)
loop = asyncio.get_event_loop()
q.start(loop)
results = []
for fn in jobs:
result = await q.enqueue_async(fn)
results.append(result)
await q.stop()
return results
# ---------------------------------------------------------------------------
# FIFO ordering
# ---------------------------------------------------------------------------
class TestFIFO:
@pytest.mark.asyncio
async def test_results_in_enqueue_order(self):
"""Results come back in the order items were enqueued."""
order = []
async def make_job(n):
async def _job():
order.append(n)
return True
return _job
q = _make_queue(pacing=0.01)
loop = asyncio.get_event_loop()
q.start(loop)
futs = []
for i in range(5):
futs.append(await q.enqueue_async(await make_job(i)))
# Wait for all to complete
await asyncio.gather(*[asyncio.wrap_future(concurrent.futures.Future()) for _ in range(0)],
return_exceptions=True)
# Stop drains remaining items
await q.stop()
assert order == [0, 1, 2, 3, 4]
@pytest.mark.asyncio
async def test_no_drops(self):
"""Every enqueued item executes — no items are dropped.
Uses enqueue_async so we can await all completions without sleeping;
this also avoids dependence on the pacing floor timing.
"""
executed = []
async def make_job(n):
async def _job():
executed.append(n)
return True
return _job
q = _make_queue(pacing=0.01)
loop = asyncio.get_event_loop()
q.start(loop)
N = 6
# Enqueue all jobs concurrently (fire them as tasks), then gather.
tasks = [asyncio.ensure_future(q.enqueue_async(await make_job(i))) for i in range(N)]
await asyncio.gather(*tasks)
await q.stop()
assert len(executed) == N
assert sorted(executed) == list(range(N))
# ---------------------------------------------------------------------------
# Pacing
# ---------------------------------------------------------------------------
class TestPacing:
@pytest.mark.asyncio
async def test_pacing_gap_respected(self):
"""Send timestamps are spaced >= pacing_seconds apart."""
pacing = 0.05
timestamps: list[float] = []
async def _job():
timestamps.append(time.monotonic())
return True
q = _make_queue(pacing=pacing)
loop = asyncio.get_event_loop()
q.start(loop)
N = 4
futs = [asyncio.ensure_future(q.enqueue_async(_job)) for _ in range(N)]
await asyncio.gather(*futs)
await q.stop()
assert len(timestamps) == N
for i in range(1, N):
gap = timestamps[i] - timestamps[i - 1]
assert gap >= pacing * 0.9, f"gap[{i}]={gap:.3f} < pacing={pacing}"
@pytest.mark.asyncio
async def test_pacing_read_live(self):
"""Pacing value is read from the callable on each iteration."""
pacing_value = 0.05
timestamps: list[float] = []
async def _job():
timestamps.append(time.monotonic())
return True
q = RadioSendQueue(pacing_fn=lambda: pacing_value)
loop = asyncio.get_event_loop()
q.start(loop)
# Enqueue first
futs = [asyncio.ensure_future(q.enqueue_async(_job)) for _ in range(2)]
await asyncio.gather(*futs)
# Change pacing and run 2 more
pacing_value = 0.10
futs = [asyncio.ensure_future(q.enqueue_async(_job)) for _ in range(2)]
await asyncio.gather(*futs)
await q.stop()
assert len(timestamps) == 4
# ---------------------------------------------------------------------------
# Config floor
# ---------------------------------------------------------------------------
class TestPacingFloor:
@pytest.mark.asyncio
async def test_floor_enforced(self):
"""Pacing below the floor is clamped up to _PACING_FLOOR (0.25 s)."""
q = RadioSendQueue(pacing_fn=lambda: 0.001) # way below floor
loop = asyncio.get_event_loop()
q.start(loop)
timestamps: list[float] = []
async def _job():
timestamps.append(time.monotonic())
return True
futs = [asyncio.ensure_future(q.enqueue_async(_job)) for _ in range(2)]
await asyncio.gather(*futs)
await q.stop()
assert len(timestamps) == 2
gap = timestamps[1] - timestamps[0]
assert gap >= _PACING_FLOOR * 0.9, f"floor not enforced: gap={gap:.3f}"
def test_floor_constant(self):
assert _PACING_FLOOR == 0.25
# ---------------------------------------------------------------------------
# Event loop not blocked
# ---------------------------------------------------------------------------
class TestNonBlocking:
@pytest.mark.asyncio
async def test_other_tasks_progress_during_drain(self):
"""The event loop remains available to other coroutines while the queue drains."""
pacing = 0.05
q = _make_queue(pacing=pacing)
loop = asyncio.get_event_loop()
q.start(loop)
progress_count = 0
async def _send_job():
await asyncio.sleep(0) # yield briefly
return True
async def _observer():
nonlocal progress_count
for _ in range(8):
await asyncio.sleep(0.02)
progress_count += 1
# Run drain + observer concurrently
futs = [asyncio.ensure_future(q.enqueue_async(_send_job)) for _ in range(5)]
obs = asyncio.ensure_future(_observer())
await asyncio.gather(*futs, obs)
await q.stop()
# Observer should have completed all its iterations
assert progress_count >= 6, f"observer only made {progress_count} iterations"
@pytest.mark.asyncio
async def test_send_returns_actual_result(self):
"""Future resolves to the actual bool returned by the send job."""
q = _make_queue(pacing=0.01)
loop = asyncio.get_event_loop()
q.start(loop)
async def _ok():
return True
async def _fail():
return False
r1 = await q.enqueue_async(_ok)
r2 = await q.enqueue_async(_fail)
r3 = await q.enqueue_async(_ok)
await q.stop()
assert r1 is True
assert r2 is False
assert r3 is True
# ---------------------------------------------------------------------------
# Serialization — no overlap
# ---------------------------------------------------------------------------
class TestSerialization:
@pytest.mark.asyncio
async def test_no_concurrent_sends(self):
"""Only one job runs at a time — active_at windows never overlap."""
active_intervals: list[tuple[float, float]] = []
lock = asyncio.Lock()
async def _job():
start = time.monotonic()
async with lock:
end = time.monotonic()
active_intervals.append((start, end))
return True
q = _make_queue(pacing=0.01)
loop = asyncio.get_event_loop()
q.start(loop)
N = 5
futs = [asyncio.ensure_future(q.enqueue_async(_job)) for _ in range(N)]
await asyncio.gather(*futs)
await q.stop()
assert len(active_intervals) == N
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
class TestLifecycle:
@pytest.mark.asyncio
async def test_start_stop(self):
q = _make_queue(pacing=0.01)
loop = asyncio.get_event_loop()
assert not q.running
q.start(loop)
assert q.running
await q.stop()
assert not q.running
@pytest.mark.asyncio
async def test_stop_with_pending_items(self):
"""stop() cancels the drain; pending items stay in queue (not processed after stop)."""
processed = []
async def _slow_job():
await asyncio.sleep(0.5) # slow — won't complete before stop
processed.append(1)
return True
q = _make_queue(pacing=0.01)
loop = asyncio.get_event_loop()
q.start(loop)
# Enqueue a slow job + a second job
q.enqueue_fire_and_forget(_slow_job)
await asyncio.sleep(0.01) # let drain start the slow job
await q.stop()
# The slow job was in-flight; don't assert specific processed count.
assert not q.running
@pytest.mark.asyncio
async def test_enqueue_before_start_raises(self):
q = _make_queue(pacing=0.01)
with pytest.raises(RuntimeError, match="start"):
await q.enqueue_async(lambda: None)
@pytest.mark.asyncio
async def test_fire_and_forget_before_start_is_noop(self):
"""enqueue_fire_and_forget on unstarted queue logs and does nothing."""
q = _make_queue(pacing=0.01)
# Should not raise
q.enqueue_fire_and_forget(lambda: None)
# ---------------------------------------------------------------------------
# MeshtasticTransport.send_message_async fallback
# ---------------------------------------------------------------------------
class TestMTFallback:
@pytest.mark.asyncio
async def test_send_message_async_falls_back_without_queue(self):
"""When queue not started, send_message_async uses run_in_executor."""
from meshai.config import ConnectionConfig
from meshai.connector import MeshtasticTransport
cfg = ConnectionConfig()
mt = MeshtasticTransport(cfg)
# Mock the blocking send so we don't need a real radio
with patch.object(mt, "send_message", return_value=True) as mock_send:
result = await mt.send_message_async("hello", channel=0)
assert result is True
mock_send.assert_called_once()
@pytest.mark.asyncio
async def test_send_message_async_via_queue(self):
"""When queue is started, send_message_async goes through the drain."""
from meshai.config import ConnectionConfig
from meshai.connector import MeshtasticTransport
cfg = ConnectionConfig(meshtastic_send_pacing_seconds=0.05)
mt = MeshtasticTransport(cfg)
# Arm queue manually (normally done by set_message_callback)
loop = asyncio.get_event_loop()
from meshai.transport.send_queue import RadioSendQueue
pacing_fn = lambda: max(0.25, getattr(cfg, "meshtastic_send_pacing_seconds", 2.0))
mt._mt_queue = RadioSendQueue(pacing_fn=pacing_fn)
mt._mt_queue.start(loop)
calls = []
with patch.object(mt, "_blocking_mt_send", side_effect=lambda *a, **kw: calls.append(a) or True):
r1 = await mt.send_message_async("msg1", channel=0)
r2 = await mt.send_message_async("msg2", channel=0)
await mt._mt_queue.stop()
assert r1 is True
assert r2 is True
assert len(calls) == 2
# ---------------------------------------------------------------------------
# Config round-trip
# ---------------------------------------------------------------------------
class TestConfig:
def test_pacing_defaults(self):
from meshai.config import ConnectionConfig
cfg = ConnectionConfig()
assert cfg.meshtastic_send_pacing_seconds == 2.0
assert cfg.meshcore_send_pacing_seconds == 2.0
def test_pacing_round_trips(self):
from meshai.config import ConnectionConfig, _dataclass_to_dict, _dict_to_dataclass
cfg = ConnectionConfig(meshtastic_send_pacing_seconds=3.5, meshcore_send_pacing_seconds=1.5)
d = _dataclass_to_dict(cfg)
assert d["meshtastic_send_pacing_seconds"] == 3.5
assert d["meshcore_send_pacing_seconds"] == 1.5
cfg2 = _dict_to_dataclass(ConnectionConfig, d)
assert cfg2.meshtastic_send_pacing_seconds == 3.5
assert cfg2.meshcore_send_pacing_seconds == 1.5
# ---------------------------------------------------------------------------
# Regression tests — deadlock / teardown
# ---------------------------------------------------------------------------
class TestDeadlockRegression:
"""Regression suite for the two deadlock/hang bugs fixed in feat/send-queue.
Both tests must PASS on the fixed code and would HANG (timeout) on the
pre-fix code:
BLOCKER 1 ``req_telemetry_async`` self-deadlock:
Pre-fix: _telem_job_outer (running inside the drain) called
_req_telemetry_async which called _enqueue_mc_loop_send a nested
enqueue-and-await inside a single-threaded drain job deadlock.
Post-fix: _req_telemetry_async is inline; no nested enqueue.
BLOCKER 2 pending futures abandoned on teardown/reconnect:
Pre-fix: stop() only cancelled the drain task; queue-sitting items had
their concurrent.futures.Futures left unresolved, so
``await asyncio.wrap_future(cfut)`` callers hung indefinitely.
Post-fix: stop() drains the remaining queue and cancels every cfut.
"""
@pytest.mark.asyncio
async def test_telemetry_queue_no_deadlock(self):
"""req_telemetry_async must not self-deadlock the MC drain (BLOCKER 1).
Drives the on-demand telemetry poll through a real _mc_send_queue
(not mocked) with fake MC commands. Asserts it resolves within 2 s
and that a subsequent send on the same queue also drains (queue not
wedged). With the pre-fix code this would hang at the asyncio.wait_for
timeout because the nested _enqueue_mc_loop_send deadlocks the drain.
"""
from meshai.config import ConnectionConfig
from meshai.transport.meshcore_transport import MeshCoreTransport
cfg = ConnectionConfig(
meshcore_host="127.0.0.1",
meshcore_send_pacing_seconds=0.01,
)
mc = MeshCoreTransport(cfg)
loop = asyncio.get_event_loop()
# Arm the MC queue directly (bypass connect() / TCP).
mc._loop = loop
mc._connected = True
mc._mc_send_queue = asyncio.Queue()
mc._mc_drain_task = loop.create_task(
mc._mc_drain_loop(), name="test-mc-drain"
)
fake_lpp = [{"channel": 0, "type": 120, "value": 80}] # battery_pct=80
class _FakeCommands:
async def req_telemetry_sync(self, contact, min_timeout=5):
return fake_lpp
class _FakeMC:
commands = _FakeCommands()
def get_contact_by_key_prefix(self, prefix):
return {"adv_name": "Node1", "public_key": prefix}
def get_contact_by_name(self, name):
return {"adv_name": name, "public_key": "aabbcc"}
mc._mc = _FakeMC()
# Must complete within 2 s; pre-fix code deadlocks here.
data = await asyncio.wait_for(
mc.req_telemetry_async("aabbcc"),
timeout=2.0,
)
assert data is not None, "expected telemetry data back from cache"
assert data.get("battery_pct") == 80
# Subsequent send on the same queue must also drain (queue not wedged).
done = asyncio.Event()
async def _normal_send() -> bool:
done.set()
return True
ok = await asyncio.wait_for(
mc._enqueue_mc_loop_send(_normal_send), timeout=2.0
)
assert ok is True
assert done.is_set()
# Cleanup.
mc._mc_drain_task.cancel()
try:
await mc._mc_drain_task
except asyncio.CancelledError:
pass
@pytest.mark.asyncio
async def test_teardown_resolves_pending_futures(self):
"""RadioSendQueue.stop() must resolve all pending futures (BLOCKER 2).
Enqueues a slow job to occupy the drain, then enqueues three more that
sit in the queue. Calls stop() mid-drain and asserts every future is
done (not pending/hanging) and resolves promptly to
CancelledError/exception. With pre-fix code the pending futs would
never be set so the assert would fail or the test would time out.
"""
q = RadioSendQueue(pacing_fn=lambda: 10.0) # huge pacing keeps drain idle long
loop = asyncio.get_event_loop()
q.start(loop)
drain_started = asyncio.Event()
async def _slow_job():
drain_started.set()
await asyncio.sleep(60) # will be cancelled by stop()
return True
async def _fast_job():
return True
# Kick off the slow job — drain picks it up immediately.
slow_task = asyncio.ensure_future(q.enqueue_async(_slow_job))
await asyncio.wait_for(drain_started.wait(), timeout=1.0)
# Enqueue three more jobs while drain is occupied by slow_job.
pending_tasks = [
asyncio.ensure_future(q.enqueue_async(_fast_job)) for _ in range(3)
]
# Stop the queue while slow_job is in-flight and fast jobs are pending.
await q.stop()
# Give the event loop one tick to propagate cfut cancellations into
# the asyncio tasks waiting at wrap_future(cfut).
await asyncio.sleep(0)
# Every future must be resolved — none hanging indefinitely.
# Await them with a short timeout; pre-fix code they would never complete.
all_tasks = [slow_task] + pending_tasks
done, pending_set = await asyncio.wait(all_tasks, timeout=1.0)
assert not pending_set, (
f"{len(pending_set)} task(s) still pending after stop() — "
"futures not resolved on teardown"
)
# Awaiting them must raise (CancelledError) — not return a value.
for task in all_tasks:
assert task.done()
with pytest.raises(
(asyncio.CancelledError, concurrent.futures.CancelledError, Exception)
):
task.result()
@pytest.mark.asyncio
async def test_reconnect_resolves_old_futures(self):
"""_start_mc_queue on reconnect must cancel futures from the old queue
(BLOCKER 2 reconnect path).
Arms the queue, enqueues three items without draining them, then calls
_start_mc_queue again (simulating reconnect). All three old futures
must be cancelled so no caller hangs. Pre-fix code would leave them
unresolved.
"""
from meshai.config import ConnectionConfig
from meshai.transport.meshcore_transport import MeshCoreTransport
cfg = ConnectionConfig(
meshcore_host="127.0.0.1",
meshcore_send_pacing_seconds=0.01,
)
mc = MeshCoreTransport(cfg)
loop = asyncio.get_event_loop()
mc._loop = loop
# Initial arm — drain is live but _mc is None so any job would return False.
mc._start_mc_queue()
await asyncio.sleep(0) # let drain task start
# Enqueue three items; they sit in the queue unprocessed.
pending_cfuts: list[concurrent.futures.Future] = []
for _ in range(3):
cfut: concurrent.futures.Future = concurrent.futures.Future()
async def _noop() -> bool:
return True
await mc._mc_send_queue.put((_noop, cfut))
pending_cfuts.append(cfut)
# Simulate reconnect: _start_mc_queue replaces the queue.
# The fix must cancel old cfuts before creating the new queue.
mc._start_mc_queue()
await asyncio.sleep(0.05) # let any scheduled callbacks run
for cfut in pending_cfuts:
assert cfut.done(), "old cfut not resolved after _start_mc_queue reconnect"
assert cfut.cancelled(), "old cfut should be cancelled"
# Cleanup new drain.
if mc._mc_drain_task is not None:
mc._mc_drain_task.cancel()
try:
await mc._mc_drain_task
except asyncio.CancelledError:
pass