mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 21:27:16 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
241 lines
7.8 KiB
Python
241 lines
7.8 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Coming up while signal-cli is not there yet.
|
|
|
|
The failure being nailed down: on 2026-08-30 the machine rebooted, the bot's
|
|
first HTTP call raised because the container was still starting, the unit
|
|
burned its five restarts in 25 seconds and stayed dead for eight hours.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import signal
|
|
from dataclasses import replace
|
|
from typing import TYPE_CHECKING
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from signal_bot import startup as startup_module
|
|
from signal_bot.bot import build_bot
|
|
from signal_bot.discovery import GroupDirectory
|
|
from signal_bot.liveness import Liveness
|
|
from signal_bot.membership import MembershipError
|
|
from signal_bot.startup import (
|
|
MAX_RETRY_SECONDS,
|
|
NotReadyError,
|
|
Runtime,
|
|
Starter,
|
|
create_runtime,
|
|
terminate,
|
|
)
|
|
from signal_bot.transport_fake import FakeTransport
|
|
from signal_bot.transport_rest import TransportError
|
|
from signal_bot.trust import InviteTrust
|
|
from tests.conftest import SECOND_GROUP, WATCHED_GROUP, mock_client, signal_client
|
|
|
|
if TYPE_CHECKING:
|
|
from signal_bot.config import Config
|
|
from signal_bot.store import Store
|
|
|
|
DEAD = httpx.ConnectError("Server disconnected without sending a response.")
|
|
|
|
|
|
def _offline(request: httpx.Request) -> httpx.Response:
|
|
raise DEAD
|
|
|
|
|
|
def _fake_runtime(config: Config, store: Store) -> Runtime:
|
|
"""A runtime whose loops touch nothing outside the process."""
|
|
client = mock_client(lambda r: httpx.Response(200))
|
|
directory = GroupDirectory(config, client, store, InviteTrust(()))
|
|
liveness = Liveness(config, client)
|
|
bot = build_bot(config, client, store, FakeTransport(), directory, liveness)
|
|
return Runtime(bot=bot)
|
|
|
|
|
|
class Sleeps:
|
|
"""An async sleep that records instead of waiting."""
|
|
|
|
def __init__(self) -> None:
|
|
self.delays: list[float] = []
|
|
|
|
async def __call__(self, seconds: float) -> None:
|
|
self.delays.append(seconds)
|
|
|
|
|
|
async def test_create_runtime_verifies_the_container_mode(config: Config, store: Store) -> None:
|
|
runtime = await create_runtime(config, signal_client(WATCHED_GROUP, SECOND_GROUP), store)
|
|
assert isinstance(runtime, Runtime)
|
|
await runtime.stop()
|
|
|
|
|
|
async def test_create_runtime_refuses_a_polling_container(config: Config, store: Store) -> None:
|
|
with pytest.raises(TransportError):
|
|
await create_runtime(config, signal_client(mode="normal"), store)
|
|
|
|
|
|
async def test_create_runtime_refuses_a_pinned_group_it_cannot_join(
|
|
config: Config, store: Store
|
|
) -> None:
|
|
"""A pending invite looks healthy from outside; a pinned startup must not."""
|
|
pinned = replace(config, group_ids=(WATCHED_GROUP,))
|
|
with pytest.raises(MembershipError, match="not a joined member"):
|
|
await create_runtime(pinned, signal_client(WATCHED_GROUP, member=False), store)
|
|
|
|
|
|
async def test_create_runtime_accepts_an_account_in_no_groups_at_all(
|
|
config: Config, store: Store
|
|
) -> None:
|
|
"""An unpinned bot with no groups is not broken; it is waiting to be added."""
|
|
runtime = await create_runtime(config, signal_client(), store)
|
|
assert runtime.bot.directory.groups == ()
|
|
await runtime.stop()
|
|
|
|
|
|
async def test_an_unreadable_listing_is_not_a_missing_group(config: Config, store: Store) -> None:
|
|
"""The trap this ordering exists for.
|
|
|
|
With the container down, the watched set stays empty. Judging GROUP_IDS
|
|
on that empty set would raise MembershipError -- a verdict nothing can
|
|
retry -- for a bot that is in its group and simply cannot ask yet.
|
|
"""
|
|
pinned = replace(config, group_ids=(WATCHED_GROUP,))
|
|
|
|
def half_up(request: httpx.Request) -> httpx.Response:
|
|
if "/v1/groups" in request.url.path:
|
|
raise DEAD
|
|
return httpx.Response(200, json={"mode": "json-rpc"})
|
|
|
|
with pytest.raises(NotReadyError, match="could not be read"):
|
|
await create_runtime(pinned, mock_client(half_up), store)
|
|
|
|
|
|
async def test_runtime_start_and_stop_cancels_tasks(config: Config, store: Store) -> None:
|
|
runtime = _fake_runtime(config, store)
|
|
runtime.start()
|
|
# listen, the daily report, the group refresh loop, the deafness gate,
|
|
# and the one-shot release announcement.
|
|
assert len(runtime.tasks) == 5
|
|
await asyncio.sleep(0)
|
|
await runtime.stop()
|
|
assert all(task.done() for task in runtime.tasks)
|
|
|
|
|
|
async def test_starter_connects_on_the_first_attempt(config: Config, store: Store) -> None:
|
|
sleeps = Sleeps()
|
|
starter = Starter(
|
|
config,
|
|
mock_client(lambda r: httpx.Response(200)),
|
|
store,
|
|
sleeper=sleeps,
|
|
factory=lambda c, _client, s: _ready(c, s),
|
|
)
|
|
await starter.run()
|
|
assert starter.runtime is not None
|
|
assert sleeps.delays == []
|
|
await starter.runtime.stop()
|
|
|
|
|
|
async def _ready(config: Config, store: Store) -> Runtime:
|
|
return _fake_runtime(config, store)
|
|
|
|
|
|
async def test_starter_survives_a_container_that_is_not_up_yet(
|
|
config: Config, store: Store
|
|
) -> None:
|
|
"""The whole point: a slow container costs attempts, never the process."""
|
|
attempts = {"n": 0}
|
|
|
|
async def flaky(cfg: Config, _client: httpx.AsyncClient, opened: Store) -> Runtime:
|
|
attempts["n"] += 1
|
|
if attempts["n"] < 3:
|
|
raise DEAD
|
|
return _fake_runtime(cfg, opened)
|
|
|
|
stopped: list[bool] = []
|
|
sleeps = Sleeps()
|
|
starter = Starter(
|
|
config,
|
|
mock_client(_offline),
|
|
store,
|
|
sleeper=sleeps,
|
|
stopper=lambda: stopped.append(True),
|
|
factory=flaky,
|
|
)
|
|
# Unhealthy and honest about why, before it ever succeeds.
|
|
assert starter.runtime is None
|
|
assert "not attempted yet" in starter.reason
|
|
|
|
await starter.run()
|
|
|
|
assert attempts["n"] == 3
|
|
assert starter.runtime is not None
|
|
# Recovered with nobody touching it, and without ending the process.
|
|
assert stopped == []
|
|
assert sleeps.delays == [5.0, 10.0]
|
|
await starter.runtime.stop()
|
|
|
|
|
|
async def test_starter_backoff_stops_growing(config: Config, store: Store) -> None:
|
|
sleeps = Sleeps()
|
|
starter = Starter(
|
|
config,
|
|
mock_client(_offline),
|
|
store,
|
|
sleeper=sleeps,
|
|
stopper=lambda: None,
|
|
factory=_always_offline,
|
|
)
|
|
await starter.run(iterations=8)
|
|
assert starter.runtime is None
|
|
assert max(sleeps.delays) == MAX_RETRY_SECONDS
|
|
assert f"attempt {len(sleeps.delays)}" in starter.reason
|
|
assert "ConnectError" in starter.reason
|
|
|
|
|
|
async def _always_offline(config: Config, client: httpx.AsyncClient, store: Store) -> Runtime:
|
|
raise DEAD
|
|
|
|
|
|
async def test_a_container_in_the_wrong_mode_is_fatal(config: Config, store: Store) -> None:
|
|
"""Not an outage: no number of retries turns 'normal' into 'json-rpc'."""
|
|
stopped: list[bool] = []
|
|
sleeps = Sleeps()
|
|
starter = Starter(
|
|
config,
|
|
signal_client(mode="normal"),
|
|
store,
|
|
sleeper=sleeps,
|
|
stopper=lambda: stopped.append(True),
|
|
)
|
|
await starter.run()
|
|
assert starter.runtime is None
|
|
assert stopped == [True]
|
|
# It gave up rather than backing off forever.
|
|
assert sleeps.delays == []
|
|
assert "TransportError" in starter.reason
|
|
|
|
|
|
async def test_a_pinned_group_the_bot_is_not_in_is_fatal(config: Config, store: Store) -> None:
|
|
stopped: list[bool] = []
|
|
pinned = replace(config, group_ids=(WATCHED_GROUP,))
|
|
starter = Starter(
|
|
pinned,
|
|
signal_client(WATCHED_GROUP, member=False),
|
|
store,
|
|
sleeper=Sleeps(),
|
|
stopper=lambda: stopped.append(True),
|
|
)
|
|
await starter.run()
|
|
assert stopped == [True]
|
|
assert "MembershipError" in starter.reason
|
|
|
|
|
|
def test_terminate_asks_uvicorn_to_shut_down(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A background task cannot exit the process; SIGTERM is how it asks."""
|
|
sent: list[int] = []
|
|
monkeypatch.setattr(startup_module.signal, "raise_signal", sent.append)
|
|
terminate()
|
|
assert sent == [signal.SIGTERM]
|