mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Do better DM retry to align with stndard firmware retry (but so that we can track the acks). Closes #73.
This commit is contained in:
@@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
from meshcore import EventType
|
||||
|
||||
import app.services.message_send as message_send_service
|
||||
from app.models import SendDirectMessageRequest
|
||||
from app.radio import radio_manager
|
||||
from app.repository import ContactRepository
|
||||
@@ -26,6 +27,12 @@ def _reset_radio_state():
|
||||
radio_manager._operation_lock = prev_lock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _disable_background_dm_retries(monkeypatch):
|
||||
monkeypatch.setattr(message_send_service, "DM_SEND_MAX_ATTEMPTS", 1)
|
||||
yield
|
||||
|
||||
|
||||
def _make_mc(name="TestNode"):
|
||||
mc = MagicMock()
|
||||
mc.self_info = {"name": name}
|
||||
|
||||
@@ -12,6 +12,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
import app.services.message_send as message_send_service
|
||||
from app.radio import radio_manager
|
||||
from app.repository import (
|
||||
ChannelRepository,
|
||||
@@ -39,6 +40,12 @@ def _reset_radio_state():
|
||||
radio_manager._channel_key_by_slot = prev_key_by_slot
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _disable_background_dm_retries(monkeypatch):
|
||||
monkeypatch.setattr(message_send_service, "DM_SEND_MAX_ATTEMPTS", 1)
|
||||
yield
|
||||
|
||||
|
||||
def _patch_require_connected(mc=None, *, detail="Radio not connected"):
|
||||
if mc is None:
|
||||
return patch(
|
||||
|
||||
@@ -201,6 +201,42 @@ class TestAckEventHandler:
|
||||
assert "expected" in _pending_acks
|
||||
assert "different" in _buffered_acks
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_dm_ack_clears_sibling_retry_codes(self, test_db):
|
||||
"""A DM should stop at ack_count=1 even if retry ACK codes arrive later."""
|
||||
from app.event_handlers import on_ack
|
||||
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text="Hello",
|
||||
received_at=1700000000,
|
||||
conversation_key="aa" * 32,
|
||||
sender_timestamp=1700000000,
|
||||
outgoing=True,
|
||||
)
|
||||
|
||||
track_pending_ack("ack1", message_id=msg_id, timeout_ms=10000)
|
||||
track_pending_ack("ack2", message_id=msg_id, timeout_ms=10000)
|
||||
|
||||
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
|
||||
class FirstAckEvent:
|
||||
payload = {"code": "ack1"}
|
||||
|
||||
class SecondAckEvent:
|
||||
payload = {"code": "ack2"}
|
||||
|
||||
await on_ack(FirstAckEvent())
|
||||
await on_ack(SecondAckEvent())
|
||||
|
||||
ack_count, _ = await MessageRepository.get_ack_and_paths(msg_id)
|
||||
assert ack_count == 1
|
||||
assert "ack2" not in _pending_acks
|
||||
assert "ack2" in _buffered_acks
|
||||
mock_broadcast.assert_called_once_with(
|
||||
"message_acked", {"message_id": msg_id, "ack_count": 1}
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ack_empty_code_ignored(self, test_db):
|
||||
"""ACK with empty code is ignored."""
|
||||
|
||||
@@ -8,6 +8,7 @@ import pytest
|
||||
from fastapi import HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
import app.services.message_send as message_send_service
|
||||
from app.models import (
|
||||
SendChannelMessageRequest,
|
||||
SendDirectMessageRequest,
|
||||
@@ -69,6 +70,7 @@ def _make_mc(name="TestNode"):
|
||||
mc.commands.send_msg = AsyncMock(return_value=_make_radio_result())
|
||||
mc.commands.send_chan_msg = AsyncMock(return_value=_make_radio_result())
|
||||
mc.commands.add_contact = AsyncMock(return_value=_make_radio_result())
|
||||
mc.commands.reset_path = AsyncMock(return_value=MagicMock(type=EventType.OK, payload={}))
|
||||
mc.commands.set_channel = AsyncMock(return_value=_make_radio_result())
|
||||
mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
return mc
|
||||
@@ -94,6 +96,12 @@ async def _insert_contact(public_key, name="Alice", **overrides):
|
||||
await ContactRepository.upsert(data)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _disable_background_dm_retries(monkeypatch):
|
||||
monkeypatch.setattr(message_send_service, "DM_SEND_MAX_ATTEMPTS", 1)
|
||||
yield
|
||||
|
||||
|
||||
class TestOutgoingDMBroadcast:
|
||||
"""Test that outgoing DMs are broadcast via broadcast_event for fanout dispatch."""
|
||||
|
||||
@@ -272,6 +280,185 @@ class TestOutgoingDMBroadcast:
|
||||
assert message.acked == 1
|
||||
assert any(event_type == "message_acked" for event_type, _data in broadcasts)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_without_expected_ack_does_not_schedule_retries(self, test_db):
|
||||
mc = _make_mc()
|
||||
pub_key = "fb" * 32
|
||||
await _insert_contact(pub_key, "Alice")
|
||||
|
||||
mc.commands.send_msg = AsyncMock(return_value=_make_radio_result({}))
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
patch("app.services.message_send.asyncio.create_task") as mock_create_task,
|
||||
):
|
||||
message = await send_direct_message(
|
||||
SendDirectMessageRequest(destination=pub_key, text="Hello")
|
||||
)
|
||||
|
||||
assert message.acked == 0
|
||||
mock_create_task.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_background_retries_reset_path_before_final_attempt(self, test_db):
|
||||
mc = _make_mc()
|
||||
pub_key = "fc" * 32
|
||||
await _insert_contact(pub_key, "Alice")
|
||||
|
||||
mc.commands.send_msg = AsyncMock(
|
||||
side_effect=[
|
||||
_make_radio_result(
|
||||
{"expected_ack": b"\x00\x00\x00\x01", "suggested_timeout": 8000}
|
||||
),
|
||||
_make_radio_result(
|
||||
{"expected_ack": b"\x00\x00\x00\x02", "suggested_timeout": 7000}
|
||||
),
|
||||
_make_radio_result(
|
||||
{"expected_ack": b"\x00\x00\x00\x03", "suggested_timeout": 6000}
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
retry_tasks = []
|
||||
loop = asyncio.get_running_loop()
|
||||
slept_for = []
|
||||
|
||||
def schedule_retry(coro):
|
||||
task = loop.create_task(coro)
|
||||
retry_tasks.append(task)
|
||||
return task
|
||||
|
||||
async def no_wait(seconds):
|
||||
slept_for.append(seconds)
|
||||
return None
|
||||
|
||||
with (
|
||||
patch.object(message_send_service, "DM_SEND_MAX_ATTEMPTS", 3),
|
||||
patch("app.routers.messages.track_pending_ack", return_value=False),
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
patch("app.services.message_send.asyncio.create_task", side_effect=schedule_retry),
|
||||
patch("app.services.message_send.asyncio.sleep", side_effect=no_wait),
|
||||
):
|
||||
await send_direct_message(SendDirectMessageRequest(destination=pub_key, text="Hello"))
|
||||
await asyncio.gather(*retry_tasks)
|
||||
|
||||
assert mc.commands.send_msg.await_count == 3
|
||||
assert mc.commands.add_contact.await_count == 3
|
||||
assert mc.commands.send_msg.await_args_list[1].kwargs["attempt"] == 1
|
||||
assert mc.commands.send_msg.await_args_list[2].kwargs["attempt"] == 2
|
||||
mc.commands.reset_path.assert_awaited_once_with(pub_key)
|
||||
assert slept_for == pytest.approx([9.6, 8.4])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_background_retry_stops_after_late_ack(self, test_db):
|
||||
from app.event_handlers import on_ack
|
||||
|
||||
mc = _make_mc()
|
||||
pub_key = "fd" * 32
|
||||
await _insert_contact(pub_key, "Alice")
|
||||
|
||||
mc.commands.send_msg = AsyncMock(
|
||||
return_value=_make_radio_result(
|
||||
{"expected_ack": b"\xde\xad\xbe\xef", "suggested_timeout": 8000}
|
||||
)
|
||||
)
|
||||
|
||||
retry_tasks = []
|
||||
sleep_gate = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def schedule_retry(coro):
|
||||
task = loop.create_task(coro)
|
||||
retry_tasks.append(task)
|
||||
return task
|
||||
|
||||
async def gated_sleep(_seconds):
|
||||
await sleep_gate.wait()
|
||||
|
||||
class MockAckEvent:
|
||||
payload = {"code": "deadbeef"}
|
||||
|
||||
with (
|
||||
patch.object(message_send_service, "DM_SEND_MAX_ATTEMPTS", 3),
|
||||
patch("app.event_handlers.broadcast_event"),
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
patch("app.services.message_send.asyncio.create_task", side_effect=schedule_retry),
|
||||
patch("app.services.message_send.asyncio.sleep", side_effect=gated_sleep),
|
||||
):
|
||||
message = await send_direct_message(
|
||||
SendDirectMessageRequest(destination=pub_key, text="Hello")
|
||||
)
|
||||
await on_ack(MockAckEvent())
|
||||
sleep_gate.set()
|
||||
await asyncio.gather(*retry_tasks)
|
||||
|
||||
ack_count, _ = await MessageRepository.get_ack_and_paths(message.id)
|
||||
assert ack_count == 1
|
||||
assert mc.commands.send_msg.await_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_buffered_retry_ack_clears_older_dm_ack_codes(self, test_db):
|
||||
from app.event_handlers import on_ack
|
||||
|
||||
mc = _make_mc()
|
||||
pub_key = "fe" * 32
|
||||
await _insert_contact(pub_key, "Alice")
|
||||
|
||||
mc.commands.send_msg = AsyncMock(
|
||||
side_effect=[
|
||||
_make_radio_result(
|
||||
{"expected_ack": b"\xaa\xaa\xaa\x01", "suggested_timeout": 8000}
|
||||
),
|
||||
_make_radio_result(
|
||||
{"expected_ack": b"\xbb\xbb\xbb\x02", "suggested_timeout": 8000}
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
retry_tasks = []
|
||||
sleep_gate = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def schedule_retry(coro):
|
||||
task = loop.create_task(coro)
|
||||
retry_tasks.append(task)
|
||||
return task
|
||||
|
||||
async def gated_sleep(_seconds):
|
||||
await sleep_gate.wait()
|
||||
|
||||
class RetryAckEvent:
|
||||
payload = {"code": "bbbbbb02"}
|
||||
|
||||
class FirstAckEvent:
|
||||
payload = {"code": "aaaaaa01"}
|
||||
|
||||
with (
|
||||
patch.object(message_send_service, "DM_SEND_MAX_ATTEMPTS", 3),
|
||||
patch("app.event_handlers.broadcast_event"),
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
patch("app.services.message_send.asyncio.create_task", side_effect=schedule_retry),
|
||||
patch("app.services.message_send.asyncio.sleep", side_effect=gated_sleep),
|
||||
):
|
||||
message = await send_direct_message(
|
||||
SendDirectMessageRequest(destination=pub_key, text="Hello")
|
||||
)
|
||||
await on_ack(RetryAckEvent())
|
||||
sleep_gate.set()
|
||||
await asyncio.gather(*retry_tasks)
|
||||
await on_ack(FirstAckEvent())
|
||||
|
||||
ack_count, _ = await MessageRepository.get_ack_and_paths(message.id)
|
||||
assert ack_count == 1
|
||||
|
||||
|
||||
class TestOutgoingChannelBroadcast:
|
||||
"""Test that outgoing channel messages are broadcast via broadcast_event for fanout dispatch."""
|
||||
|
||||
Reference in New Issue
Block a user