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>
67 lines
2.4 KiB
Python
Executable File
67 lines
2.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Measure what one text reply costs the bot's own code, against the live bot.
|
|
|
|
tool/latency.py testbot # 20 x !pomoc, judged at 100 ms p95
|
|
tool/latency.py testbot --text '!ja' --times 50
|
|
tool/latency.py testbot --budget 50
|
|
|
|
Posts for real into the named group, which must be in SELFTEST_GROUPS -- the
|
|
same fail-closed rule as scripts/selftest.sh. The command's cooldown is
|
|
switched off for the run and put back afterwards. Exit 1 when the 95th
|
|
percentile of the bot's own time is over budget; see signal_bot/latency.py
|
|
for what "own" excludes and why.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
from typing import Final
|
|
|
|
import httpx
|
|
from dotenv import load_dotenv
|
|
|
|
from signal_bot.app import HTTP_TIMEOUT_SECONDS, USER_AGENT
|
|
from signal_bot.latency import LatencyError, measure
|
|
from signal_bot.served import OWN_BUDGET_MS
|
|
|
|
EXIT_OVER_BUDGET: Final = 1
|
|
EXIT_REFUSED: Final = 2
|
|
DEFAULT_TIMES: Final = 20
|
|
|
|
|
|
async def run(group: str, text: str, times: int, budget_ms: int, base: str) -> int:
|
|
"""One measured run; the exit code is the verdict."""
|
|
async with httpx.AsyncClient(
|
|
timeout=HTTP_TIMEOUT_SECONDS, headers={"User-Agent": USER_AGENT}
|
|
) as client:
|
|
report = await measure(client, base, group, text, times, budget_ms)
|
|
sys.stdout.write(report.render() + "\n")
|
|
return 0 if report.within_budget else EXIT_OVER_BUDGET
|
|
|
|
|
|
def main() -> int:
|
|
"""Parse arguments and run one measurement."""
|
|
parser = argparse.ArgumentParser(
|
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
|
)
|
|
parser.add_argument("group", help="test group name; must be in SELFTEST_GROUPS")
|
|
parser.add_argument("--text", default="!pomoc", help="the command to type (default: !pomoc)")
|
|
parser.add_argument("--times", type=int, default=DEFAULT_TIMES, help="replies to sample")
|
|
parser.add_argument("--budget", type=int, default=OWN_BUDGET_MS, help="p95 own_ms allowed")
|
|
args = parser.parse_args()
|
|
load_dotenv()
|
|
base = f"http://127.0.0.1:{os.environ.get('BIND_PORT', '8137')}"
|
|
try:
|
|
return asyncio.run(run(args.group, args.text, args.times, args.budget, base))
|
|
except (LatencyError, httpx.HTTPError) as error:
|
|
sys.stderr.write(f"latency: {error}\n")
|
|
return EXIT_REFUSED
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|