From 5317a829141f833a886e272af2b03c166b5fafab Mon Sep 17 00:00:00 2001 From: Farhan Date: Fri, 11 Sep 2026 21:15:46 +0500 Subject: [PATCH] Hold the dev backend listen socket in the granian supervisor On Linux granian 2.8 hands each worker a socket spec and the worker binds with SO_REUSEPORT only after it has imported and compiled the app. The file-watch reload stops the old worker before spawning the new one, so nothing listens for the whole boot and every request in that window is refused. Granian 2.7.4, the minimum we pin, bound the socket in the supervisor. Override `_init_shared_socket` in dev to bind once in the supervisor and pass the inheritable descriptor to workers, the path granian already uses on macOS and Windows. Requests sent during a reload now wait in the accept backlog and are answered by the new worker. Measured on a 60-page app in full dev mode, probing /ping every 50 ms across two edits: 21-22 refused probes between +0.20 s and +1.26 s before, 0 refused and 22-24 held with a 1.3 s maximum wait after. Single-worker throughput is unchanged (4071 vs 4017 keep-alive req/s). Claude-Session: https://claude.ai/code/session_01CtZAjq1esmYw7iRHd5TAbG --- news/+dev-reload-hold-socket.bugfix.md | 1 + reflex/utils/exec.py | 21 ++++++++++- tests/units/utils/test_exec.py | 50 ++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 news/+dev-reload-hold-socket.bugfix.md diff --git a/news/+dev-reload-hold-socket.bugfix.md b/news/+dev-reload-hold-socket.bugfix.md new file mode 100644 index 00000000000..3696def32ce --- /dev/null +++ b/news/+dev-reload-hold-socket.bugfix.md @@ -0,0 +1 @@ +Keep the development backend port open while hot reload restarts the worker, so requests made during a reload wait for the new worker instead of being refused. diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index c93b11949c3..766d66f7710 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -10,6 +10,7 @@ import os import platform import re +import socket import subprocess import sys from collections.abc import Mapping, Sequence @@ -669,13 +670,31 @@ def run_granian_backend(host: str, port: int, loglevel: LogLevel): from granian.constants import Interfaces from granian.log import LogLevels + from granian.net import SocketSpec # pyright: ignore[reportPrivateImportUsage] from granian.server import Server as Granian from reflex_base.environment import _load_dotenv_from_env + class ParentBoundGranian(Granian): # pyright: ignore[reportGeneralTypeIssues] + """Granian server that binds the listen socket in the supervisor. + + On Linux each worker otherwise binds only after loading the app, so + requests during a reload are refused. With the supervisor holding the + socket they wait in the accept backlog for the new worker. + """ + + def _init_shared_socket(self): + self._ssp = SocketSpec(self.bind_addr, self.bind_port, self.backlog) + self._shd = self._ssp.build() + self._sfd = self._shd.get_fd() + self._ssp = None + sock = socket.socket(fileno=self._sfd) + sock.set_inheritable(True) + self._sso = sock + reset_dev_backend_reload_marker() environment.REFLEX_DEV_BACKEND_RELOAD_ACTIVE.set(True) - granian_app = Granian( + granian_app = ParentBoundGranian( target=get_app_instance_from_file(), factory=True, address=host, diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index 5dfc677c094..88592da53c0 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_exec.py @@ -1,6 +1,7 @@ """Tests for development backend launchers in ``reflex.utils.exec``.""" import os +import socket from pathlib import Path import pytest @@ -80,6 +81,55 @@ def serve(self): assert seen["value"] == "True" +def test_run_granian_backend_binds_listen_socket_in_supervisor( + tmp_path: Path, mocker: MockerFixture +): + """The dev server holds the listen socket so requests queue across worker restarts.""" + mocker.patch.object( + exec_utils, + "get_dev_backend_reload_marker", + return_value=tmp_path / exec_utils.DEV_BACKEND_RELOAD_MARKER, + ) + mocker.patch.object( + exec_utils, "get_app_instance_from_file", return_value="app:app" + ) + mocker.patch.object(exec_utils, "get_reload_paths", return_value=[]) + granian_server = pytest.importorskip("granian.server") + servers: list[object] = [] + + class FakeGranian: + def __init__(self, *_args, **_kwargs): + self.bind_addr = "127.0.0.1" + self.bind_port = 0 + self.backlog = 16 + servers.append(self) + + def on_reload(self, _callback): + pass + + def serve(self): + pass + + mocker.patch.object(granian_server, "Server", FakeGranian) + + exec_utils.run_granian_backend( + host="127.0.0.1", port=0, loglevel=exec_utils.LogLevel.INFO + ) + + (server,) = servers + server._init_shared_socket() # pyright: ignore[reportAttributeAccessIssue] + listener: socket.socket = server._sso # pyright: ignore[reportAttributeAccessIssue] + try: + assert listener.get_inheritable() + # Once a worker calls listen the supervisor's descriptor keeps the + # socket listening, so connections queue while no worker accepts. + listener.listen() + with socket.create_connection(listener.getsockname(), timeout=1): + pass + finally: + listener.close() + + def test_with_development_condition_sets_node_and_bun_options(): """Both runtime option vars gain the development condition flag.""" env = exec_utils._with_development_condition({})