Files
Jean-Pierre MartineauandClaude Opus 5.5 ffe31dac36 Docstrings and comments of meshterm/core are in ASD-STE100
The prose of each docstring and comment in the core package now follows
ASD-STE100 Simplified Technical English, with Canadian spelling: short
sentences, simple verb tenses, no dashes or semicolons, and one word for
one concept (docs/development/writing-style.md has the rules and the
glossary). The meaning and each reason stay. The code does not change:
the AST of each file, without its docstrings, is the same as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 03:29:03 -04:00

85 lines
3.4 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The transmit lock: it keeps the scope of a channel off the floods of other senders.
The firmware has no scope for each channel. It has a *session* scope: one setting on the
companion, which applies to each flood that the companion sends. This includes channel
messages, flood DMs, adverts, and requests (refer to
:meth:`~meshterm.core.connection.Device.set_flood_scope`). Thus a channel scope is three
commands, not one: set the session scope, send the channel message, and set the scope
back. If the app transmits something else between the first and the third command, it
goes out with the region of that channel too. Examples are a courier retry, a DM that the
user typed on another screen, and the weekly advert. Then only the repeaters that carry
that region relay it, and its sender never chose that region.
Thus each transmission that MeshTerm asks of the companion holds this lock for the time
that it takes to give the command to the companion. A scoped channel send holds the lock
for all three commands. Nothing can go into the window between them, and the window is
only three round trips long. A wait for an acknowledgement is not a transmission, and it
occurs outside the lock. Thus, when a DM waits eight seconds for its ack, it never delays a
channel message.
The lock is **re-entrant for each task**. The scoped send takes the lock, then it calls
the usual channel send, which takes the lock again for itself. The channel send must not
wait for itself.
No client can prevent one leak. When a DM arrives at the companion in the window, the
firmware sends the acknowledgement itself. That ack is a flood with the session scope of
that instant. The only mitigation is that the window is only three round trips long. The
official apps have the same problem.
"""
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".
Use :meth:`held`, an async context manager. A task that already holds the lock enters
again immediately. All other tasks wait until the holder releases the lock.
"""
def __init__(self) -> None:
"""Start with no holder."""
self._lock = asyncio.Lock()
self._owner: asyncio.Task | None = None
self._depth = 0
@property
def locked(self) -> bool:
"""Whether a task holds the lock now."""
return self._lock.locked()
def held_by_me(self) -> bool:
"""Whether the task that calls this method holds the lock."""
try:
task = asyncio.current_task()
except RuntimeError: # no loop runs, so no task 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, or enter again immediately if this task 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"]