mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:37:03 +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>
124 lines
3.4 KiB
Python
124 lines
3.4 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""One lock per person, none for anybody who is not in the middle of something."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from signal_bot.user_lock import UserLocks
|
|
|
|
|
|
async def _settled() -> None:
|
|
for _ in range(3):
|
|
await asyncio.sleep(0)
|
|
|
|
|
|
async def test_the_same_person_runs_one_thing_at_a_time() -> None:
|
|
locks = UserLocks()
|
|
gate = asyncio.Event()
|
|
order: list[str] = []
|
|
|
|
async def first() -> None:
|
|
async with locks.holding("alice"):
|
|
order.append("first in")
|
|
await gate.wait()
|
|
order.append("first out")
|
|
|
|
async def second() -> None:
|
|
async with locks.holding("alice"):
|
|
order.append("second in")
|
|
|
|
one = asyncio.ensure_future(first())
|
|
two = asyncio.ensure_future(second())
|
|
await _settled()
|
|
assert order == ["first in"]
|
|
assert not two.done()
|
|
gate.set()
|
|
await asyncio.gather(one, two)
|
|
assert order == ["first in", "first out", "second in"]
|
|
|
|
|
|
async def test_different_people_do_not_wait_for_each_other() -> None:
|
|
locks = UserLocks()
|
|
gate = asyncio.Event()
|
|
order: list[str] = []
|
|
|
|
async def slow() -> None:
|
|
async with locks.holding("alice"):
|
|
order.append("alice in")
|
|
await gate.wait()
|
|
|
|
async def quick() -> None:
|
|
async with locks.holding("bob"):
|
|
order.append("bob in")
|
|
|
|
one = asyncio.ensure_future(slow())
|
|
two = asyncio.ensure_future(quick())
|
|
await _settled()
|
|
assert order == ["alice in", "bob in"]
|
|
assert two.done()
|
|
gate.set()
|
|
await one
|
|
|
|
|
|
async def test_a_lock_is_forgotten_once_nobody_wants_it() -> None:
|
|
locks = UserLocks()
|
|
async with locks.holding("alice"):
|
|
assert locks.held == {"alice"}
|
|
assert locks.held == frozenset()
|
|
|
|
|
|
async def test_a_newcomer_waits_for_a_holder_who_was_once_a_waiter() -> None:
|
|
"""Releasing must not drop a lock somebody else is queued on.
|
|
|
|
Otherwise the next person to arrive would get a fresh lock and run
|
|
alongside the one who had just been let through.
|
|
"""
|
|
locks = UserLocks()
|
|
first_gate = asyncio.Event()
|
|
second_gate = asyncio.Event()
|
|
order: list[str] = []
|
|
|
|
async def holding(name: str, gate: asyncio.Event) -> None:
|
|
async with locks.holding("alice"):
|
|
order.append(name)
|
|
await gate.wait()
|
|
|
|
one = asyncio.ensure_future(holding("first", first_gate))
|
|
two = asyncio.ensure_future(holding("second", second_gate))
|
|
await _settled()
|
|
first_gate.set()
|
|
await one
|
|
await _settled()
|
|
assert order == ["first", "second"]
|
|
assert locks.held == {"alice"}
|
|
|
|
three = asyncio.ensure_future(holding("third", asyncio.Event()))
|
|
await _settled()
|
|
assert order == ["first", "second"]
|
|
assert not three.done()
|
|
second_gate.set()
|
|
await two
|
|
await _settled()
|
|
assert order == ["first", "second", "third"]
|
|
three.cancel()
|
|
await asyncio.gather(three, return_exceptions=True)
|
|
assert locks.held == frozenset()
|
|
|
|
|
|
async def test_a_block_that_raises_still_releases_and_forgets() -> None:
|
|
locks = UserLocks()
|
|
|
|
async def breaks() -> None:
|
|
async with locks.holding("alice"):
|
|
msg = "boom"
|
|
raise RuntimeError(msg)
|
|
|
|
with pytest.raises(RuntimeError, match="boom"):
|
|
await breaks()
|
|
assert locks.held == frozenset()
|
|
async with locks.holding("alice"):
|
|
assert locks.held == {"alice"}
|