mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +01:00
Bot.listen awaited every frame to completion, so one Commons fetch or one attachment upload held up every text reply behind it (DOCS-latency.md). Each frame now runs in its own task, never more than LIMIT at once; the reader waits for a slot before spawning, so a burst applies back-pressure at the websocket instead of fanning out upstream. One person's commands stay serialised (user_lock.py): two quick !kot would otherwise both pass the cooldown gate before either recorded a use. Every served command reports its timings split into the bot's own time and everybody else's (served.py); /_selftest carries that block and tool/latency.py holds own_ms to 100 ms at p95, with the command's cooldown switched off for the run and put back afterwards. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
51 lines
1.8 KiB
Python
51 lines
1.8 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""One person's commands run one at a time; everybody else's interleave.
|
|
|
|
Handling frames concurrently (``inflight.py``) opens a race the sequential
|
|
loop never had: two ``!kot`` from the same person in the same second would
|
|
both pass the cooldown gate before either had recorded a use, and both be
|
|
served. Serialising per person closes it without serialising the group --
|
|
the gate and the write it guards happen under that person's lock, so the
|
|
second attempt sees the first one's use and is refused, exactly as before.
|
|
|
|
Locks are dropped when nobody holds or wants them, so the table cannot grow
|
|
with every person who ever typed a command.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncIterator
|
|
|
|
|
|
class UserLocks:
|
|
"""A lock per person, created on first use and forgotten when idle."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Start with nobody waiting on anything."""
|
|
self._locks: dict[str, asyncio.Lock] = {}
|
|
self._waiting: dict[str, int] = {}
|
|
|
|
@property
|
|
def held(self) -> frozenset[str]:
|
|
"""Who has a lock right now -- held or queued for. For tests."""
|
|
return frozenset(self._locks)
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def holding(self, user: str) -> AsyncIterator[None]:
|
|
"""Run the block as the only thing this person has going."""
|
|
lock = self._locks.setdefault(user, asyncio.Lock())
|
|
self._waiting[user] = self._waiting.get(user, 0) + 1
|
|
try:
|
|
async with lock:
|
|
yield
|
|
finally:
|
|
self._waiting[user] -= 1
|
|
if self._waiting[user] == 0:
|
|
del self._waiting[user]
|
|
del self._locks[user]
|