# 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_retry as retry_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 NotReadyError, Runtime, create_runtime from signal_bot.startup_retry import MAX_RETRY_SECONDS, Starter, 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(retry_module.signal, "raise_signal", sent.append) terminate() assert sent == [signal.SIGTERM]