Files
MeshTerm/meshterm/core/transmit_lock.py
T
Jean-Pierre MartineauandClaude Opus 5.5 7cdc6d9ba3 A channel can be given a region its messages are flooded into
The firmware has no per-channel scope, so a channel send is now a window
on the companion's session scope: set it, send, restore it, under a
transmit lock every flood-capable send also takes (DMs, adverts, admin
commands, logins, regions/neighbour requests), so nothing interleaves.
Ack waits stay outside the lock. A scope the radio can't take stops the
message (FloodScopeError naming the firmware it needs) rather than sending
it unscoped or under the default; * checks the firmware version, since
before 1.16 the override would quietly fall back to the default scope.
A failed restore is repaired by the next transmission.

Channel scopes are set from the channel page's Send scope picker (known
regions with their carriers, or a typed name) and from the CLI (channels
scope, chat send --scope). The chat's title carries the scope as a status
atom, ^R/Resend sends the newest scoped message again unscoped after an
amber confirm, and the scope each sent message went under is recorded
(messages.scope, schema v17) so the paths dialog can say what it went
under and when no known repeater carries that region. Device config now
teaches the region store the default scope it reads or sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:02:34 -04:00

82 lines
3.3 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The transmit lock: what keeps a channel's scope from leaking onto someone else's flood.
The firmware has no per-channel scope. What it has is a *session* scope — one setting on
the companion that every flood it sends goes out under, channel messages, flood DMs,
adverts and requests alike (see :meth:`~meshterm.core.connection.Device.set_flood_scope`).
A channel scope is therefore three commands, not one: set the session scope, send the
channel message, set it back. Anything else the app transmits *between* the first and the
third goes out under that channel's region too — a courier retry, a DM typed in another
screen, the weekly advert — and would be relayed only by the repeaters carrying a region
its sender never chose.
So every transmission MeshTerm asks of the companion holds this lock for the moment it
takes to hand the frame over, and a scoped channel send holds it across all three commands.
Nothing can be interleaved into the window, and the window is as short as three round
trips; an acknowledgement *wait* is not a transmission and is done outside the lock, so a
DM waiting eight seconds for its ack never holds a channel message up behind it.
The lock is **re-entrant per task**: the scoped send takes it, then calls the ordinary
channel send, which takes it again on its own account — and must not wait for itself.
One leak is out of any client's reach: a DM *received* inside the window is acknowledged
by the firmware on its own, and that ack is a flood under whatever the session scope is
at that instant. The window being three round trips long is the whole of the mitigation,
and it is what the official apps live with too.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
class TransmitLock:
"""A task-re-entrant :class:`asyncio.Lock` for "something is going on the air now".
Interact through :meth:`held`, an async context manager. A task already holding the
lock enters again at once; any other task waits for the holder to leave.
"""
def __init__(self) -> None:
"""Start unheld."""
self._lock = asyncio.Lock()
self._owner: asyncio.Task | None = None
self._depth = 0
@property
def locked(self) -> bool:
"""Whether any task holds the lock right now."""
return self._lock.locked()
def held_by_me(self) -> bool:
"""Whether the calling task is the one holding the lock."""
try:
task = asyncio.current_task()
except RuntimeError: # no running loop: nobody here holds anything
return False
return task is not None and self._owner is task
@asynccontextmanager
async def held(self) -> AsyncIterator[None]:
"""Hold the lock for the body; re-enter at once if this task already holds it."""
if self.held_by_me():
self._depth += 1
try:
yield
finally:
self._depth -= 1
return
async with self._lock:
self._owner = asyncio.current_task()
self._depth = 1
try:
yield
finally:
self._owner = None
self._depth = 0
__all__ = ["TransmitLock"]