From 9a5b3a0029ccf9a45b4d9587f15f0946743097a3 Mon Sep 17 00:00:00 2001 From: Dimosthenis Schizas Date: Mon, 5 Oct 2026 17:45:24 +0300 Subject: [PATCH 1/2] fix(bot): close the Discord session on SIGTERM Hypercorn installed its own SIGTERM/SIGINT handlers when the webhook server started, replacing the ones py-cord sets in Client.run. On SIGTERM only the webhook server stopped and the bot kept its gateway session until SIGKILL. Passing a shutdown trigger keeps py-cord's handlers, so the bot closes and the process exits right away. Also call bot.run directly instead of wrapping its result in create_task. --- src/__main__.py | 2 +- src/webhooks/server.py | 13 ++++++++++--- tests/src/webhooks/test_server_serve.py | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 tests/src/webhooks/test_server_serve.py diff --git a/src/__main__.py b/src/__main__.py index 96e27b4e..e7047e09 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -25,4 +25,4 @@ logger.debug(f"Starting webhook server listening on port: {settings.WEBHOOK_PORT}") bot.loop.create_task(serve()) logger.debug(f"Starting bot with token: {settings.bot.TOKEN}") - bot.loop.create_task(bot.run(settings.bot.TOKEN)) + bot.run(settings.bot.TOKEN) diff --git a/src/webhooks/server.py b/src/webhooks/server.py index 7dfd78e6..39c20f6e 100644 --- a/src/webhooks/server.py +++ b/src/webhooks/server.py @@ -1,7 +1,8 @@ +import asyncio import hashlib import hmac -import logging import json +import logging from typing import Any, Dict from fastapi import FastAPI, HTTPException, Request @@ -102,5 +103,11 @@ async def webhook_handler(request: Request) -> Dict[str, Any]: config.bind = [f"0.0.0.0:{settings.WEBHOOK_PORT}"] -async def serve(): - await hypercorn_serve(app, config) +async def _never_shutdown() -> None: + await asyncio.Event().wait() + + +async def serve() -> None: + """Run the webhook server until the event loop is stopped.""" + # Passing a trigger stops hypercorn from replacing py-cord's SIGTERM/SIGINT handlers, which close the bot. + await hypercorn_serve(app, config, shutdown_trigger=_never_shutdown) diff --git a/tests/src/webhooks/test_server_serve.py b/tests/src/webhooks/test_server_serve.py new file mode 100644 index 00000000..7f3d6791 --- /dev/null +++ b/tests/src/webhooks/test_server_serve.py @@ -0,0 +1,19 @@ +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from src.webhooks import server + + +@pytest.mark.asyncio +async def test_serve_passes_shutdown_trigger_so_hypercorn_keeps_off_signals(): + with patch.object(server, "hypercorn_serve", new=AsyncMock()) as mock_serve: + await server.serve() + + trigger = mock_serve.await_args.kwargs.get("shutdown_trigger") + assert trigger is not None + waiter = asyncio.ensure_future(trigger()) + await asyncio.sleep(0) + assert not waiter.done() + waiter.cancel() From 5c61a57a062f65a101b52fe1516d7745bfa9302f Mon Sep 17 00:00:00 2001 From: Dimosthenis Schizas Date: Tue, 6 Oct 2026 12:31:02 +0300 Subject: [PATCH 2/2] fix(bot): drain in-flight webhooks before closing the bot on shutdown SIGTERM and SIGINT now set one shutdown event. hypercorn uses it as its shutdown trigger: it stops accepting requests and lets in-flight webhooks finish (up to 50s), then the bot closes its Discord session. Before, py-cord's handler stopped the loop and cancelled in-flight webhooks. The 50s timeout is an upper bound: an idle server stops at once, so the bot stays connected only as long as the slowest in-flight webhook. Also stops logging the bot token at DEBUG, with a test that fails if a log call includes a token. --- src/__main__.py | 1 - src/webhooks/server.py | 20 ++++++++----- tests/src/test_no_token_in_logs.py | 24 +++++++++++++++ tests/src/webhooks/test_server_serve.py | 40 ++++++++++++++++++------- 4 files changed, 67 insertions(+), 18 deletions(-) create mode 100644 tests/src/test_no_token_in_logs.py diff --git a/src/__main__.py b/src/__main__.py index e7047e09..a1b439dc 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -24,5 +24,4 @@ logger.info("Starting bot & webhook server") logger.debug(f"Starting webhook server listening on port: {settings.WEBHOOK_PORT}") bot.loop.create_task(serve()) - logger.debug(f"Starting bot with token: {settings.bot.TOKEN}") bot.run(settings.bot.TOKEN) diff --git a/src/webhooks/server.py b/src/webhooks/server.py index 39c20f6e..7aca37b0 100644 --- a/src/webhooks/server.py +++ b/src/webhooks/server.py @@ -3,6 +3,7 @@ import hmac import json import logging +import signal from typing import Any, Dict from fastapi import FastAPI, HTTPException, Request @@ -101,13 +102,18 @@ async def webhook_handler(request: Request) -> Dict[str, Any]: config = HypercornConfig() config.bind = [f"0.0.0.0:{settings.WEBHOOK_PORT}"] - - -async def _never_shutdown() -> None: - await asyncio.Event().wait() +# Upper bound only: an idle server stops at once. Keep it below the pod's termination grace period. +config.graceful_timeout = 50 async def serve() -> None: - """Run the webhook server until the event loop is stopped.""" - # Passing a trigger stops hypercorn from replacing py-cord's SIGTERM/SIGINT handlers, which close the bot. - await hypercorn_serve(app, config, shutdown_trigger=_never_shutdown) + """Run the webhook server until SIGTERM/SIGINT, drain in-flight webhooks, then close the bot.""" + shutdown = asyncio.Event() + loop = asyncio.get_running_loop() + # Replaces py-cord's handlers, which stop the loop and cancel in-flight webhooks. + # The bot stays connected during the drain, because the handlers need it. + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(sig, shutdown.set) + + await hypercorn_serve(app, config, shutdown_trigger=shutdown.wait) + await bot.close() diff --git a/tests/src/test_no_token_in_logs.py b/tests/src/test_no_token_in_logs.py new file mode 100644 index 00000000..9f41fdbe --- /dev/null +++ b/tests/src/test_no_token_in_logs.py @@ -0,0 +1,24 @@ +import ast +from pathlib import Path + +SRC = Path(__file__).resolve().parents[2] / "src" +LOG_METHODS = {"debug", "info", "warning", "error", "exception", "critical", "log"} + + +def _log_calls_reading_a_token() -> list[str]: + found = [] + for path in sorted(SRC.rglob("*.py")): + for node in ast.walk(ast.parse(path.read_text())): + if not ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in LOG_METHODS + ): + continue + if any(isinstance(n, ast.Attribute) and n.attr == "TOKEN" for n in ast.walk(node)): + found.append(f"{path.relative_to(SRC.parent)}:{node.lineno}") + return found + + +def test_log_calls_do_not_include_a_token(): + assert _log_calls_reading_a_token() == [] diff --git a/tests/src/webhooks/test_server_serve.py b/tests/src/webhooks/test_server_serve.py index 7f3d6791..c03598bc 100644 --- a/tests/src/webhooks/test_server_serve.py +++ b/tests/src/webhooks/test_server_serve.py @@ -1,4 +1,5 @@ import asyncio +import signal from unittest.mock import AsyncMock, patch import pytest @@ -7,13 +8,32 @@ @pytest.mark.asyncio -async def test_serve_passes_shutdown_trigger_so_hypercorn_keeps_off_signals(): - with patch.object(server, "hypercorn_serve", new=AsyncMock()) as mock_serve: - await server.serve() - - trigger = mock_serve.await_args.kwargs.get("shutdown_trigger") - assert trigger is not None - waiter = asyncio.ensure_future(trigger()) - await asyncio.sleep(0) - assert not waiter.done() - waiter.cancel() +@pytest.mark.parametrize("sig", [signal.SIGTERM, signal.SIGINT]) +async def test_signal_drains_webhooks_then_closes_the_bot(monkeypatch, sig): + signal_handlers = {} + monkeypatch.setattr( + asyncio.get_running_loop(), "add_signal_handler", lambda s, callback: signal_handlers.__setitem__(s, callback) + ) + calls = [] + + async def fake_hypercorn_serve(app, config, shutdown_trigger): + await shutdown_trigger() + calls.append("drained") + + close = AsyncMock(side_effect=lambda: calls.append("bot closed")) + with ( + patch.object(server, "hypercorn_serve", new=fake_hypercorn_serve), + patch.object(server.bot, "close", new=close), + ): + serving = asyncio.create_task(server.serve()) + await asyncio.sleep(0) + assert not serving.done() + + signal_handlers[sig]() + await asyncio.wait_for(serving, timeout=1) + + assert calls == ["drained", "bot closed"] + + +def test_webhooks_get_up_to_50_seconds_to_drain(): + assert server.config.graceful_timeout == 50