Added systemd notify support with ready, stopping, and watchdog notifications.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 13s
CI / Tests (Python 3.13) (push) Successful in 13s
CI / Tests (Python 3.14) (push) Successful in 11s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s

This commit is contained in:
2026-04-10 09:28:07 -04:00
parent 48eae62608
commit e44d7fe09e
+59
View File
@@ -18,10 +18,13 @@ from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
import contextlib
import importlib.resources import importlib.resources
import logging import logging
import os
import shutil import shutil
import signal import signal
import socket
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -33,6 +36,43 @@ from .api.config import Config
from .bot import Owlbot, StartupError from .bot import Owlbot, StartupError
def _sd_notify(state: str) -> None:
"""Send a notification to systemd, if running under a systemd service."""
addr = os.environ.get("NOTIFY_SOCKET")
if not addr:
return
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as sock:
if addr[0] == "@":
addr = "\0" + addr[1:]
sock.sendto(state.encode(), addr)
except OSError:
pass
def _watchdog_interval() -> float | None:
"""Return the watchdog ping interval in seconds, or None if disabled.
systemd sets WATCHDOG_USEC to the total timeout. We ping at half that
interval so there's margin for scheduling jitter.
"""
usec = os.environ.get("WATCHDOG_USEC")
if not usec:
return None
try:
return int(usec) / 1_000_000 / 2
except ValueError:
return None
async def _watchdog_loop(interval: float, stop: asyncio.Event) -> None:
"""Periodically send WATCHDOG=1 until *stop* is set."""
while not stop.is_set():
_sd_notify("WATCHDOG=1")
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(stop.wait(), timeout=interval)
def _init() -> None: def _init() -> None:
"""Scaffold default config into the current directory.""" """Scaffold default config into the current directory."""
config_dest = Path.cwd() / "config.yaml" config_dest = Path.cwd() / "config.yaml"
@@ -221,8 +261,27 @@ Examples:
loop.add_signal_handler(sig, stop_event.set) loop.add_signal_handler(sig, stop_event.set)
async with bot: async with bot:
_sd_notify("READY=1")
wd_interval = _watchdog_interval()
wd_task: asyncio.Task[None] | None = None
if wd_interval is not None:
logger.info(
f"systemd watchdog enabled (pinging every {wd_interval:.1f}s)."
)
wd_task = asyncio.create_task(
_watchdog_loop(wd_interval, stop_event),
name="systemd-watchdog",
)
await stop_event.wait() await stop_event.wait()
logger.info("Received shutdown signal.") logger.info("Received shutdown signal.")
_sd_notify("STOPPING=1")
if wd_task is not None:
wd_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await wd_task
try: try:
uvloop.run(_run()) uvloop.run(_run())