signal-bot/tests/test_startup.py
Krzysztof kuhy Rudnicki 72482629d9 fix: a signal-api that is not up yet no longer kills the bot at boot
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:23:42 +02:00

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]