Add retry on flood for repeater login

This commit is contained in:
Jack Kingsman
2026-07-25 15:12:12 -07:00
parent 50d063b195
commit 1fb716aed3
5 changed files with 295 additions and 20 deletions
+2 -2
View File
@@ -341,7 +341,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/contacts/{public_key}/routing-override` | Set or clear a forced routing override | | POST | `/api/contacts/{public_key}/routing-override` | Set or clear a forced routing override |
| POST | `/api/contacts/{public_key}/trace` | Trace route to contact | | POST | `/api/contacts/{public_key}/trace` | Trace route to contact |
| POST | `/api/contacts/{public_key}/path-discovery` | Discover forward/return paths and persist the learned direct route | | POST | `/api/contacts/{public_key}/path-discovery` | Discover forward/return paths and persist the learned direct route |
| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater | | POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater (escalates to one flood retry if the first attempt draws no reply) |
| POST | `/api/contacts/{public_key}/repeater/status` | Fetch repeater status telemetry | | POST | `/api/contacts/{public_key}/repeater/status` | Fetch repeater status telemetry |
| POST | `/api/contacts/{public_key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data | | POST | `/api/contacts/{public_key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data |
| POST | `/api/contacts/{public_key}/repeater/neighbors` | Fetch repeater neighbors | | POST | `/api/contacts/{public_key}/repeater/neighbors` | Fetch repeater neighbors |
@@ -354,7 +354,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| GET | `/api/contacts/{public_key}/repeater/telemetry-history` | Stored telemetry history for a repeater (read-only, no radio access) | | GET | `/api/contacts/{public_key}/repeater/telemetry-history` | Stored telemetry history for a repeater (read-only, no radio access) |
| POST | `/api/contacts/{public_key}/telemetry` | Fetch CayenneLPP telemetry from any contact (single attempt, 10s timeout) | | POST | `/api/contacts/{public_key}/telemetry` | Fetch CayenneLPP telemetry from any contact (single attempt, 10s timeout) |
| GET | `/api/contacts/{public_key}/telemetry-history` | Stored LPP telemetry history for a contact (read-only, no radio access) | | GET | `/api/contacts/{public_key}/telemetry-history` | Stored LPP telemetry history for a contact (read-only, no radio access) |
| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server | | POST | `/api/contacts/{public_key}/room/login` | Log in to a room server (escalates to one flood retry if the first attempt draws no reply) |
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry | | POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data | | POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries | | POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
+19 -2
View File
@@ -142,6 +142,23 @@ app/
- ACKs are delivery state, not routing state. Bundled ACKs inside PATH packets still satisfy pending DM sends, but ACK history does not feed contact route learning. - ACKs are delivery state, not routing state. Bundled ACKs inside PATH packets still satisfy pending DM sends, but ACK history does not feed contact route learning.
- DM ACKs are matched from two independent radio emissions, so confirmation does not depend on the radio surfacing a host control frame: (1) the `EventType.ACK`/`SEND_CONFIRMED` host frame via `event_handlers.on_ack`, and (2) the raw RF packet itself via `packet_processor.process_raw_packet`. The packet processor extracts ACK codes both from PATH-return packets (flood replies, ACK embedded in `extra`) and from standalone `PayloadType.ACK` packets (direct replies, 4-byte cleartext payload), feeding both into `apply_dm_ack_code`. This matters for companion firmwares (e.g. pyMC over TCP) that do not reliably emit a separate host ACK frame for direct-routed replies. - DM ACKs are matched from two independent radio emissions, so confirmation does not depend on the radio surfacing a host control frame: (1) the `EventType.ACK`/`SEND_CONFIRMED` host frame via `event_handlers.on_ack`, and (2) the raw RF packet itself via `packet_processor.process_raw_packet`. The packet processor extracts ACK codes both from PATH-return packets (flood replies, ACK embedded in `extra`) and from standalone `PayloadType.ACK` packets (direct replies, 4-byte cleartext payload), feeding both into `apply_dm_ack_code`. This matters for companion firmwares (e.g. pyMC over TCP) that do not reliably emit a separate host ACK frame for direct-routed replies.
### Server login route escalation
`prepare_authenticated_contact_connection` (`routers/server_control.py`, shared by repeater and room login) sends one login over the contact's effective route. If that draws **no reply at all**, it calls `reset_path(...)` and retries exactly once as flood.
This is intentionally *more* than the reference implementations do — do not "correct" it back to single-shot:
- Firmware `BaseChatMesh::sendLogin` picks flood only when `out_path_len == OUT_PATH_UNKNOWN` and never retries; `CMD_SEND_LOGIN` calls it once.
- `meshcore_py` has `send_msg_with_retry` (with `flood_after` + `reset_path`) but no login equivalent — `send_login`/`send_login_sync` are single-shot.
- Firmware only clears a stale path when the *host* asks (`CMD_RESET_PATH`); client-side path learning is otherwise passive via `onContactPathRecv`.
Escalating is still correct because the **server** side treats an inbound flood as its cue to relearn the return path (`simple_repeater`/`simple_room_server`: `if (is_flood) client->out_path_len = OUT_PATH_UNKNOWN`). A flood login is therefore what repairs a broken route in both directions, and login gates the whole repeater dashboard.
Escalation is bounded to one extra attempt and only fires when:
- the first attempt **timed out**. `LOGIN_FAILED` means the server heard us and refused, so the route is fine and retrying only hammers it with bad credentials; a send error is a local radio problem a different route will not fix.
- the contact was **not already on flood** (`effective_route_source != "flood"`), since the retry would otherwise be byte-identical.
The retry deliberately does not re-run `_ensure_on_radio` — re-adding the contact would restore the route just cleared. `reset_path` clears the route on the radio only; the stored contact route is untouched, so the next `add_contact` re-stages it. That mirrors the DM retry and keeps one bad login from discarding a route that may be fine.
### Echo/repeat dedup ### Echo/repeat dedup
- Channel message uniqueness (`idx_messages_dedup_null_safe`): `(type, conversation_key, text, COALESCE(sender_timestamp, 0))` where `type = 'CHAN'`. - Channel message uniqueness (`idx_messages_dedup_null_safe`): `(type, conversation_key, text, COALESCE(sender_timestamp, 0))` where `type = 'CHAN'`.
@@ -242,7 +259,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
- `POST /contacts/{public_key}/routing-override` - `POST /contacts/{public_key}/routing-override`
- `POST /contacts/{public_key}/trace` - `POST /contacts/{public_key}/trace`
- `POST /contacts/{public_key}/path-discovery` — discover forward/return paths, persist the learned direct route, and sync it back to the radio best-effort - `POST /contacts/{public_key}/path-discovery` — discover forward/return paths, persist the learned direct route, and sync it back to the radio best-effort
- `POST /contacts/{public_key}/repeater/login` - `POST /contacts/{public_key}/repeater/login` — one attempt on the effective route, then one flood retry on timeout
- `POST /contacts/{public_key}/repeater/status` - `POST /contacts/{public_key}/repeater/status`
- `POST /contacts/{public_key}/repeater/lpp-telemetry` - `POST /contacts/{public_key}/repeater/lpp-telemetry`
- `POST /contacts/{public_key}/repeater/neighbors` - `POST /contacts/{public_key}/repeater/neighbors`
@@ -255,7 +272,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
- `GET /contacts/{public_key}/repeater/telemetry-history` — stored telemetry history for a repeater (read-only, no radio access) - `GET /contacts/{public_key}/repeater/telemetry-history` — stored telemetry history for a repeater (read-only, no radio access)
- `POST /contacts/{public_key}/telemetry` — on-demand CayenneLPP telemetry from any contact (persists in `contact_telemetry_history`) - `POST /contacts/{public_key}/telemetry` — on-demand CayenneLPP telemetry from any contact (persists in `contact_telemetry_history`)
- `GET /contacts/{public_key}/telemetry-history` — stored LPP telemetry history for a contact (read-only) - `GET /contacts/{public_key}/telemetry-history` — stored LPP telemetry history for a contact (read-only)
- `POST /contacts/{public_key}/room/login` - `POST /contacts/{public_key}/room/login` — one attempt on the effective route, then one flood retry on timeout
- `POST /contacts/{public_key}/room/status` - `POST /contacts/{public_key}/room/status`
- `POST /contacts/{public_key}/room/lpp-telemetry` - `POST /contacts/{public_key}/room/lpp-telemetry`
- `POST /contacts/{public_key}/room/acl` - `POST /contacts/{public_key}/room/acl`
+117 -15
View File
@@ -80,6 +80,15 @@ def _login_timeout_message(label: str) -> str:
) )
def _login_flood_retry_timeout_message(label: str) -> str:
return (
f"No login confirmation was heard from the {label}, including a retry sent as flood "
"in case the stored route was stale. That can mean the password was wrong, the "
f"{label} is out of range, or the reply was missed in transit. "
"You're free to attempt interaction; try logging in again if authenticated actions fail."
)
def extract_response_text(event) -> str: def extract_response_text(event) -> str:
"""Extract text from a CLI response event, stripping the firmware '> ' prefix.""" """Extract text from a CLI response event, stripping the firmware '> ' prefix."""
text = event.payload.get("text", str(event.payload)) text = event.payload.get("text", str(event.payload))
@@ -214,17 +223,20 @@ async def fetch_contact_cli_response(
subscription.unsubscribe() subscription.unsubscribe()
async def prepare_authenticated_contact_connection( async def _attempt_server_login(
mc, mc,
contact: Contact, contact: Contact,
password: str, password: str,
*, *,
label: str | None = None, contact_label: str,
response_timeout: float = SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS, response_timeout: float,
) -> RepeaterLoginResponse: ) -> RepeaterLoginResponse:
"""Prepare connection to a server-capable contact by adding it to the radio and logging in.""" """Send one login and wait for the reply.
Subscriptions are per-attempt because the resolving future can only be
settled once a retry needs a fresh pair.
"""
pubkey_prefix = contact.public_key[:12].lower() pubkey_prefix = contact.public_key[:12].lower()
contact_label = label or get_server_contact_label(contact)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
login_future = loop.create_future() login_future = loop.create_future()
@@ -254,9 +266,6 @@ async def prepare_authenticated_contact_connection(
) )
try: try:
logger.info("Adding %s %s to radio", contact_label, contact.public_key[:12])
await _ensure_on_radio(mc, contact)
logger.info("Sending login to %s %s", contact_label, contact.public_key[:12]) logger.info("Sending login to %s %s", contact_label, contact.public_key[:12])
login_result = await mc.commands.send_login(contact.public_key, password) login_result = await mc.commands.send_login(contact.public_key, password)
@@ -268,10 +277,7 @@ async def prepare_authenticated_contact_connection(
) )
try: try:
return await asyncio.wait_for( return await asyncio.wait_for(login_future, timeout=response_timeout)
login_future,
timeout=response_timeout,
)
except TimeoutError: except TimeoutError:
logger.warning( logger.warning(
"No login response from %s %s within %.1fs", "No login response from %s %s within %.1fs",
@@ -284,6 +290,105 @@ async def prepare_authenticated_contact_connection(
authenticated=False, authenticated=False,
message=_login_timeout_message(contact_label), message=_login_timeout_message(contact_label),
) )
finally:
success_subscription.unsubscribe()
failed_subscription.unsubscribe()
async def prepare_authenticated_contact_connection(
mc,
contact: Contact,
password: str,
*,
label: str | None = None,
response_timeout: float = SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
) -> RepeaterLoginResponse:
"""Prepare connection to a server-capable contact by adding it to the radio and logging in.
A login that draws no reply at all may mean the stored direct route has gone
stale, so it escalates to one flood retry mirroring the DM send path, which
already resets the path before its final attempt. This is deliberately more
than the reference clients do: firmware ``sendLogin`` and ``send_login_sync``
are both single-shot, and firmware only clears a stale path when the *host*
asks it to (``CMD_RESET_PATH``). Escalating is still the right call because
the server side treats an inbound flood as its cue to relearn the return path
(``simple_repeater``/``simple_room_server``: ``if (is_flood)
client->out_path_len = OUT_PATH_UNKNOWN``), so a flood login is exactly what
repairs a broken route in both directions.
Escalation is bounded to a single extra attempt and only fires when:
- the first attempt timed out. An explicit ``LOGIN_FAILED`` means the
server heard us and refused, so the route is fine and retrying would
just hammer it with bad credentials. A send error is a local radio
problem that a different route will not fix.
- the first attempt actually used a route. If the contact was already on
flood, the retry would be byte-identical for no gain.
``reset_path`` clears the route on the radio only; the stored contact route
is left alone, so the next ``add_contact`` re-stages it. That matches the DM
retry and keeps a single bad login from discarding a route that may be fine.
"""
contact_label = label or get_server_contact_label(contact)
try:
logger.info("Adding %s %s to radio", contact_label, contact.public_key[:12])
await _ensure_on_radio(mc, contact)
response = await _attempt_server_login(
mc,
contact,
password,
contact_label=contact_label,
response_timeout=response_timeout,
)
if response.status != "timeout":
return response
if contact.effective_route_source == "flood":
logger.debug(
"Login to %s %s timed out on flood; no route to escalate from",
contact_label,
contact.public_key[:12],
)
return response
logger.info(
"Login to %s %s timed out on the %s route; resetting path and retrying as flood",
contact_label,
contact.public_key[:12],
contact.effective_route_source,
)
reset_result = await mc.commands.reset_path(contact.public_key)
if reset_result is None:
logger.warning(
"No response from radio for reset_path to %s before flood login retry",
contact.public_key[:12],
)
return response
if reset_result.type == EventType.ERROR:
logger.warning(
"Failed to reset path before flood login retry to %s: %s",
contact.public_key[:12],
reset_result.payload,
)
return response
# Deliberately no _ensure_on_radio here — re-adding the contact would
# restore the very route we just cleared, and the retry would go direct.
flood_response = await _attempt_server_login(
mc,
contact,
password,
contact_label=contact_label,
response_timeout=response_timeout,
)
if flood_response.status == "timeout":
return RepeaterLoginResponse(
status="timeout",
authenticated=False,
message=_login_flood_retry_timeout_message(contact_label),
)
return flood_response
except HTTPException as exc: except HTTPException as exc:
logger.warning( logger.warning(
"%s login setup failed for %s: %s", "%s login setup failed for %s: %s",
@@ -296,9 +401,6 @@ async def prepare_authenticated_contact_connection(
authenticated=False, authenticated=False,
message=f"{_login_send_failed_message(contact_label)} ({exc.detail})", message=f"{_login_send_failed_message(contact_label)} ({exc.detail})",
) )
finally:
success_subscription.unsubscribe()
failed_subscription.unsubscribe()
async def batch_cli_fetch( async def batch_cli_fetch(
+1 -1
View File
@@ -422,7 +422,7 @@ State: `useConversationNavigation` controls open/close via `infoPaneChannelKey`.
For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput). For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`. **Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`. The frontend sends exactly one request; the backend internally escalates a timed-out login to one flood retry (see `app/AGENTS.md` § "Server login route escalation"), so a single call may take up to two response windows. Do not add a client-side login retry loop on top — a `LOGIN_FAILED` result means the password was refused, not that the route needs another attempt.
**Dashboard panes** (after login): Telemetry, Node Info, Neighbors, ACL, Radio Settings, Regions, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. The Regions pane prefers the admin CLI hierarchy and falls back to the guest anon flood-allowed names, so its payload carries a `source` of `cli` or `anon`. Panes retry up to 3 times client-side. `Neighbors` depends on the smaller `node-info` fetch for repeater GPS, not the heavier radio-settings batch. "Load All" fetches all panes serially (parallel would queue behind the radio lock). **Dashboard panes** (after login): Telemetry, Node Info, Neighbors, ACL, Radio Settings, Regions, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. The Regions pane prefers the admin CLI hierarchy and falls back to the guest anon flood-allowed names, so its payload carries a `source` of `cli` or `anon`. Panes retry up to 3 times client-side. `Neighbors` depends on the smaller `node-info` fetch for repeater GPS, not the heavier radio-settings batch. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
+156
View File
@@ -818,6 +818,162 @@ class TestPrepareRepeaterConnection:
assert "No login confirmation was heard from the repeater" in (response.message or "") assert "No login confirmation was heard from the repeater" in (response.message or "")
def _make_routed_contact() -> Contact:
"""A repeater with a learned direct route, so a login goes out direct."""
contact = Contact(
public_key=KEY_A,
name="Repeater",
type=2,
direct_path="aabb",
direct_path_len=2,
direct_path_hash_mode=0,
)
assert contact.effective_route_source == "direct"
return contact
class TestRepeaterLoginFloodEscalation:
"""A login that draws no reply escalates to one flood retry.
Firmware's own ``sendLogin`` is single-shot, so this is RT going beyond the
reference clients. It works because the *server* treats an inbound flood as
its cue to relearn the return path.
"""
@pytest.mark.asyncio
async def test_timeout_on_direct_route_resets_path_and_retries_as_flood(self):
mc = _mock_mc()
contact = _make_routed_contact()
subscriptions: dict[EventType, tuple[object, object]] = {}
attempts = 0
def _subscribe(event_type, callback, attribute_filters=None):
subscriptions[event_type] = (callback, attribute_filters)
return MagicMock(unsubscribe=MagicMock())
async def _send_login(*args, **kwargs):
nonlocal attempts
attempts += 1
# First attempt (direct) is never answered; the flood retry lands.
if attempts > 1:
callback, _filters = subscriptions[EventType.LOGIN_SUCCESS]
callback(_radio_result(EventType.LOGIN_SUCCESS, {"pubkey_prefix": KEY_A[:12]}))
return _radio_result(EventType.MSG_SENT)
mc.subscribe = MagicMock(side_effect=_subscribe)
mc.commands.send_login = AsyncMock(side_effect=_send_login)
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
response = await prepare_repeater_connection(mc, contact, "secret")
assert response.status == "ok"
assert response.authenticated is True
assert attempts == 2
mc.commands.reset_path.assert_awaited_once_with(KEY_A)
# The contact must not be re-added between attempts — that would restore
# the route we just cleared and send the retry direct again.
assert mc.commands.add_contact.await_count == 1
@pytest.mark.asyncio
async def test_flood_retry_timeout_reports_that_flood_was_tried(self):
mc = _mock_mc()
contact = _make_routed_contact()
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
response = await prepare_repeater_connection(mc, contact, "secret")
assert response.status == "timeout"
assert response.authenticated is False
assert "retry sent as flood" in (response.message or "")
assert mc.commands.send_login.await_count == 2
@pytest.mark.asyncio
async def test_no_escalation_when_contact_is_already_flood(self):
mc = _mock_mc()
contact = _make_contact() # no direct route -> already flooding
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
response = await prepare_repeater_connection(mc, contact, "secret")
assert response.status == "timeout"
# A flood retry would be byte-identical, so don't bother the repeater
assert mc.commands.send_login.await_count == 1
mc.commands.reset_path.assert_not_awaited()
@pytest.mark.asyncio
async def test_rejected_login_does_not_escalate(self):
"""LOGIN_FAILED means the route works and the password doesn't."""
mc = _mock_mc()
contact = _make_routed_contact()
subscriptions: dict[EventType, tuple[object, object]] = {}
def _subscribe(event_type, callback, attribute_filters=None):
subscriptions[event_type] = (callback, attribute_filters)
return MagicMock(unsubscribe=MagicMock())
async def _send_login(*args, **kwargs):
callback, _filters = subscriptions[EventType.LOGIN_FAILED]
callback(_radio_result(EventType.LOGIN_FAILED, {"pubkey_prefix": KEY_A[:12]}))
return _radio_result(EventType.MSG_SENT)
mc.subscribe = MagicMock(side_effect=_subscribe)
mc.commands.send_login = AsyncMock(side_effect=_send_login)
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
response = await prepare_repeater_connection(mc, contact, "bad")
assert response.status == "error"
assert mc.commands.send_login.await_count == 1
mc.commands.reset_path.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_error_does_not_escalate(self):
"""A local radio failure is not fixed by picking a different route."""
mc = _mock_mc()
contact = _make_routed_contact()
mc.commands.send_login = AsyncMock(
return_value=_radio_result(EventType.ERROR, {"err": "busy"})
)
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
response = await prepare_repeater_connection(mc, contact, "secret")
assert response.status == "error"
assert mc.commands.send_login.await_count == 1
mc.commands.reset_path.assert_not_awaited()
@pytest.mark.asyncio
async def test_failed_reset_path_returns_original_timeout(self):
mc = _mock_mc()
contact = _make_routed_contact()
mc.commands.reset_path = AsyncMock(
return_value=_radio_result(EventType.ERROR, {"err": "nope"})
)
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
response = await prepare_repeater_connection(mc, contact, "secret")
assert response.status == "timeout"
# Without a cleared path the retry would just go direct again
assert mc.commands.send_login.await_count == 1
assert "retry sent as flood" not in (response.message or "")
@pytest.mark.asyncio
async def test_no_reset_path_response_returns_original_timeout(self):
mc = _mock_mc()
contact = _make_routed_contact()
mc.commands.reset_path = AsyncMock(return_value=None)
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
response = await prepare_repeater_connection(mc, contact, "secret")
assert response.status == "timeout"
assert mc.commands.send_login.await_count == 1
class TestRepeaterStatus: class TestRepeaterStatus:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_success_with_field_mapping(self, test_db): async def test_success_with_field_mapping(self, test_db):