Improved shutdown cleanup ordering.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 23s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 6s

This commit is contained in:
2026-06-30 09:08:35 -04:00
parent 73c103c486
commit 29edbdba18
2 changed files with 151 additions and 5 deletions
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Any, cast
import discord
import pytest
from discord import app_commands
from discord.ext import commands
from simcord import run
from crabstero import metrics
@@ -40,6 +41,37 @@ def _counter_value(counter: Any, **labels: str) -> float:
return float(counter.labels(**labels)._value.get())
class _LifecycleResource:
"""Resource double that records lifecycle cleanup calls."""
def __init__(
self,
name: str,
events: list[str],
*,
fail_once: bool = False,
) -> None:
"""Store the event name and shared event log."""
self._name = name
self._events = events
self._fail_once = fail_once
async def _record(self) -> None:
"""Record a cleanup call and optionally fail once."""
self._events.append(self._name)
if self._fail_once:
self._fail_once = False
raise RuntimeError(f"{self._name} cleanup failed")
async def close(self) -> None:
"""Record a close call."""
await self._record()
async def stop(self) -> None:
"""Record a stop call."""
await self._record()
class TestSetupHook:
"""Bot startup wires Discord cogs and slash commands under Simcord."""
@@ -88,6 +120,88 @@ class TestSetupHook:
await bot.close()
class TestClose:
"""Bot shutdown ordering."""
async def test_closes_discord_before_local_resources(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Discord is closed before local resources are released."""
events: list[str] = []
bot = Crabstero(str(tmp_path / "close-order.db"))
original_close = commands.Bot.close
async def close_discord(self: commands.Bot) -> None:
events.append("discord")
await original_close(self)
monkeypatch.setattr(commands.Bot, "close", close_discord)
cast("Any", bot).ingest_cache = _LifecycleResource("cache", events)
cast("Any", bot)._metrics_server = _LifecycleResource("metrics", events)
cast("Any", bot)._db = _LifecycleResource("db", events)
await bot.close()
assert events == ["discord", "cache", "metrics", "db"]
async def test_local_resources_close_when_discord_close_fails(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Local resources are released even if Discord close raises."""
events: list[str] = []
bot = Crabstero(str(tmp_path / "close-failure.db"))
async def close_discord(_self: commands.Bot) -> None:
events.append("discord")
raise RuntimeError("discord close failed")
monkeypatch.setattr(commands.Bot, "close", close_discord)
cast("Any", bot).ingest_cache = _LifecycleResource("cache", events)
cast("Any", bot)._metrics_server = _LifecycleResource("metrics", events)
cast("Any", bot)._db = _LifecycleResource("db", events)
with pytest.raises(RuntimeError, match="discord close failed"):
await bot.close()
assert events == ["discord", "cache", "metrics", "db"]
async def test_local_resource_cleanup_is_retried_after_local_failure(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Local cleanup can be retried after Discord has already closed."""
events: list[str] = []
bot = Crabstero(str(tmp_path / "close-retry.db"))
original_close = commands.Bot.close
async def close_discord(self: commands.Bot) -> None:
events.append("discord")
await original_close(self)
monkeypatch.setattr(commands.Bot, "close", close_discord)
cast("Any", bot).ingest_cache = _LifecycleResource(
"cache",
events,
fail_once=True,
)
cast("Any", bot)._metrics_server = _LifecycleResource("metrics", events)
cast("Any", bot)._db = _LifecycleResource("db", events)
with pytest.raises(RuntimeError, match="cache cleanup failed"):
await bot.close()
assert events == ["discord", "cache", "metrics", "db"]
await bot.close()
assert events == ["discord", "cache", "metrics", "db", "cache"]
class TestMetrics:
"""Bot-level metrics are incremented by lifecycle error paths."""