Files
MeshTerm/meshterm/services/courier.py
T
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00

260 lines
11 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The Courier: store-and-forward delivery for the outbox.
A session-long background loop over the :class:`~meshterm.core.courier_store.
CourierStore`. Messages queue for contacts that aren't reachable right now; the courier
listens to the hub for signs of life and delivers when the recipient is *fresh* —
heard within the last few minutes — or when a scheduled entry's time arrives. Each
attempt is one ordinary chat send (:meth:`~meshterm.services.chat_service.ChatService.
send_direct`, so it lands in the conversation history with its delivery state, exactly
as if typed), and an acknowledgement settles the entry as delivered.
Deliberately transmission-shy, like the advert scheduler:
* At most **one** delivery attempt per pass, so a backlog drains politely instead of
bursting onto a shared mesh.
* Failed attempts back off exponentially (5 min doubling to an hour) and — after the
first shot — wait until the contact has been heard *again*, so an absent contact is never
hammered on faith alone. Only a scheduled entry's first attempt fires blind: the
schedule was an explicit instruction.
* After :data:`MAX_ATTEMPTS` unacknowledged tries the courier gives up and says so.
Outcomes (delivered, given-up) are also raised as Watchtower alerts, so the header's
``▲`` badge lights when an overnight delivery finally lands.
"""
from __future__ import annotations
import asyncio
from datetime import datetime
from typing import TYPE_CHECKING
from ..core.connection import Unsubscribe
from ..core.courier_store import QUEUED, QueuedMessage
from ..core.events import EventKind, MeshEvent
from ..core.models import Contact, utcnow
if TYPE_CHECKING:
from ..context import AppContext
#: Seconds between courier passes.
POLL_S = 30.0
#: How recently (seconds) a node must have been heard to count as reachable.
FRESH_S = 600.0
#: Delivery attempts before the courier gives up on an entry.
MAX_ATTEMPTS = 5
#: Backoff after a failed attempt: this base doubling per failure, capped below.
BACKOFF_BASE_S = 300.0
BACKOFF_CAP_S = 3600.0
def _preview(text: str, width: int = 32) -> str:
"""The message body shortened for alert/log one-liners."""
return text if len(text) <= width else text[: width - 1] + "…"
class CourierService:
"""Owns the store-and-forward loop for an interactive session.
Interact through the async lifecycle methods (:meth:`start`, :meth:`stop`,
:meth:`aclose`) and :meth:`attempt_now`; eligibility lives in the synchronous
:meth:`eligible` so tests can drive it directly.
"""
def __init__(self, ctx: AppContext) -> None:
"""Initialize the (idle) service.
Args:
ctx: The shared application context (store, chat service, event hub).
"""
self._ctx = ctx
self._task: asyncio.Task | None = None
self._unsubscribe: Unsubscribe | None = None
#: When each node id was last overheard this session (the freshness map).
self._heard: dict[str, datetime] = {}
#: Re-entry guard: one attempt in flight at a time, pass or forced.
self._sending = False
@property
def active(self) -> bool:
"""Whether the courier loop is running."""
return self._task is not None and not self._task.done()
def pending_count(self) -> int:
"""How many messages wait in the outbox."""
return self._ctx.courier_store.pending_count()
# --- lifecycle ---------------------------------------------------------------------
async def start(self) -> None:
"""Start the loop and the freshness subscription. Idempotent, device-free."""
if self.active:
return
def on_event(event: MeshEvent) -> None:
obs = event.observation
if obs is not None and obs.node:
self._heard[obs.node] = obs.observed_at or utcnow()
self._unsubscribe = self._ctx.events.subscribe(on_event, EventKind.OBSERVATION)
self._task = asyncio.ensure_future(self._run())
self._ctx.log.info("courier started (%d queued)", self._ctx.courier_store.pending_count())
async def stop(self) -> None:
"""Stop the loop and the subscription. Idempotent."""
if self._unsubscribe is not None:
self._unsubscribe()
self._unsubscribe = None
task, self._task = self._task, None
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def aclose(self) -> None:
"""Stop the loop at session end (an alias for :meth:`stop`)."""
await self.stop()
async def _run(self) -> None:
"""Tick forever: sleep, then make one best-effort pass."""
while True:
await asyncio.sleep(POLL_S)
try:
await self._pass()
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - a bad pass must not kill the loop
self._ctx.log.debug("courier: pass failed: %s", exc)
# --- eligibility ---------------------------------------------------------------------
def heard_recently(self, node_key: str, now: datetime | None = None) -> bool:
"""Whether ``node_key`` was overheard within the freshness window."""
heard = self._heard.get(node_key)
if heard is None:
return False
return ((now or utcnow()) - heard).total_seconds() <= FRESH_S
def eligible(self, message: QueuedMessage, now: datetime | None = None) -> bool:
"""Whether one queued entry may be attempted right now.
The rules, in order: only queued entries; a schedule holds until its time;
failed attempts wait out an exponential backoff; and — except for a scheduled
entry's first, explicitly-timed shot — the recipient must have been heard
within the freshness window.
Args:
message: The outbox entry.
now: The evaluation time (defaults to the current time; tests inject).
Returns:
``True`` when an attempt is allowed.
"""
now = now or utcnow()
if message.status != QUEUED:
return False
if message.not_before is not None and now < message.not_before:
return False
if message.last_attempt is not None and message.attempts > 0:
backoff = min(BACKOFF_CAP_S, BACKOFF_BASE_S * 2 ** (message.attempts - 1))
if (now - message.last_attempt).total_seconds() < backoff:
return False
scheduled_first_shot = message.not_before is not None and message.attempts == 0
if not scheduled_first_shot and not self.heard_recently(message.node_key, now):
return False
return True
def next_retry_s(self, message: QueuedMessage, now: datetime | None = None) -> float | None:
"""Seconds until the backoff releases a failed entry, or ``None`` if free now."""
if message.last_attempt is None or message.attempts == 0:
return None
backoff = min(BACKOFF_CAP_S, BACKOFF_BASE_S * 2 ** (message.attempts - 1))
remaining = backoff - ((now or utcnow()) - message.last_attempt).total_seconds()
return remaining if remaining > 0 else None
# --- delivery ------------------------------------------------------------------------
async def _pass(self) -> None:
"""One pass: attempt the oldest eligible entry, if any (one per pass)."""
if not self._ctx.is_connected or self._sending:
return
now = utcnow()
for message in self._ctx.courier_store.pending():
if self.eligible(message, now):
await self._attempt(message)
return
async def attempt_now(self, ident: int) -> str:
"""Force one delivery attempt for the screen's *Send now*, skipping eligibility.
Args:
ident: The outbox entry's id.
Returns:
A short outcome string: ``delivered``, ``no ack``, ``gave up``,
``unknown contact``, ``busy``, or ``gone``.
"""
message = self._ctx.courier_store.get(ident)
if message is None or message.status != QUEUED:
return "gone"
if self._sending:
return "busy"
return await self._attempt(message)
async def _attempt(self, message: QueuedMessage) -> str:
"""Run one delivery attempt and settle the entry's state."""
store = self._ctx.courier_store
self._sending = True
try:
contact = await self._resolve(message)
if contact is None:
# Not addressable (yet): the device doesn't know the contact. Leave the
# entry waiting — no budget spent on a send that can't happen.
self._ctx.log.debug("courier: no contact for %r; leaving queued", message.node_name)
return "unknown contact"
store.note_attempt(message.ident)
chat = await self._ctx.chat.send_direct(contact, message.text)
if chat.acked:
store.mark_delivered(message.ident)
self._ctx.watch_store.add_alert(
"courier",
message.node_name,
f"delivered “{_preview(message.text)}” (attempt {message.attempts})",
)
self._ctx.log.info("courier: delivered to %s", message.node_name)
return "delivered"
if message.attempts >= MAX_ATTEMPTS:
store.mark_gave_up(message.ident)
self._ctx.watch_store.add_alert(
"courier",
message.node_name,
f"gave up on “{_preview(message.text)}” after {message.attempts} attempts",
)
return "gave up"
self._ctx.log.debug(
"courier: no ack from %s (attempt %d/%d)",
message.node_name,
message.attempts,
MAX_ATTEMPTS,
)
return "no ack"
finally:
self._sending = False
async def _resolve(self, message: QueuedMessage) -> Contact | None:
"""Find the recipient in the device's contact list, by key then by name."""
device = await self._ctx.device()
contacts = await device.get_contacts()
for contact in contacts:
ident = (contact.public_key or contact.key_prefix or "").lower()
ident = ident.removeprefix("0x")[:12]
if ident and ident == message.node_key:
return contact
needle = message.node_name.casefold()
return next((c for c in contacts if c.name.casefold() == needle), None)