Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/7328.bugfix.md
Original file line number Diff line number Diff line change
@@ -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.
74 changes: 59 additions & 15 deletions reflex/reflex.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
from __future__ import annotations

import logging
import signal
import sys
from collections.abc import Callable
from contextlib import contextmanager
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
Expand Down Expand Up @@ -397,6 +400,23 @@ def _compile_app_worker(
otel.flush()


@contextmanager
def _frontend_sigterm_handler(enabled: bool):
Comment thread
FarhanAliRaza marked this conversation as resolved.
"""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,
Expand Down Expand Up @@ -437,21 +457,45 @@ 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)
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()

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):
Expand Down
7 changes: 6 additions & 1 deletion reflex/utils/exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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"

Expand Down Expand Up @@ -296,7 +298,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)
Expand Down
9 changes: 6 additions & 3 deletions reflex/utils/processes.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,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.

Expand All @@ -293,6 +294,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.
Expand Down Expand Up @@ -346,9 +348,10 @@ def raise_first_failure(tasks: list[futures.Future]):
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(fn[0], *fn[1:]) for fn in fns]
if interrupt_on_failure:
for task in tasks:
task.add_done_callback(wake_main_thread)

try:
try:
Expand Down
Loading
Loading