From 861b82044eea994e9d77926ab573714a7c4a6124 Mon Sep 17 00:00:00 2001 From: flcrom <161213233+flcrom@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:21:32 +0530 Subject: [PATCH 01/19] Stop the frontend tree when headless reflex run exits --- news/6980.bugfix.md | 1 + reflex/reflex.py | 56 +++++++++++---- reflex/utils/exec.py | 3 + reflex/utils/processes.py | 60 +++++++++++++++- tests/units/test_reflex.py | 106 ++++++++++++++++++++++++++++ tests/units/utils/test_processes.py | 66 +++++++++++++++++ 6 files changed, 274 insertions(+), 18 deletions(-) create mode 100644 news/6980.bugfix.md diff --git a/news/6980.bugfix.md b/news/6980.bugfix.md new file mode 100644 index 00000000000..af182526a7a --- /dev/null +++ b/news/6980.bugfix.md @@ -0,0 +1 @@ +`reflex run` now stops its development frontend process group on shutdown, avoiding a hang after SIGTERM or SIGINT without a TTY. diff --git a/reflex/reflex.py b/reflex/reflex.py index a72cbec40b3..fb0fc793073 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -3,10 +3,14 @@ from __future__ import annotations import logging +import signal +import sys +import threading from collections.abc import Callable from importlib import import_module from importlib.util import find_spec from pathlib import Path +from types import FrameType from typing import TYPE_CHECKING, Any, NoReturn, cast import click @@ -436,21 +440,43 @@ def _run_dev( running_mode.has_backend(), )) - # Start the frontend and backend. - with processes.run_concurrently_context(*commands): - # In dev mode, run the backend on the main thread. - if running_mode.has_backend() and backend_port: - exec.run_backend( - backend_host, - int(backend_port), - config.loglevel.subprocess_level(), - running_mode.has_frontend(), - ) - # The windows uvicorn bug workaround - # https://github.com/reflex-dev/reflex/issues/2335 - if constants.IS_WINDOWS and exec.frontend_process: - # Sends SIGTERM in windows - exec.kill(exec.frontend_process.pid) + frontend_only = running_mode.has_frontend() and not running_mode.has_backend() + install_handlers = ( + frontend_only + and sys.platform != "win32" + and threading.current_thread() is threading.main_thread() + ) + old_handlers = {} + if install_handlers: + + def stop_frontend(signum: int, frame: FrameType | None) -> None: + raise SystemExit(0) + + old_handlers = { + sig: signal.getsignal(sig) for sig in (signal.SIGTERM, signal.SIGINT) + } + for sig in old_handlers: + signal.signal(sig, stop_frontend) + try: + with processes.run_concurrently_context( + *commands, interrupt_on_failure=not frontend_only + ) as tasks: + if frontend_only and tasks: + tasks[0].result() + elif running_mode.has_backend() and backend_port: + exec.run_backend( + backend_host, + int(backend_port), + config.loglevel.subprocess_level(), + running_mode.has_frontend(), + ) + # The windows uvicorn bug workaround + # https://github.com/reflex-dev/reflex/issues/2335 + if constants.IS_WINDOWS and exec.frontend_process: + exec.kill(exec.frontend_process.pid) + finally: + for sig, handler in old_handlers.items(): + signal.signal(sig, handler) def _run_preview(running_mode: constants.RunningMode, port: int, host: str): diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index d84f469bc52..99bf680ffae 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -291,6 +291,8 @@ def run_process_and_launch_url( } if constants.IS_WINDOWS and backend_present: kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # pyright: ignore [reportAttributeAccessIssue] + elif not constants.IS_WINDOWS: + kwargs["start_new_session"] = True process = processes.new_process( run_command, cwd=get_web_dir(), @@ -299,6 +301,7 @@ def run_process_and_launch_url( ) global frontend_process frontend_process = process + processes.track_frontend(process) if process.stdout: for line in processes.stream_logs("Starting frontend", process): new_content, new_hash = get_package_json_and_hash(json_file_path) diff --git a/reflex/utils/processes.py b/reflex/utils/processes.py index e41303a2fad..adf2765185f 100644 --- a/reflex/utils/processes.py +++ b/reflex/utils/processes.py @@ -11,6 +11,7 @@ import subprocess import sys import threading +import time from _thread import interrupt_main from collections.abc import Callable, Generator, Sequence from concurrent import futures @@ -264,6 +265,43 @@ def subprocess_p_open(args: subprocess._CMD, **kwargs): return fn(non_empty_args, **kwargs) +# The frontend is the only child enrolled in run teardown. Each worker gets +# its own context, so unrelated calls to new_process are never signaled. +_run_children = threading.local() + + +def _stop_frontend(process: subprocess.Popen[str], timeout: float = 5.0) -> None: + """Stop the frontend and its POSIX process group when the run ends.""" + if sys.platform == "win32": + if process.poll() is None: + process.terminate() + return + # The frontend starts in its own session, so its PID is its group ID. + # The root may have exited before teardown; descendants can still hold + # that group open. Reuse of the numeric ID is a narrow residual race once + # the root has been reaped by the log reader. + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + return + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + os.killpg(process.pid, 0) + except ProcessLookupError: + return + time.sleep(0.05) + with contextlib.suppress(ProcessLookupError): + os.killpg(process.pid, signal.SIGKILL) + + +def track_frontend(process: subprocess.Popen[str]) -> None: + """Enroll a spawned frontend in its run context when one is active.""" + children = getattr(_run_children, "frontends", None) + if children is not None: + children.append(process) + + def _interrupt_main_thread(): """Deliver a SIGINT to the main thread to break it out of a blocking call. @@ -283,6 +321,7 @@ def _interrupt_main_thread(): @contextlib.contextmanager def run_concurrently_context( *fns: Callable[..., Any] | tuple[Callable[..., Any], ...], + interrupt_on_failure: bool = True, ) -> Generator[list[futures.Future], None, None]: """Run functions concurrently in a thread pool. @@ -293,6 +332,7 @@ def run_concurrently_context( Args: *fns: The functions to run. + interrupt_on_failure: Whether to wake the main thread if a task fails. Yields: The futures for the functions. @@ -343,12 +383,22 @@ def raise_first_failure(tasks: list[futures.Future]): # Run the functions concurrently. executor = None + frontends: list[subprocess.Popen[str]] = [] + + def run_with_children(fn: tuple[Callable[..., Any], ...]) -> Any: + _run_children.frontends = frontends + try: + return fn[0](*fn[1:]) + finally: + _run_children.frontends = None + try: executor = futures.ThreadPoolExecutor(max_workers=len(fns)) # Submit the tasks. - tasks = [executor.submit(*fn) for fn in fns] - for task in tasks: - task.add_done_callback(wake_main_thread) + tasks = [executor.submit(run_with_children, fn) for fn in fns] + if interrupt_on_failure: + for task in tasks: + task.add_done_callback(wake_main_thread) try: try: @@ -364,6 +414,8 @@ def raise_first_failure(tasks: list[futures.Future]): # does not wait). with interrupt_lock: in_body = False + for child in frontends: + _stop_frontend(child) # Get the results in the order completed to check any exceptions. for task in futures.as_completed(tasks): @@ -375,6 +427,8 @@ def raise_first_failure(tasks: list[futures.Future]): raise_first_failure(tasks) raise finally: + for child in frontends: + _stop_frontend(child) # Shutdown the executor if executor: executor.shutdown(wait=False) diff --git a/tests/units/test_reflex.py b/tests/units/test_reflex.py index 1e76ad1a2a1..30f9dd73993 100644 --- a/tests/units/test_reflex.py +++ b/tests/units/test_reflex.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import json import os import subprocess @@ -431,3 +432,108 @@ def test_init_records_version_check_after_frontend_setup( reflex._init("demo") assert events == ["frontend", "version"] + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX signals") +@pytest.mark.parametrize("mode", ["frontend", "fullstack"]) +@pytest.mark.parametrize("sig", ["SIGTERM", "SIGINT"]) +def test_no_tty_run_stops_frontend(tmp_path, mode, sig): + """Headless full-stack and frontend-only runs stop their process tree.""" + import signal + import time + + import psutil + + from reflex.testing import DEFAULT_TIMEOUT + + pids = tmp_path / "children" + driver = tmp_path / "driver.py" + driver.write_text( + """import signal, subprocess, sys, time, types +from reflex.utils import build, exec as exec_mod, processes, telemetry + +def frontend(*args): + code = "import subprocess,sys,time\\n" + \ + "p=subprocess.Popen([sys.executable,'-c','import time;time.sleep(60)'])\\n" + \ + "print(p.pid,flush=True);time.sleep(60)\\n" + p = subprocess.Popen([sys.executable,"-c",code],start_new_session=True, + stdout=subprocess.PIPE,text=True) + grandchild = int(p.stdout.readline()) + processes.track_frontend(p) + open(PIDS,"w").write(f"{p.pid} {grandchild}") + p.wait() + +def backend(*args): + def stop(sig,frame): raise SystemExit(0) + signal.signal(signal.SIGTERM,stop) + signal.signal(signal.SIGINT,stop) + while True: time.sleep(1) + +exec_mod.run_frontend=frontend +exec_mod.run_backend=backend +telemetry.send=lambda *a,**k:None +build.setup_frontend=lambda *a,**k:None +import reflex.reflex as rx +rx._compile_app=lambda:None +rx.get_config=lambda:types.SimpleNamespace(_set_persistent=lambda **k:None, + loglevel=types.SimpleNamespace(subprocess_level=lambda:None)) +from reflex_base import constants +rx._run_dev(MODE,3000,PORT,"127.0.0.1") +""" + .replace("PIDS", repr(str(pids))) + .replace( + "MODE", + "constants.RunningMode.FRONTEND_ONLY" + if mode == "frontend" + else "constants.RunningMode.FULLSTACK", + ) + .replace("PORT", "None" if mode == "frontend" else "8000") + ) + launcher = subprocess.Popen( + [sys.executable, str(driver)], + cwd=tmp_path, + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + child = grandchild = None + try: + deadline = time.monotonic() + DEFAULT_TIMEOUT + while not pids.exists() and time.monotonic() < deadline: + if launcher.poll() is not None: + pytest.fail(f"launcher exited early: {launcher.returncode}") + time.sleep(0.01) + assert pids.exists(), "frontend did not start" + child, grandchild = map(int, pids.read_text().split()) + os.kill(launcher.pid, getattr(signal, sig)) + try: + returncode = launcher.wait(timeout=DEFAULT_TIMEOUT) + except subprocess.TimeoutExpired: + pytest.fail("no-TTY launcher hung after signal") + assert returncode == 0 + for pid in (child, grandchild): + deadline = time.monotonic() + DEFAULT_TIMEOUT + while time.monotonic() < deadline: + try: + if psutil.Process(pid).status() in ( + psutil.STATUS_ZOMBIE, + psutil.STATUS_DEAD, + ): + break + except psutil.NoSuchProcess: + break + time.sleep(0.05) + else: + pytest.fail(f"frontend descendant {pid} survived signal") + finally: + if launcher.poll() is None: + os.killpg(launcher.pid, signal.SIGKILL) + launcher.wait(timeout=DEFAULT_TIMEOUT) + if child is not None: + with contextlib.suppress(ProcessLookupError): + os.killpg(child, signal.SIGKILL) + for pid in (child, grandchild): + if pid is not None: + with contextlib.suppress(ProcessLookupError): + os.kill(pid, signal.SIGKILL) diff --git a/tests/units/utils/test_processes.py b/tests/units/utils/test_processes.py index e16e321051b..1325a2a842f 100644 --- a/tests/units/utils/test_processes.py +++ b/tests/units/utils/test_processes.py @@ -1,9 +1,12 @@ """Test process utilities.""" +import contextlib import logging +import os import signal import socket import subprocess +import sys import threading import time from contextlib import closing @@ -416,3 +419,66 @@ def test_stream_logs_still_fails_on_a_real_error_exit(caplog): list(stream_logs("Starting frontend", process)) assert any("failed with exit code 1" in r.getMessage() for r in caplog.records) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups") +def test_frontend_group_ends_with_run_context(tmp_path): + """The opted-in frontend and its child are both stopped at context exit.""" + grandchild_pid = tmp_path / "grandchild.pid" + ready = threading.Event() + root: list[subprocess.Popen[str]] = [] + + def frontend(): + code = ( + "import subprocess,sys,time\n" + "p=subprocess.Popen([sys.executable,'-c','import time;time.sleep(60)'])\n" + f"open({str(grandchild_pid)!r},'w').write(str(p.pid))\n" + "time.sleep(60)\n" + ) + p = subprocess.Popen( + [sys.executable, "-c", code], + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + root.append(p) + processes.track_frontend(p) + ready.set() + p.wait() + + with run_concurrently_context(frontend): + assert ready.wait(DEFAULT_TIMEOUT) + deadline = time.monotonic() + DEFAULT_TIMEOUT + while not grandchild_pid.exists() and time.monotonic() < deadline: + time.sleep(0.01) + assert grandchild_pid.exists() + assert root[0].poll() is not None + grandchild = int(grandchild_pid.read_text()) + deadline = time.monotonic() + DEFAULT_TIMEOUT + while time.monotonic() < deadline: + try: + os.kill(grandchild, 0) + except ProcessLookupError: + break + # The orphan may remain a zombie until init reaps it. + import psutil + + if psutil.Process(grandchild).status() == psutil.STATUS_ZOMBIE: + break + time.sleep(0.05) + else: + with contextlib.suppress(ProcessLookupError): + os.kill(grandchild, signal.SIGKILL) + pytest.fail("frontend grandchild remained runnable") + + +def test_run_context_leaves_untracked_process_alone(): + """A child without frontend opt-in is not stopped by cleanup.""" + child = subprocess.Popen([sys.executable, "-c", "import time;time.sleep(30)"]) + try: + with run_concurrently_context(lambda: None): + pass + assert child.poll() is None + finally: + child.terminate() + child.wait(timeout=DEFAULT_TIMEOUT) From a27499ce8c75a27f9c8f8889b210fa3d5853dad3 Mon Sep 17 00:00:00 2001 From: flcrom <161213233+flcrom@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:34:59 +0530 Subject: [PATCH 02/19] Keep frontend cleanup safe across startup and shutdown races --- reflex/utils/processes.py | 42 +++++++++++++++++++++++------ tests/units/test_reflex.py | 14 +++++++--- tests/units/utils/test_processes.py | 16 ++++++++++- 3 files changed, 59 insertions(+), 13 deletions(-) diff --git a/reflex/utils/processes.py b/reflex/utils/processes.py index adf2765185f..8d6ac81ef9c 100644 --- a/reflex/utils/processes.py +++ b/reflex/utils/processes.py @@ -270,6 +270,33 @@ def subprocess_p_open(args: subprocess._CMD, **kwargs): _run_children = threading.local() +class _FrontendRegistry: + """Serialize frontend enrollment and one-time teardown across worker threads.""" + + def __init__(self) -> None: + """Start with no tracked frontends.""" + self._lock = threading.Lock() + self._frontends: list[subprocess.Popen[str]] = [] + self._closing = False + + def register(self, process: subprocess.Popen[str]) -> None: + """Enroll a child, or stop it if teardown already took the list.""" + with self._lock: + if not self._closing: + self._frontends.append(process) + return + _stop_frontend(process) + + def stop_all(self) -> None: + """Stop each enrolled frontend exactly once, including on repeated cleanup.""" + with self._lock: + self._closing = True + children = self._frontends + self._frontends = [] + for child in children: + _stop_frontend(child) + + def _stop_frontend(process: subprocess.Popen[str], timeout: float = 5.0) -> None: """Stop the frontend and its POSIX process group when the run ends.""" if sys.platform == "win32": @@ -284,6 +311,7 @@ def _stop_frontend(process: subprocess.Popen[str], timeout: float = 5.0) -> None os.killpg(process.pid, signal.SIGTERM) except ProcessLookupError: return + # Wait up to five seconds by default for the frontend group to exit. deadline = time.monotonic() + timeout while time.monotonic() < deadline: try: @@ -297,9 +325,9 @@ def _stop_frontend(process: subprocess.Popen[str], timeout: float = 5.0) -> None def track_frontend(process: subprocess.Popen[str]) -> None: """Enroll a spawned frontend in its run context when one is active.""" - children = getattr(_run_children, "frontends", None) - if children is not None: - children.append(process) + registry = getattr(_run_children, "frontends", None) + if registry is not None: + registry.register(process) def _interrupt_main_thread(): @@ -383,7 +411,7 @@ def raise_first_failure(tasks: list[futures.Future]): # Run the functions concurrently. executor = None - frontends: list[subprocess.Popen[str]] = [] + frontends = _FrontendRegistry() def run_with_children(fn: tuple[Callable[..., Any], ...]) -> Any: _run_children.frontends = frontends @@ -414,8 +442,7 @@ def run_with_children(fn: tuple[Callable[..., Any], ...]) -> Any: # does not wait). with interrupt_lock: in_body = False - for child in frontends: - _stop_frontend(child) + frontends.stop_all() # Get the results in the order completed to check any exceptions. for task in futures.as_completed(tasks): @@ -427,8 +454,7 @@ def run_with_children(fn: tuple[Callable[..., Any], ...]) -> Any: raise_first_failure(tasks) raise finally: - for child in frontends: - _stop_frontend(child) + frontends.stop_all() # Shutdown the executor if executor: executor.shutdown(wait=False) diff --git a/tests/units/test_reflex.py b/tests/units/test_reflex.py index 30f9dd73993..ac584ca94cb 100644 --- a/tests/units/test_reflex.py +++ b/tests/units/test_reflex.py @@ -460,7 +460,8 @@ def frontend(*args): stdout=subprocess.PIPE,text=True) grandchild = int(p.stdout.readline()) processes.track_frontend(p) - open(PIDS,"w").write(f"{p.pid} {grandchild}") + with open(PIDS,"w") as handshake: + handshake.write(f"{p.pid} {grandchild}") p.wait() def backend(*args): @@ -500,12 +501,17 @@ def stop(sig,frame): raise SystemExit(0) child = grandchild = None try: deadline = time.monotonic() + DEFAULT_TIMEOUT - while not pids.exists() and time.monotonic() < deadline: + while time.monotonic() < deadline: if launcher.poll() is not None: pytest.fail(f"launcher exited early: {launcher.returncode}") + if pids.exists(): + parts = pids.read_text().split() + if len(parts) == 2: + child, grandchild = map(int, parts) + break time.sleep(0.01) - assert pids.exists(), "frontend did not start" - child, grandchild = map(int, pids.read_text().split()) + assert child is not None, "frontend did not start" + assert grandchild is not None, "frontend grandchild did not start" os.kill(launcher.pid, getattr(signal, sig)) try: returncode = launcher.wait(timeout=DEFAULT_TIMEOUT) diff --git a/tests/units/utils/test_processes.py b/tests/units/utils/test_processes.py index 1325a2a842f..174cc3a58e3 100644 --- a/tests/units/utils/test_processes.py +++ b/tests/units/utils/test_processes.py @@ -294,6 +294,20 @@ def _fail_on_release(): time.sleep(0.1) +def test_frontend_registry_stops_late_enrollment_once(monkeypatch): + """A worker racing shutdown cannot orphan a frontend or stop it twice.""" + stopped = [] + monkeypatch.setattr(processes, "_stop_frontend", stopped.append) + registry = processes._FrontendRegistry() + first = mock.Mock(spec=subprocess.Popen) + late = mock.Mock(spec=subprocess.Popen) + registry.register(first) + registry.stop_all() + registry.register(late) + registry.stop_all() + assert stopped == [first, late] + + def _finished_process(returncode: int, output: str = "ready\n") -> mock.MagicMock: """Build a Popen stand-in that has already exited with the given code. @@ -426,7 +440,7 @@ def test_frontend_group_ends_with_run_context(tmp_path): """The opted-in frontend and its child are both stopped at context exit.""" grandchild_pid = tmp_path / "grandchild.pid" ready = threading.Event() - root: list[subprocess.Popen[str]] = [] + root: list[subprocess.Popen[bytes]] = [] def frontend(): code = ( From fde4d36097ea4bd105f31aa6eee9210e3e131b13 Mon Sep 17 00:00:00 2001 From: flcrom <161213233+flcrom@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:52:35 +0530 Subject: [PATCH 03/19] Use text-mode test frontend for the tracked process type --- tests/units/utils/test_processes.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/units/utils/test_processes.py b/tests/units/utils/test_processes.py index 174cc3a58e3..5f5181feb69 100644 --- a/tests/units/utils/test_processes.py +++ b/tests/units/utils/test_processes.py @@ -440,7 +440,7 @@ def test_frontend_group_ends_with_run_context(tmp_path): """The opted-in frontend and its child are both stopped at context exit.""" grandchild_pid = tmp_path / "grandchild.pid" ready = threading.Event() - root: list[subprocess.Popen[bytes]] = [] + root: list[subprocess.Popen[str]] = [] def frontend(): code = ( @@ -454,6 +454,7 @@ def frontend(): start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + text=True, ) root.append(p) processes.track_frontend(p) From 268685f63dadc88884ac7ee5d9ada99a318bf28a Mon Sep 17 00:00:00 2001 From: flcrom <161213233+flcrom@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:11:00 +0530 Subject: [PATCH 04/19] Hoist headless shutdown test imports to module scope --- tests/units/test_reflex.py | 11 ++++------- tests/units/utils/test_processes.py | 3 +-- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/units/test_reflex.py b/tests/units/test_reflex.py index ac584ca94cb..bf2ce53066e 100644 --- a/tests/units/test_reflex.py +++ b/tests/units/test_reflex.py @@ -5,14 +5,18 @@ import contextlib import json import os +import signal import subprocess import sys +import time import click import click.testing +import psutil import pytest from reflex import reflex +from reflex.testing import DEFAULT_TIMEOUT _CLI_STARTUP_DENIED_MODULES = frozenset({ "PIL", @@ -439,13 +443,6 @@ def test_init_records_version_check_after_frontend_setup( @pytest.mark.parametrize("sig", ["SIGTERM", "SIGINT"]) def test_no_tty_run_stops_frontend(tmp_path, mode, sig): """Headless full-stack and frontend-only runs stop their process tree.""" - import signal - import time - - import psutil - - from reflex.testing import DEFAULT_TIMEOUT - pids = tmp_path / "children" driver = tmp_path / "driver.py" driver.write_text( diff --git a/tests/units/utils/test_processes.py b/tests/units/utils/test_processes.py index 5f5181feb69..f5c067d73df 100644 --- a/tests/units/utils/test_processes.py +++ b/tests/units/utils/test_processes.py @@ -12,6 +12,7 @@ from contextlib import closing from unittest import mock +import psutil import pytest from reflex.testing import DEFAULT_TIMEOUT, AppHarness @@ -476,8 +477,6 @@ def frontend(): except ProcessLookupError: break # The orphan may remain a zombie until init reaps it. - import psutil - if psutil.Process(grandchild).status() == psutil.STATUS_ZOMBIE: break time.sleep(0.05) From 369d48c495bcf3b0dda5616524d198477d44320c Mon Sep 17 00:00:00 2001 From: 47th <161213233+flcrom@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:34:33 +0530 Subject: [PATCH 05/19] Simplify frontend shutdown handlers and cleanup --- reflex/reflex.py | 78 +++++++++++++++++++++++++----------------------- 1 file changed, 41 insertions(+), 37 deletions(-) diff --git a/reflex/reflex.py b/reflex/reflex.py index fb0fc793073..27775bf880d 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -5,8 +5,8 @@ import logging import signal import sys -import threading from collections.abc import Callable +from contextlib import contextmanager from importlib import import_module from importlib.util import find_spec from pathlib import Path @@ -400,6 +400,23 @@ def _compile_app_worker( otel.flush() +@contextmanager +def _frontend_sigterm_handler(enabled: bool): + """Exit cleanly on SIGTERM for a frontend-only run.""" + if not enabled: + yield + return + + def stop_frontend(signum: int, frame: FrameType | None) -> None: + raise SystemExit(0) + + previous = signal.signal(signal.SIGTERM, stop_frontend) + try: + yield + finally: + signal.signal(signal.SIGTERM, previous) + + def _run_dev( running_mode: constants.RunningMode, frontend_port: int | None, @@ -441,42 +458,29 @@ def _run_dev( )) frontend_only = running_mode.has_frontend() and not running_mode.has_backend() - install_handlers = ( - frontend_only - and sys.platform != "win32" - and threading.current_thread() is threading.main_thread() - ) - old_handlers = {} - if install_handlers: - - def stop_frontend(signum: int, frame: FrameType | None) -> None: - raise SystemExit(0) - - old_handlers = { - sig: signal.getsignal(sig) for sig in (signal.SIGTERM, signal.SIGINT) - } - for sig in old_handlers: - signal.signal(sig, stop_frontend) - try: - with processes.run_concurrently_context( - *commands, interrupt_on_failure=not frontend_only - ) as tasks: - if frontend_only and tasks: - tasks[0].result() - elif running_mode.has_backend() and backend_port: - exec.run_backend( - backend_host, - int(backend_port), - config.loglevel.subprocess_level(), - running_mode.has_frontend(), - ) - # The windows uvicorn bug workaround - # https://github.com/reflex-dev/reflex/issues/2335 - if constants.IS_WINDOWS and exec.frontend_process: - exec.kill(exec.frontend_process.pid) - finally: - for sig, handler in old_handlers.items(): - signal.signal(sig, handler) + with _frontend_sigterm_handler(frontend_only and sys.platform != "win32"): + try: + with processes.run_concurrently_context( + *commands, interrupt_on_failure=not frontend_only + ) as tasks: + if frontend_only and tasks: + tasks[0].result() + elif running_mode.has_backend() and backend_port: + exec.run_backend( + backend_host, + int(backend_port), + config.loglevel.subprocess_level(), + running_mode.has_frontend(), + ) + # The windows uvicorn bug workaround + # https://github.com/reflex-dev/reflex/issues/2335 + if constants.IS_WINDOWS and exec.frontend_process: + exec.kill(exec.frontend_process.pid) + finally: + if (process := exec.frontend_process) is not None: + if process.poll() is None: + process.terminate() + exec.frontend_process = None def _run_preview(running_mode: constants.RunningMode, port: int, host: str): From 62b3460680cd949f632666d1a697a226f496a6d7 Mon Sep 17 00:00:00 2001 From: 47th <161213233+flcrom@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:55:30 +0530 Subject: [PATCH 06/19] Keep frontend in the CLI process group --- reflex/utils/exec.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 99bf680ffae..d84f469bc52 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -291,8 +291,6 @@ def run_process_and_launch_url( } if constants.IS_WINDOWS and backend_present: kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # pyright: ignore [reportAttributeAccessIssue] - elif not constants.IS_WINDOWS: - kwargs["start_new_session"] = True process = processes.new_process( run_command, cwd=get_web_dir(), @@ -301,7 +299,6 @@ def run_process_and_launch_url( ) global frontend_process frontend_process = process - processes.track_frontend(process) if process.stdout: for line in processes.stream_logs("Starting frontend", process): new_content, new_hash = get_package_json_and_hash(json_file_path) From af9a9ef553ddd65df1167ab7dea13df34d5c04af Mon Sep 17 00:00:00 2001 From: 47th <161213233+flcrom@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:56:15 +0530 Subject: [PATCH 07/19] Remove the frontend process-group registry --- reflex/utils/processes.py | 79 +-------------------------------------- 1 file changed, 1 insertion(+), 78 deletions(-) diff --git a/reflex/utils/processes.py b/reflex/utils/processes.py index 8d6ac81ef9c..934bc37b774 100644 --- a/reflex/utils/processes.py +++ b/reflex/utils/processes.py @@ -11,7 +11,6 @@ import subprocess import sys import threading -import time from _thread import interrupt_main from collections.abc import Callable, Generator, Sequence from concurrent import futures @@ -265,71 +264,6 @@ def subprocess_p_open(args: subprocess._CMD, **kwargs): return fn(non_empty_args, **kwargs) -# The frontend is the only child enrolled in run teardown. Each worker gets -# its own context, so unrelated calls to new_process are never signaled. -_run_children = threading.local() - - -class _FrontendRegistry: - """Serialize frontend enrollment and one-time teardown across worker threads.""" - - def __init__(self) -> None: - """Start with no tracked frontends.""" - self._lock = threading.Lock() - self._frontends: list[subprocess.Popen[str]] = [] - self._closing = False - - def register(self, process: subprocess.Popen[str]) -> None: - """Enroll a child, or stop it if teardown already took the list.""" - with self._lock: - if not self._closing: - self._frontends.append(process) - return - _stop_frontend(process) - - def stop_all(self) -> None: - """Stop each enrolled frontend exactly once, including on repeated cleanup.""" - with self._lock: - self._closing = True - children = self._frontends - self._frontends = [] - for child in children: - _stop_frontend(child) - - -def _stop_frontend(process: subprocess.Popen[str], timeout: float = 5.0) -> None: - """Stop the frontend and its POSIX process group when the run ends.""" - if sys.platform == "win32": - if process.poll() is None: - process.terminate() - return - # The frontend starts in its own session, so its PID is its group ID. - # The root may have exited before teardown; descendants can still hold - # that group open. Reuse of the numeric ID is a narrow residual race once - # the root has been reaped by the log reader. - try: - os.killpg(process.pid, signal.SIGTERM) - except ProcessLookupError: - return - # Wait up to five seconds by default for the frontend group to exit. - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - try: - os.killpg(process.pid, 0) - except ProcessLookupError: - return - time.sleep(0.05) - with contextlib.suppress(ProcessLookupError): - os.killpg(process.pid, signal.SIGKILL) - - -def track_frontend(process: subprocess.Popen[str]) -> None: - """Enroll a spawned frontend in its run context when one is active.""" - registry = getattr(_run_children, "frontends", None) - if registry is not None: - registry.register(process) - - def _interrupt_main_thread(): """Deliver a SIGINT to the main thread to break it out of a blocking call. @@ -411,19 +345,10 @@ def raise_first_failure(tasks: list[futures.Future]): # Run the functions concurrently. executor = None - frontends = _FrontendRegistry() - - def run_with_children(fn: tuple[Callable[..., Any], ...]) -> Any: - _run_children.frontends = frontends - try: - return fn[0](*fn[1:]) - finally: - _run_children.frontends = None - try: executor = futures.ThreadPoolExecutor(max_workers=len(fns)) # Submit the tasks. - tasks = [executor.submit(run_with_children, fn) for fn in fns] + tasks = [executor.submit(fn[0], *fn[1:]) for fn in fns] if interrupt_on_failure: for task in tasks: task.add_done_callback(wake_main_thread) @@ -442,7 +367,6 @@ def run_with_children(fn: tuple[Callable[..., Any], ...]) -> Any: # does not wait). with interrupt_lock: in_body = False - frontends.stop_all() # Get the results in the order completed to check any exceptions. for task in futures.as_completed(tasks): @@ -454,7 +378,6 @@ def run_with_children(fn: tuple[Callable[..., Any], ...]) -> Any: raise_first_failure(tasks) raise finally: - frontends.stop_all() # Shutdown the executor if executor: executor.shutdown(wait=False) From 309a6f6a5662854441af3a16789a2edf0f1af56f Mon Sep 17 00:00:00 2001 From: 47th <161213233+flcrom@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:57:12 +0530 Subject: [PATCH 08/19] Cover SIGKILL and no-TTY shutdown cases --- tests/units/test_reflex.py | 106 ++++++++++++++++++++++++++++++++++--- 1 file changed, 99 insertions(+), 7 deletions(-) diff --git a/tests/units/test_reflex.py b/tests/units/test_reflex.py index bf2ce53066e..7750a7aada9 100644 --- a/tests/units/test_reflex.py +++ b/tests/units/test_reflex.py @@ -438,25 +438,114 @@ def test_init_records_version_check_after_frontend_setup( assert events == ["frontend", "version"] +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups") +@pytest.mark.parametrize("mode", ["frontend", "fullstack"]) +def test_sigkill_run_group_stops_frontend(tmp_path, mode): + """A hard kill of the CLI job group must not leave frontend workers alive.""" + pids = tmp_path / "children" + driver = tmp_path / "driver.py" + driver.write_text( + """import subprocess, sys, time, types +from reflex.utils import build, exec as exec_mod, telemetry + +def frontend(*args): + code = "import subprocess,sys,time\\n" + \ + "p=subprocess.Popen([sys.executable,'-c','import time;time.sleep(60)'])\\n" + \ + "print(p.pid,flush=True);time.sleep(60)\\n" + p = subprocess.Popen([sys.executable,"-c",code], + stdout=subprocess.PIPE,text=True) + grandchild = int(p.stdout.readline()) + exec_mod.frontend_process = p + with open(PIDS,"w") as handshake: + handshake.write(f"{p.pid} {grandchild}") + p.wait() + +exec_mod.run_frontend=frontend +exec_mod.run_backend=lambda *args: time.sleep(60) +telemetry.send=lambda *a,**k:None +build.setup_frontend=lambda *a,**k:None +import reflex.reflex as rx +rx._compile_app=lambda:None +rx.get_config=lambda:types.SimpleNamespace(_set_persistent=lambda **k:None, + loglevel=types.SimpleNamespace(subprocess_level=lambda:None)) +from reflex_base import constants +rx._run_dev(MODE,3000,PORT,"127.0.0.1") +""" + .replace("PIDS", repr(str(pids))) + .replace( + "MODE", + "constants.RunningMode.FRONTEND_ONLY" + if mode == "frontend" + else "constants.RunningMode.FULLSTACK", + ) + .replace("PORT", "None" if mode == "frontend" else "8000") + ) + launcher = subprocess.Popen( + [sys.executable, str(driver)], + cwd=tmp_path, + start_new_session=True, + stderr=subprocess.PIPE, + text=True, + ) + child = grandchild = None + try: + deadline = time.monotonic() + DEFAULT_TIMEOUT + while time.monotonic() < deadline: + if launcher.poll() is not None: + pytest.fail(f"launcher exited early: {launcher.returncode}") + if pids.exists(): + child, grandchild = map(int, pids.read_text().split()) + break + time.sleep(0.01) + assert child is not None + os.killpg(launcher.pid, signal.SIGKILL) + launcher.wait(timeout=DEFAULT_TIMEOUT) + for pid in (child, grandchild): + deadline = time.monotonic() + DEFAULT_TIMEOUT + while time.monotonic() < deadline: + try: + if psutil.Process(pid).status() in ( + psutil.STATUS_ZOMBIE, + psutil.STATUS_DEAD, + ): + break + except psutil.NoSuchProcess: + break + time.sleep(0.05) + else: + pytest.fail(f"frontend descendant {pid} survived SIGKILL") + finally: + if launcher.poll() is None: + os.killpg(launcher.pid, signal.SIGKILL) + launcher.wait(timeout=DEFAULT_TIMEOUT) + if child is not None: + with contextlib.suppress(ProcessLookupError): + os.killpg(child, signal.SIGKILL) + for pid in (child, grandchild): + if pid is not None: + with contextlib.suppress(ProcessLookupError): + os.kill(pid, signal.SIGKILL) + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX signals") @pytest.mark.parametrize("mode", ["frontend", "fullstack"]) @pytest.mark.parametrize("sig", ["SIGTERM", "SIGINT"]) def test_no_tty_run_stops_frontend(tmp_path, mode, sig): - """Headless full-stack and frontend-only runs stop their process tree.""" + """Headless runs stop the frontend on SIGTERM or SIGINT.""" pids = tmp_path / "children" driver = tmp_path / "driver.py" driver.write_text( """import signal, subprocess, sys, time, types -from reflex.utils import build, exec as exec_mod, processes, telemetry +from reflex.utils import build, exec as exec_mod, telemetry def frontend(*args): - code = "import subprocess,sys,time\\n" + \ + code = "import signal,subprocess,sys,time\\n" + \ "p=subprocess.Popen([sys.executable,'-c','import time;time.sleep(60)'])\\n" + \ + "signal.signal(signal.SIGTERM, lambda *a: (p.terminate(), sys.exit(0)))\\n" + \ "print(p.pid,flush=True);time.sleep(60)\\n" - p = subprocess.Popen([sys.executable,"-c",code],start_new_session=True, - stdout=subprocess.PIPE,text=True) + p = subprocess.Popen([sys.executable,"-c",code],stdout=subprocess.PIPE,text=True) grandchild = int(p.stdout.readline()) - processes.track_frontend(p) + exec_mod.frontend_process = p with open(PIDS,"w") as handshake: handshake.write(f"{p.pid} {grandchild}") p.wait() @@ -514,7 +603,10 @@ def stop(sig,frame): raise SystemExit(0) returncode = launcher.wait(timeout=DEFAULT_TIMEOUT) except subprocess.TimeoutExpired: pytest.fail("no-TTY launcher hung after signal") - assert returncode == 0 + if sig == "SIGINT": + assert returncode in (0, -signal.SIGINT) + else: + assert returncode == 0 for pid in (child, grandchild): deadline = time.monotonic() + DEFAULT_TIMEOUT while time.monotonic() < deadline: From 1dee94577fa092cf91941ac4eef4adc7440fa663 Mon Sep 17 00:00:00 2001 From: 47th <161213233+flcrom@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:58:00 +0530 Subject: [PATCH 09/19] Drop obsolete process-group tests --- tests/units/utils/test_processes.py | 67 ----------------------------- 1 file changed, 67 deletions(-) diff --git a/tests/units/utils/test_processes.py b/tests/units/utils/test_processes.py index f5c067d73df..9da3bbda7e3 100644 --- a/tests/units/utils/test_processes.py +++ b/tests/units/utils/test_processes.py @@ -1,8 +1,6 @@ """Test process utilities.""" -import contextlib import logging -import os import signal import socket import subprocess @@ -12,7 +10,6 @@ from contextlib import closing from unittest import mock -import psutil import pytest from reflex.testing import DEFAULT_TIMEOUT, AppHarness @@ -295,20 +292,6 @@ def _fail_on_release(): time.sleep(0.1) -def test_frontend_registry_stops_late_enrollment_once(monkeypatch): - """A worker racing shutdown cannot orphan a frontend or stop it twice.""" - stopped = [] - monkeypatch.setattr(processes, "_stop_frontend", stopped.append) - registry = processes._FrontendRegistry() - first = mock.Mock(spec=subprocess.Popen) - late = mock.Mock(spec=subprocess.Popen) - registry.register(first) - registry.stop_all() - registry.register(late) - registry.stop_all() - assert stopped == [first, late] - - def _finished_process(returncode: int, output: str = "ready\n") -> mock.MagicMock: """Build a Popen stand-in that has already exited with the given code. @@ -436,56 +419,6 @@ def test_stream_logs_still_fails_on_a_real_error_exit(caplog): assert any("failed with exit code 1" in r.getMessage() for r in caplog.records) -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups") -def test_frontend_group_ends_with_run_context(tmp_path): - """The opted-in frontend and its child are both stopped at context exit.""" - grandchild_pid = tmp_path / "grandchild.pid" - ready = threading.Event() - root: list[subprocess.Popen[str]] = [] - - def frontend(): - code = ( - "import subprocess,sys,time\n" - "p=subprocess.Popen([sys.executable,'-c','import time;time.sleep(60)'])\n" - f"open({str(grandchild_pid)!r},'w').write(str(p.pid))\n" - "time.sleep(60)\n" - ) - p = subprocess.Popen( - [sys.executable, "-c", code], - start_new_session=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - text=True, - ) - root.append(p) - processes.track_frontend(p) - ready.set() - p.wait() - - with run_concurrently_context(frontend): - assert ready.wait(DEFAULT_TIMEOUT) - deadline = time.monotonic() + DEFAULT_TIMEOUT - while not grandchild_pid.exists() and time.monotonic() < deadline: - time.sleep(0.01) - assert grandchild_pid.exists() - assert root[0].poll() is not None - grandchild = int(grandchild_pid.read_text()) - deadline = time.monotonic() + DEFAULT_TIMEOUT - while time.monotonic() < deadline: - try: - os.kill(grandchild, 0) - except ProcessLookupError: - break - # The orphan may remain a zombie until init reaps it. - if psutil.Process(grandchild).status() == psutil.STATUS_ZOMBIE: - break - time.sleep(0.05) - else: - with contextlib.suppress(ProcessLookupError): - os.kill(grandchild, signal.SIGKILL) - pytest.fail("frontend grandchild remained runnable") - - def test_run_context_leaves_untracked_process_alone(): """A child without frontend opt-in is not stopped by cleanup.""" child = subprocess.Popen([sys.executable, "-c", "import time;time.sleep(30)"]) From d912ac9639c43109f242d3ce19ae5403d23a44aa Mon Sep 17 00:00:00 2001 From: 47th <161213233+flcrom@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:58:48 +0530 Subject: [PATCH 10/19] Create 7328.bugfix.md --- news/7328.bugfix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/7328.bugfix.md diff --git a/news/7328.bugfix.md b/news/7328.bugfix.md new file mode 100644 index 00000000000..8c5e419ee8c --- /dev/null +++ b/news/7328.bugfix.md @@ -0,0 +1 @@ +`reflex run` now stops its frontend on SIGTERM and SIGINT without a TTY, while keeping frontend workers in the CLI process group so a hard kill also stops them. From 4c7361bb8cd296b3a0818415a22f685035239ac9 Mon Sep 17 00:00:00 2001 From: 47th <161213233+flcrom@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:02:14 +0530 Subject: [PATCH 11/19] Wait for complete frontend PID handshake --- tests/units/test_reflex.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/units/test_reflex.py b/tests/units/test_reflex.py index 7750a7aada9..fb3c271468d 100644 --- a/tests/units/test_reflex.py +++ b/tests/units/test_reflex.py @@ -494,8 +494,10 @@ def frontend(*args): if launcher.poll() is not None: pytest.fail(f"launcher exited early: {launcher.returncode}") if pids.exists(): - child, grandchild = map(int, pids.read_text().split()) - break + parts = pids.read_text().split() + if len(parts) == 2: + child, grandchild = map(int, parts) + break time.sleep(0.01) assert child is not None os.killpg(launcher.pid, signal.SIGKILL) From 692915c53c9f44b4a8c09b10413fe5c654e8cf28 Mon Sep 17 00:00:00 2001 From: 47th <161213233+flcrom@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:33:18 +0530 Subject: [PATCH 12/19] Stop frontend before waiting for its worker --- reflex/reflex.py | 51 ++++++++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/reflex/reflex.py b/reflex/reflex.py index 27775bf880d..135aecc4498 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -457,30 +457,39 @@ def _run_dev( running_mode.has_backend(), )) + with exec._frontend_process_lock: + exec._frontend_shutting_down = False + exec.frontend_process = None + frontend_only = running_mode.has_frontend() and not running_mode.has_backend() - with _frontend_sigterm_handler(frontend_only and sys.platform != "win32"): + + with ( + _frontend_sigterm_handler(frontend_only and sys.platform != "win32"), + processes.run_concurrently_context( + *commands, interrupt_on_failure=not frontend_only + ) as tasks, + ): try: - with processes.run_concurrently_context( - *commands, interrupt_on_failure=not frontend_only - ) as tasks: - if frontend_only and tasks: - tasks[0].result() - elif running_mode.has_backend() and backend_port: - exec.run_backend( - backend_host, - int(backend_port), - config.loglevel.subprocess_level(), - running_mode.has_frontend(), - ) - # The windows uvicorn bug workaround - # https://github.com/reflex-dev/reflex/issues/2335 - if constants.IS_WINDOWS and exec.frontend_process: - exec.kill(exec.frontend_process.pid) + if frontend_only and tasks: + tasks[0].result() + elif running_mode.has_backend() and backend_port: + exec.run_backend( + backend_host, + int(backend_port), + config.loglevel.subprocess_level(), + running_mode.has_frontend(), + ) + # The windows uvicorn bug workaround + # https://github.com/reflex-dev/reflex/issues/2335 + if constants.IS_WINDOWS and exec.frontend_process: + exec.kill(exec.frontend_process.pid) finally: - if (process := exec.frontend_process) is not None: - if process.poll() is None: - process.terminate() - exec.frontend_process = None + with exec._frontend_process_lock: + exec._frontend_shutting_down = True + if (process := exec.frontend_process) is not None: + if process.poll() is None: + process.terminate() + exec.frontend_process = None def _run_preview(running_mode: constants.RunningMode, port: int, host: str): From ad0b790d3da2c08195721221b2a7fb75d9820dbb Mon Sep 17 00:00:00 2001 From: 47th <161213233+flcrom@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:34:20 +0530 Subject: [PATCH 13/19] Catch frontend launches racing shutdown --- reflex/utils/exec.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index d84f469bc52..83aece9a270 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -34,6 +34,8 @@ # For uvicorn windows bug fix (#2335) frontend_process = None +_frontend_process_lock = threading.Lock() +_frontend_shutting_down = False DEV_BACKEND_RELOAD_MARKER = ".reflex_dev_backend_started" @@ -298,7 +300,10 @@ def run_process_and_launch_url( **kwargs, ) global frontend_process - frontend_process = process + with _frontend_process_lock: + frontend_process = process + if _frontend_shutting_down and process.poll() is None: + process.terminate() if process.stdout: for line in processes.stream_logs("Starting frontend", process): new_content, new_hash = get_package_json_and_hash(json_file_path) From 048c7cfcccae5df81b0917914142bb43a075bd95 Mon Sep 17 00:00:00 2001 From: 47th <161213233+flcrom@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:35:11 +0530 Subject: [PATCH 14/19] Test real launch and late frontend cleanup --- tests/units/test_reflex.py | 199 +++++++++++++++++++++++++++++++++---- 1 file changed, 182 insertions(+), 17 deletions(-) diff --git a/tests/units/test_reflex.py b/tests/units/test_reflex.py index fb3c271468d..4bbe903efad 100644 --- a/tests/units/test_reflex.py +++ b/tests/units/test_reflex.py @@ -438,6 +438,161 @@ def test_init_records_version_check_after_frontend_setup( assert events == ["frontend", "version"] +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX signals") +def test_late_frontend_start_stops_after_backend_returns(tmp_path): + """A frontend started after backend teardown must exit without hanging.""" + pids = tmp_path / "frontend.pid" + driver = tmp_path / "driver.py" + driver.write_text( + """import subprocess, sys, time, types +from pathlib import Path +from reflex.utils import build, exec as exec_mod, processes, telemetry + +original_new_process = processes.new_process +def record_process(*args, **kwargs): + deadline = time.monotonic() + 5 + while not exec_mod._frontend_shutting_down and time.monotonic() < deadline: + time.sleep(0.01) + assert exec_mod._frontend_shutting_down + p = original_new_process(*args, **kwargs) + with open(PIDS, "w") as handshake: + handshake.write(str(p.pid)) + return p +processes.new_process = record_process +exec_mod.get_web_dir = lambda: Path.cwd() +exec_mod.get_package_json_and_hash = lambda *args: ({}, "unchanged") +exec_mod.frontend_env = lambda *args: {} +exec_mod.path_ops.get_node_bin_path = lambda: None + +def frontend(*args): + exec_mod.run_process_and_launch_url( + [sys.executable, "-c", "import time;time.sleep(60)"], True + ) + +def backend(*args): + return None + +exec_mod.run_frontend = frontend +exec_mod.run_backend = backend +telemetry.send = lambda *args, **kwargs: None +build.setup_frontend = lambda *args, **kwargs: None +import reflex.reflex as rx +rx._compile_app = lambda: None +rx.get_config = lambda: types.SimpleNamespace( + _set_persistent=lambda **kwargs: None, + loglevel=types.SimpleNamespace(subprocess_level=lambda: None), +) +from reflex_base import constants +rx._run_dev(constants.RunningMode.FULLSTACK, 3000, 8000, "127.0.0.1") +""".replace("PIDS", repr(str(pids))) + ) + launcher = subprocess.Popen( + [sys.executable, str(driver)], + cwd=tmp_path, + start_new_session=True, + stderr=subprocess.PIPE, + ) + child = None + try: + try: + launcher.wait(timeout=3) + except subprocess.TimeoutExpired: + pytest.fail("late frontend trapped the run after backend returned") + assert launcher.returncode == 0 + assert pids.exists(), "late frontend never launched" + child = int(pids.read_text().strip()) + with contextlib.suppress(psutil.NoSuchProcess): + assert psutil.Process(child).status() in ( + psutil.STATUS_ZOMBIE, + psutil.STATUS_DEAD, + ) + finally: + if launcher.poll() is None: + os.killpg(launcher.pid, signal.SIGKILL) + launcher.wait(timeout=DEFAULT_TIMEOUT) + if child is not None: + with contextlib.suppress(ProcessLookupError): + os.kill(child, signal.SIGKILL) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX signals") +def test_fullstack_backend_exit_stops_frontend(tmp_path): + """Frontend must not trap the run after a normal backend return.""" + pids = tmp_path / "frontend.pid" + driver = tmp_path / "driver.py" + driver.write_text( + """import subprocess, sys, time, types +from pathlib import Path +from reflex.utils import build, exec as exec_mod, processes, telemetry + +exec_mod.get_web_dir = lambda: Path.cwd() +exec_mod.get_package_json_and_hash = lambda *args: ({}, "unchanged") +exec_mod.frontend_env = lambda *args: {} +exec_mod.path_ops.get_node_bin_path = lambda: None +original_new_process = processes.new_process +def record_process(*args, **kwargs): + p = original_new_process(*args, **kwargs) + with open(PIDS, "w") as handshake: + handshake.write(str(p.pid)) + return p +processes.new_process = record_process +exec_mod.run_frontend = lambda *args: exec_mod.run_process_and_launch_url( + [sys.executable, "-c", "import time;time.sleep(60)"], True +) +def backend(*args): + from pathlib import Path + deadline = time.monotonic() + 5 + while not Path(PIDS).exists() and time.monotonic() < deadline: + time.sleep(0.01) +exec_mod.run_backend = backend +telemetry.send = lambda *args, **kwargs: None +build.setup_frontend = lambda *args, **kwargs: None +import reflex.reflex as rx +rx._compile_app = lambda: None +rx.get_config = lambda: types.SimpleNamespace( + _set_persistent=lambda **kwargs: None, + loglevel=types.SimpleNamespace(subprocess_level=lambda: None), +) +from reflex_base import constants +rx._run_dev(constants.RunningMode.FULLSTACK, 3000, 8000, "127.0.0.1") +""".replace("PIDS", repr(str(pids))) + ) + launcher = subprocess.Popen( + [sys.executable, str(driver)], + cwd=tmp_path, + start_new_session=True, + stderr=subprocess.PIPE, + ) + child = None + try: + deadline = time.monotonic() + DEFAULT_TIMEOUT + while time.monotonic() < deadline: + if launcher.poll() is not None: + pytest.fail(f"launcher exited early: {launcher.returncode}") + if pids.exists() and (pid := pids.read_text().strip()): + child = int(pid) + break + time.sleep(0.01) + assert child is not None + try: + launcher.wait(timeout=2) + except subprocess.TimeoutExpired: + pytest.fail("full-stack run hung after backend returned") + assert launcher.returncode == 0 + with contextlib.suppress(psutil.NoSuchProcess): + assert psutil.Process(child).status() in ( + psutil.STATUS_ZOMBIE, + psutil.STATUS_DEAD, + ) + finally: + if launcher.poll() is None: + os.killpg(launcher.pid, signal.SIGKILL) + launcher.wait(timeout=DEFAULT_TIMEOUT) + if child is not None: + with contextlib.suppress(ProcessLookupError): + os.kill(child, signal.SIGKILL) + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups") @pytest.mark.parametrize("mode", ["frontend", "fullstack"]) def test_sigkill_run_group_stops_frontend(tmp_path, mode): @@ -446,21 +601,26 @@ def test_sigkill_run_group_stops_frontend(tmp_path, mode): driver = tmp_path / "driver.py" driver.write_text( """import subprocess, sys, time, types -from reflex.utils import build, exec as exec_mod, telemetry - -def frontend(*args): - code = "import subprocess,sys,time\\n" + \ - "p=subprocess.Popen([sys.executable,'-c','import time;time.sleep(60)'])\\n" + \ - "print(p.pid,flush=True);time.sleep(60)\\n" - p = subprocess.Popen([sys.executable,"-c",code], - stdout=subprocess.PIPE,text=True) - grandchild = int(p.stdout.readline()) - exec_mod.frontend_process = p +from pathlib import Path +from reflex.utils import build, exec as exec_mod, processes, telemetry + +code = "import subprocess,sys,time\\n" + \ + "p=subprocess.Popen([sys.executable,'-c','import time;time.sleep(60)'])\\n" + \ + "print(p.pid,flush=True);time.sleep(60)\\n" +exec_mod.get_web_dir=lambda:Path.cwd() +exec_mod.get_package_json_and_hash=lambda *args:({}, "unchanged") +exec_mod.frontend_env=lambda *args:{} +exec_mod.path_ops.get_node_bin_path=lambda:None +original_new_process=processes.new_process +def record_process(*args,**kwargs): + p=original_new_process(*args,**kwargs) with open(PIDS,"w") as handshake: - handshake.write(f"{p.pid} {grandchild}") - p.wait() - -exec_mod.run_frontend=frontend + handshake.write(str(p.pid)) + return p +processes.new_process=record_process +exec_mod.run_frontend=lambda *args:exec_mod.run_process_and_launch_url( + [sys.executable,"-c",code],True +) exec_mod.run_backend=lambda *args: time.sleep(60) telemetry.send=lambda *a,**k:None build.setup_frontend=lambda *a,**k:None @@ -495,11 +655,16 @@ def frontend(*args): pytest.fail(f"launcher exited early: {launcher.returncode}") if pids.exists(): parts = pids.read_text().split() - if len(parts) == 2: - child, grandchild = map(int, parts) - break + if len(parts) == 1: + child = int(parts[0]) + children = psutil.Process(child).children() + if children: + grandchild = children[0].pid + break time.sleep(0.01) assert child is not None + assert grandchild is not None + assert os.getpgid(child) == launcher.pid os.killpg(launcher.pid, signal.SIGKILL) launcher.wait(timeout=DEFAULT_TIMEOUT) for pid in (child, grandchild): From f4914242a91d5bea32bd4e769ecda896f5103576 Mon Sep 17 00:00:00 2001 From: 47th <161213233+flcrom@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:36:06 +0530 Subject: [PATCH 15/19] Remove stale process-group changelog fragment --- news/6980.bugfix.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 news/6980.bugfix.md diff --git a/news/6980.bugfix.md b/news/6980.bugfix.md deleted file mode 100644 index af182526a7a..00000000000 --- a/news/6980.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -`reflex run` now stops its development frontend process group on shutdown, avoiding a hang after SIGTERM or SIGINT without a TTY. From 0810d254aecb6f1a05df4c100469a8b744c99414 Mon Sep 17 00:00:00 2001 From: 47th <161213233+flcrom@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:00:20 +0530 Subject: [PATCH 16/19] Update reflex.py --- reflex/reflex.py | 60 ++++++++++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/reflex/reflex.py b/reflex/reflex.py index 135aecc4498..30fa94e07f0 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -463,33 +463,39 @@ def _run_dev( frontend_only = running_mode.has_frontend() and not running_mode.has_backend() - with ( - _frontend_sigterm_handler(frontend_only and sys.platform != "win32"), - processes.run_concurrently_context( - *commands, interrupt_on_failure=not frontend_only - ) as tasks, - ): - try: - if frontend_only and tasks: - tasks[0].result() - elif running_mode.has_backend() and backend_port: - exec.run_backend( - backend_host, - int(backend_port), - config.loglevel.subprocess_level(), - running_mode.has_frontend(), - ) - # The windows uvicorn bug workaround - # https://github.com/reflex-dev/reflex/issues/2335 - if constants.IS_WINDOWS and exec.frontend_process: - exec.kill(exec.frontend_process.pid) - finally: - with exec._frontend_process_lock: - exec._frontend_shutting_down = True - if (process := exec.frontend_process) is not None: - if process.poll() is None: - process.terminate() - exec.frontend_process = None + def stop_frontend() -> None: + with exec._frontend_process_lock: + exec._frontend_shutting_down = True + if (process := exec.frontend_process) is not None: + if process.poll() is None: + process.terminate() + exec.frontend_process = None + + try: + with ( + _frontend_sigterm_handler(frontend_only and sys.platform != "win32"), + processes.run_concurrently_context( + *commands, interrupt_on_failure=not frontend_only + ) as tasks, + ): + try: + if frontend_only and tasks: + tasks[0].result() + elif running_mode.has_backend() and backend_port: + exec.run_backend( + backend_host, + int(backend_port), + config.loglevel.subprocess_level(), + running_mode.has_frontend(), + ) + # The windows uvicorn bug workaround + # https://github.com/reflex-dev/reflex/issues/2335 + if constants.IS_WINDOWS and exec.frontend_process: + exec.kill(exec.frontend_process.pid) + finally: + stop_frontend() + finally: + stop_frontend() def _run_preview(running_mode: constants.RunningMode, port: int, host: str): From 5588791f15cb4ec600151e4eeb424299122b93a6 Mon Sep 17 00:00:00 2001 From: 47th <161213233+flcrom@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:01:23 +0530 Subject: [PATCH 17/19] Test frontend cleanup when context entry fails --- tests/units/test_reflex.py | 56 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/units/test_reflex.py b/tests/units/test_reflex.py index 4bbe903efad..a49374918e9 100644 --- a/tests/units/test_reflex.py +++ b/tests/units/test_reflex.py @@ -438,6 +438,62 @@ def test_init_records_version_check_after_frontend_setup( assert events == ["frontend", "version"] +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX signals") +def test_frontend_preflight_failure_cleans_process(tmp_path): + """An exception during concurrent context entry still stops the frontend.""" + ready = tmp_path / "frontend.pid" + driver = tmp_path / "driver.py" + driver.write_text( + """import subprocess, sys, types +from reflex.utils import build, exec as exec_mod, telemetry + +def frontend(*args): + p = subprocess.Popen([sys.executable, "-c", "import time;time.sleep(60)"]) + exec_mod.frontend_process = p + with open(PIDS, "w") as handshake: + handshake.write(str(p.pid)) + raise SystemExit(3) + +exec_mod.run_frontend = frontend +exec_mod.run_backend = lambda *args: None +telemetry.send = lambda *args, **kwargs: None +build.setup_frontend = lambda *args, **kwargs: None +import reflex.reflex as rx +rx._compile_app = lambda: None +rx.get_config = lambda: types.SimpleNamespace( + _set_persistent=lambda **kwargs: None, + loglevel=types.SimpleNamespace(subprocess_level=lambda: None), +) +from reflex_base import constants +rx._run_dev(constants.RunningMode.FULLSTACK, 3000, 8000, "127.0.0.1") +""".replace("PIDS", repr(str(ready))) + ) + launcher = subprocess.Popen( + [sys.executable, str(driver)], + cwd=tmp_path, + start_new_session=True, + stderr=subprocess.PIPE, + ) + child = None + try: + launcher.wait(timeout=DEFAULT_TIMEOUT) + assert ready.exists(), "frontend did not start" + child = int(ready.read_text()) + assert launcher.returncode == 3 + with contextlib.suppress(psutil.NoSuchProcess): + assert psutil.Process(child).status() in ( + psutil.STATUS_ZOMBIE, + psutil.STATUS_DEAD, + ) + finally: + if launcher.poll() is None: + os.killpg(launcher.pid, signal.SIGKILL) + launcher.wait(timeout=DEFAULT_TIMEOUT) + if child is not None: + with contextlib.suppress(ProcessLookupError): + os.kill(child, signal.SIGKILL) + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX signals") def test_late_frontend_start_stops_after_backend_returns(tmp_path): """A frontend started after backend teardown must exit without hanging.""" From 36c0b2acf0aba137e27c302efba83f2cf831b2ed Mon Sep 17 00:00:00 2001 From: 47th <161213233+flcrom@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:35:04 +0530 Subject: [PATCH 18/19] Drain failing frontend driver stderr in regression test --- tests/units/test_reflex.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/units/test_reflex.py b/tests/units/test_reflex.py index a49374918e9..6577b854b1a 100644 --- a/tests/units/test_reflex.py +++ b/tests/units/test_reflex.py @@ -452,6 +452,7 @@ def frontend(*args): exec_mod.frontend_process = p with open(PIDS, "w") as handshake: handshake.write(str(p.pid)) + sys.stderr.write("x" * 131072) raise SystemExit(3) exec_mod.run_frontend = frontend @@ -476,10 +477,12 @@ def frontend(*args): ) child = None try: - launcher.wait(timeout=DEFAULT_TIMEOUT) - assert ready.exists(), "frontend did not start" + _, stderr = launcher.communicate(timeout=DEFAULT_TIMEOUT) + assert ready.exists(), ( + "frontend did not start: " + stderr.decode(errors="replace")[-1000:] + ) child = int(ready.read_text()) - assert launcher.returncode == 3 + assert launcher.returncode == 3, stderr.decode(errors="replace")[-1000:] with contextlib.suppress(psutil.NoSuchProcess): assert psutil.Process(child).status() in ( psutil.STATUS_ZOMBIE, @@ -488,7 +491,7 @@ def frontend(*args): finally: if launcher.poll() is None: os.killpg(launcher.pid, signal.SIGKILL) - launcher.wait(timeout=DEFAULT_TIMEOUT) + launcher.communicate(timeout=DEFAULT_TIMEOUT) if child is not None: with contextlib.suppress(ProcessLookupError): os.kill(child, signal.SIGKILL) From 8c06ecd3fcc19925722bccc93793a89ecde392f8 Mon Sep 17 00:00:00 2001 From: 47th <161213233+flcrom@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:56:09 +0530 Subject: [PATCH 19/19] Drop unrelated run_context test --- tests/units/test_reflex.py | 1 - tests/units/utils/test_processes.py | 13 ------------- 2 files changed, 14 deletions(-) diff --git a/tests/units/test_reflex.py b/tests/units/test_reflex.py index 6577b854b1a..040b4124e94 100644 --- a/tests/units/test_reflex.py +++ b/tests/units/test_reflex.py @@ -440,7 +440,6 @@ def test_init_records_version_check_after_frontend_setup( @pytest.mark.skipif(sys.platform == "win32", reason="POSIX signals") def test_frontend_preflight_failure_cleans_process(tmp_path): - """An exception during concurrent context entry still stops the frontend.""" ready = tmp_path / "frontend.pid" driver = tmp_path / "driver.py" driver.write_text( diff --git a/tests/units/utils/test_processes.py b/tests/units/utils/test_processes.py index 9da3bbda7e3..e16e321051b 100644 --- a/tests/units/utils/test_processes.py +++ b/tests/units/utils/test_processes.py @@ -4,7 +4,6 @@ import signal import socket import subprocess -import sys import threading import time from contextlib import closing @@ -417,15 +416,3 @@ def test_stream_logs_still_fails_on_a_real_error_exit(caplog): list(stream_logs("Starting frontend", process)) assert any("failed with exit code 1" in r.getMessage() for r in caplog.records) - - -def test_run_context_leaves_untracked_process_alone(): - """A child without frontend opt-in is not stopped by cleanup.""" - child = subprocess.Popen([sys.executable, "-c", "import time;time.sleep(30)"]) - try: - with run_concurrently_context(lambda: None): - pass - assert child.poll() is None - finally: - child.terminate() - child.wait(timeout=DEFAULT_TIMEOUT)