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({})