]> git.ipfire.org Git - thirdparty/knot-resolver.git/commitdiff
controller: dispatcher to inject notify feature to supervisord
authorAleš Mrázek <ales.mrazek@nic.cz>
Sat, 11 Jul 2026 22:22:07 +0000 (00:22 +0200)
committerAleš Mrázek <ales.mrazek@nic.cz>
Sat, 11 Jul 2026 22:22:07 +0000 (00:22 +0200)
python/knot_resolver/controller/notify/notify_dispatcher.py [new file with mode: 0644]

diff --git a/python/knot_resolver/controller/notify/notify_dispatcher.py b/python/knot_resolver/controller/notify/notify_dispatcher.py
new file mode 100644 (file)
index 0000000..5e70164
--- /dev/null
@@ -0,0 +1,242 @@
+# mypy: disable-error-code=import-untyped
+
+from __future__ import annotations
+
+from supervisor.supervisord import Supervisor
+
+from knot_resolver.constants import NOTIFY_SUPPORT
+
+if NOTIFY_SUPPORT:
+    import os
+    import signal
+    import socket
+    import time
+    from functools import partial, wraps
+    from pathlib import Path
+    from typing import TYPE_CHECKING
+
+    from supervisor.events import ProcessStateEvent, ProcessStateStartingEvent, subscribe
+    from supervisor.medusa.asyncore_25 import compact_traceback
+    from supervisor.process import Subprocess
+    from supervisor.states import ProcessStates
+
+    from knot_resolver.controller.config import X_TYPE_NOTIFY, X_TYPE_VAR_NAME
+
+    from .notify_socket import NOTIFY_SOCKET_ENV_VAR, NOTIFY_SOCKET_NAME, init_notify_socket, read_notify_socket
+
+    if TYPE_CHECKING:
+        from collections.abc import Callable
+        from typing import Any, ParamSpec, TypeVar, TypeVarTuple
+
+        P = ParamSpec("P")
+        T = TypeVar("T")
+        U = TypeVar("U")
+        Ts = TypeVarTuple("Ts")
+
+    def chain_call(
+        first: Callable[P, U | tuple[Any, ...]],
+        second: Callable[..., T],
+    ) -> Callable[P, T]:
+        @wraps(first)
+        def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
+            result = first(*args, **kwargs)
+            if isinstance(result, tuple):
+                return second(*result)
+            return second(result)
+
+        return wrapper
+
+    def append_call(
+        first: Callable[P, T],
+        second: Callable[P, object],
+    ) -> Callable[P, T]:
+        @wraps(first)
+        def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
+            result = first(*args, **kwargs)
+            second(*args, **kwargs)
+            return result
+
+        return wrapper
+
+    def is_subprocess_x_type_notify(subprocess: Subprocess) -> bool:
+        env = subprocess.config.environment
+        return bool(env and env.get(X_TYPE_VAR_NAME) == X_TYPE_NOTIFY)
+
+    class NotifyPlugin:
+        def __init__(self) -> None:
+            self.starting_subprocesses: dict[int, Subprocess] = {}
+            self.notify_dispatcher: NotifyDispatcher | None = None
+
+        def track_starting_subprocesses(self, event: ProcessStateEvent) -> None:
+            subprocess: Subprocess = event.process
+
+            if isinstance(event, ProcessStateStartingEvent):
+                # subprocess is starting
+                self.starting_subprocesses[subprocess.pid] = subprocess
+            else:
+                # subprocess is not starting; remove from starting processes
+                self.starting_subprocesses.pop(subprocess.pid, None)
+
+        def supervisord_get_process_map(self, supervisord: Supervisor, mp: dict[int, object]) -> dict[int, object]:
+            if self.notify_dispatcher is None:
+                self.notify_dispatcher = NotifyDispatcher(supervisord, self)
+                supervisord.options.logger.info("injected 'notify' feature into event loop")
+
+            mp[self.notify_dispatcher.fd] = self.notify_dispatcher
+            return mp
+
+    class NotifyDispatcher:
+        def __init__(self, supervisor: Supervisor, plugin: NotifyPlugin):
+            self._supervisor = supervisor
+            self._plugin = plugin
+            self._logger = self._supervisor.options.logger
+
+            sock = init_notify_socket()
+            self.fd = sock.detach()
+            self.closed: bool = False
+
+        def readable(self) -> bool:
+            return True
+
+        def writable(self) -> bool:
+            return False
+
+        def handle_read_event(self) -> None:
+            sock = socket.socket(fileno=self.fd)
+            try:
+                result: tuple[int, bytes] | None = read_notify_socket(sock)
+            finally:
+                sock.detach()
+
+            if result is None:
+                return
+
+            pid, data = result
+
+            try:
+                subprocess = self._plugin.starting_subprocesses[pid]
+            except KeyError:
+                self._logger.warning("ignoring notify message from unregistered subprocess PID=%d", pid)
+                return
+
+            if not is_subprocess_x_type_notify(subprocess):
+                self._logger.warning(
+                    "ignoring notify message '%s' from %s, that is not configured to send it",
+                    data,
+                    subprocess.config.name,
+                )
+                return
+
+            if data.startswith(b"READY=1"):
+                subprocess._assertInState(ProcessStates.STARTING)
+                subprocess.change_state(ProcessStates.RUNNING)
+                self._logger.info(
+                    "%s entered RUNNING state, received READY notify message",
+                    subprocess.config.name,
+                )
+            elif data.startswith(b"STOPPING=1"):
+                self._logger.info(
+                    "%s entered STOPPING state, received STOPPING notify message",
+                    subprocess.config.name,
+                )
+            else:
+                self._logger.warn(
+                    "ignoring unrecognized data on $NOTIFY_SOCKET sent from '%s', PID=%d, data='%b'",
+                    subprocess.config.name,
+                    pid,
+                    data,
+                )
+
+        def handle_write_event(self) -> None:
+            raise RuntimeError("Write events are not supported by NotifyDispatcher.")
+
+        def handle_error(self) -> None:
+            _, ex_class, ex_instance, tb_info = compact_traceback()
+            self._logger.error(
+                "uncaptured exception, closing notify socket %r (%s: %s)\n%s)",
+                self,
+                ex_class.__name__,
+                ex_instance,
+                tb_info,
+            )
+            self.close()
+
+        def close(self) -> None:
+            if not self.closed:
+                os.close(self.fd)
+                self.closed = True
+
+        def flush(self) -> None:
+            return
+
+    def _subprocess_transition(subprocess: Subprocess) -> Subprocess:
+        logger = subprocess.config.options.logger
+
+        if not is_subprocess_x_type_notify(subprocess):
+            return subprocess
+
+        if (
+            subprocess.state == ProcessStates.STARTING
+            and time.time() - subprocess.laststart > subprocess.config.startsecs
+        ):
+            # STARTING -> STOPPING if the process has not sent ready notification
+            # within proc.config.startsecs
+            logger.warning(
+                f"process '{subprocess.config.name}' did not send ready notification"
+                f" within {subprocess.config.startsecs} secs, killing"
+            )
+            subprocess.kill(signal.SIGKILL)
+            subprocess.x_notifykilled = True  # used in finish() function to set to FATAL state
+            subprocess.laststart = time.time() + 1  # prevent immediate state transition to RUNNING from happening
+
+        return subprocess
+
+    def _subprocess_finish_tail(subprocess: Subprocess, pid: int, sts: object) -> tuple[Subprocess, int, object]:
+        if getattr(subprocess, "x_notifykilled", False):
+            # we want FATAL, not STOPPED state after timeout waiting for startup notification
+            # why? because it's likely not gonna help to try starting the process up again if
+            # it failed so early
+            subprocess.change_state(ProcessStates.FATAL)
+
+            # clear the marker value
+            del subprocess.x_notifykilled
+        return subprocess, pid, sts
+
+    def _subprocess_spawn_as_child_add_notify_socket_env_var(
+        subprocess: Subprocess, *args: *Ts
+    ) -> tuple[Subprocess, *Ts]:
+        if is_subprocess_x_type_notify(subprocess):
+            subprocess.config.environment[NOTIFY_SOCKET_ENV_VAR] = str(Path.cwd() / NOTIFY_SOCKET_NAME)
+        return (subprocess, *args)
+
+    def notify_monkey_patch(supervisord: Supervisor) -> None:
+        notify_plugin = NotifyPlugin()
+
+        # append notify handler to event loop
+        supervisord.get_process_map = chain_call(
+            supervisord.get_process_map,
+            partial(notify_plugin.supervisord_get_process_map, supervisord),
+        )
+
+        # chain timeout handler to transition method
+        Subprocess.transition = chain_call(_subprocess_transition, Subprocess.transition)
+        Subprocess.finish = append_call(Subprocess.finish, _subprocess_finish_tail)
+
+        # add environment variable $NOTIFY_SOCKET to starting processes
+        Subprocess._spawn_as_child = chain_call(
+            _subprocess_spawn_as_child_add_notify_socket_env_var,
+            Subprocess._spawn_as_child,
+        )
+
+        # track starting subprocesses
+        subscribe(ProcessStateEvent, notify_plugin.track_starting_subprocesses)
+
+
+def inject(supervisord: Supervisor, **_config: object) -> None:
+    logger = supervisord.options.logger
+
+    if NOTIFY_SUPPORT:
+        logger.info("The 'notify' feature is supported on this system. Patching supervisord to support it ...")
+        notify_monkey_patch(supervisord)
+    else:
+        logger.info("The 'notify' feature is not supported on this system. It is available only on Linux systems.")