signal-bot/tests/test_user_lock.py
Krzysztof kuhy Rudnicki 4af32f8bd5 Handle frames side by side, and budget the bot's own share of a reply
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>
2026-09-04 21:35:34 +02:00

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"}