Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions docs/hosting/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ from a single server on port `3000`. Pass `--frontend-port` or
the server runs up to 8 backend workers by default (`2 * cpu_count + 1`, capped at 8).
When using Granian, set `GRANIAN_WORKERS` to override the default, including to use more workers.

When using Granian on Linux and macOS, `ps` shows labeled Reflex processes such as
`reflex run [compiler]`, `reflex run [backend]`, and
`reflex run [backend] worker-1`; a standalone production frontend uses
`reflex run [frontend]`. Windows Task Manager command lines are unchanged.

When using Granian on Linux, backend workers are forked from a supervisor
process that has already imported the app, so they share its memory instead of
each importing the app again. Set `REFLEX_BACKEND_START_METHOD=spawn` (or
Expand Down
1 change: 1 addition & 0 deletions news/+process-titles.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Label Reflex compiler and Granian server processes by role so they are easier to identify in Linux and macOS process listings.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ keywords = ["web", "framework"]
requires-python = ">=3.11,<4.0"
dependencies = [
"click >=8.2",
"granian[reload] >=2.7.4",
"granian[reload,pname] >=2.7.4",
"httpx >=0.26,<1.0",
"packaging >=24.2,<27",
"psutil >=7.0.0,<8.0; sys_platform == 'win32'",
Expand Down
3 changes: 3 additions & 0 deletions reflex/reflex.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,9 @@ def _compile_app_worker(
"""
from reflex_base import otel

from reflex.utils import processes

processes.set_process_title("compiler")
try:
return app_task(*args, **kwargs)
finally:
Expand Down
22 changes: 20 additions & 2 deletions reflex/utils/exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,11 +472,17 @@ def run_frontend_prod(host: str, port: int):
host: The host to serve on.
port: The port to serve on.
"""
from reflex.utils import processes

loglevel = get_config().loglevel.subprocess_level()

if should_use_granian():
run_granian_backend_prod(
host, port, loglevel, app_target=f"{__name__}:_frontend_prod_app"
host,
port,
loglevel,
app_target=f"{__name__}:_frontend_prod_app",
process_name=processes.get_process_title("frontend"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The frontend title is only passed in the Granian branch; the Uvicorn fallback starts a plain gunicorn/uvicorn command, so standalone frontend processes remain unlabeled when that fallback is selected. Apply the frontend role title to the fallback too.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At reflex/utils/exec.py, line 485:

<comment>The frontend title is only passed in the Granian branch; the Uvicorn fallback starts a plain `gunicorn`/`uvicorn` command, so standalone frontend processes remain unlabeled when that fallback is selected. Apply the frontend role title to the fallback too.</comment>

<file context>
@@ -472,11 +472,17 @@ def run_frontend_prod(host: str, port: int):
+            port,
+            loglevel,
+            app_target=f"{__name__}:_frontend_prod_app",
+            process_name=processes.get_process_title("frontend"),
         )
     else:
</file context>

)
else:
run_uvicorn_backend_prod(
Expand Down Expand Up @@ -789,6 +795,8 @@ def run_granian_backend(host: str, port: int, loglevel: LogLevel):
from granian.server import Server as Granian
from reflex_base.environment import _load_dotenv_from_env

from reflex.utils import processes

class ParentBoundGranian(Granian): # pyright: ignore[reportGeneralTypeIssues]
"""Granian server that binds the listen socket in the supervisor.

Expand Down Expand Up @@ -941,6 +949,7 @@ def shutdown(self, exit_code: int = 0):
reload_ignore_patterns=HOTRELOAD_IGNORE_PATTERNS,
reload_tick=100,
workers_kill_timeout=2,
process_name=processes.get_process_title("backend"),
)

granian_app.on_reload(_load_dotenv_from_env)
Expand Down Expand Up @@ -1121,7 +1130,12 @@ def _preload_for_fork(app_target: str | None) -> bool:


def run_granian_backend_prod(
host: str, port: int, loglevel: LogLevel, app_target: str | None = None
host: str,
port: int,
loglevel: LogLevel,
app_target: str | None = None,
*,
process_name: str | None = None,
):
"""Run the backend in production mode using Granian.

Expand All @@ -1130,13 +1144,16 @@ def run_granian_backend_prod(
port: The app port
loglevel: The log level.
app_target: The ASGI app target to run. Defaults to the reflex app instance.
process_name: The supervisor title. Defaults to the Reflex backend title.
"""
import multiprocessing

from granian.constants import Interfaces
from granian.log import LogLevels
from granian.server import Server as Granian

from reflex.utils import processes

logger.debug("Using Granian for backend")

if (start_method := _backend_start_method()) is not None:
Expand All @@ -1156,6 +1173,7 @@ def run_granian_backend_prod(
log_level=LogLevels(os.getenv("GRANIAN_LOG_LEVEL", loglevel.value)),
log_dictconfig=_granian_log_dictconfig(),
workers=int(os.getenv("GRANIAN_WORKERS", str(_get_backend_workers()))),
process_name=process_name or processes.get_process_title("backend"),
)

granian_app.serve()
Expand Down
22 changes: 22 additions & 0 deletions reflex/utils/processes.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from pathlib import Path
from typing import Any, Literal, overload

import setproctitle
from reflex_base import constants
from reflex_base.config import get_config
from reflex_base.environment import environment
Expand All @@ -32,6 +33,27 @@
MAX_BACKEND_WORKERS = 8


def get_process_title(label: str) -> str:
"""Build a Reflex process title independent of multiprocessing's argv.

Args:
label: The process role, such as compiler, backend, or frontend.

Returns:
The interpreter, Reflex command, and process role.
"""
return f"{sys.executable} reflex run [{label}]"


def set_process_title(label: str) -> None:
"""Label the current Reflex child process.

Args:
label: The role of the current process.
"""
setproctitle.setproctitle(get_process_title(label))


def kill(pid: int):
"""Kill a process.

Expand Down
18 changes: 18 additions & 0 deletions tests/units/test_reflex.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,17 +155,35 @@ def test_compile_app_worker_flushes_telemetry(mocker):
"""Flush the completed compile span before an isolated worker exits."""
app_task = mocker.Mock(return_value=True)
flush = mocker.patch("reflex_base.otel.flush")
mocker.patch("reflex.utils.processes.set_process_title")

assert reflex._compile_app_worker(app_task, (True,), {"trigger": "initial"})

app_task.assert_called_once_with(True, trigger="initial")
flush.assert_called_once_with()


def test_compile_app_worker_sets_title_before_compiling(mocker):
"""Label the compiler child before running application code."""
calls = mocker.Mock()
calls.attach_mock(mocker.patch("reflex.utils.processes.set_process_title"), "title")
app_task = mocker.Mock(return_value=True)
calls.attach_mock(app_task, "compile")
mocker.patch("reflex_base.otel.flush")

assert reflex._compile_app_worker(app_task, (True,), {"trigger": "initial"})

assert calls.mock_calls == [
mocker.call.title("compiler"),
mocker.call.compile(True, trigger="initial"),
]


def test_compile_app_worker_flushes_telemetry_on_failure(mocker):
"""Flush telemetry even when the worker's compile task raises."""
app_task = mocker.Mock(side_effect=RuntimeError("compile failed"))
flush = mocker.patch("reflex_base.otel.flush")
mocker.patch("reflex.utils.processes.set_process_title")

with pytest.raises(RuntimeError, match="compile failed"):
reflex._compile_app_worker(app_task, (), {})
Expand Down
29 changes: 29 additions & 0 deletions tests/units/utils/test_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ def test_run_granian_backend_sets_reload_env_var_and_clears_marker(
class FakeGranian:
def __init__(self, *_args, **_kwargs):
seen["value"] = os.environ.get(DEV_BACKEND_RELOAD_ENV_NAME)
seen["process_name"] = _kwargs.get("process_name")
assert not marker.exists()

def on_reload(self, _callback):
Expand All @@ -251,6 +252,7 @@ def serve(self):
)

assert seen["value"] == "True"
assert seen["process_name"] == f"{sys.executable} reflex run [backend]"


@pytest.mark.parametrize(
Expand Down Expand Up @@ -581,6 +583,33 @@ def serve(self):
mocker.patch.object(gc, "freeze", side_effect=lambda: calls.append("freeze"))


@pytest.mark.parametrize("frontend_only", [False, True])
def test_production_server_process_titles(mocker: MockerFixture, frontend_only: bool):
"""Label backend and standalone frontend servers through their real launchers."""
granian_server = pytest.importorskip("granian.server")
server = mocker.patch.object(granian_server, "Server")
mocker.patch.object(exec_utils, "_backend_start_method", return_value=None)
mocker.patch.object(exec_utils, "_get_backend_workers", return_value=1)
mocker.patch.object(
exec_utils, "get_app_instance_from_file", return_value="app:app"
)
mocker.patch.object(exec_utils, "should_use_granian", return_value=True)

if frontend_only:
exec_utils.run_frontend_prod("127.0.0.1", 3000)
else:
exec_utils.run_granian_backend_prod("127.0.0.1", 8000, exec_utils.LogLevel.INFO)

role = "frontend" if frontend_only else "backend"
assert server.call_args.kwargs["process_name"] == (
f"{sys.executable} reflex run [{role}]"
)
assert server.call_args.kwargs["target"] == (
"reflex.utils.exec:_frontend_prod_app" if frontend_only else "app:app"
)
server.return_value.serve.assert_called_once_with()


def test_run_granian_backend_prod_preloads_app_before_forking(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
):
Expand Down
25 changes: 25 additions & 0 deletions tests/units/utils/test_processes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import signal
import socket
import subprocess
import sys
import threading
import time
from contextlib import closing
Expand All @@ -29,6 +30,30 @@
)


@pytest.mark.parametrize(
"argv", [["reflex", "run"], ["-c", "from multiprocessing.spawn import spawn_main"]]
)
@pytest.mark.parametrize("label", ["compiler", "backend", "frontend"])
def test_process_title_ignores_bootstrap_arguments(monkeypatch, argv, label):
"""Process titles identify Reflex even in spawned Python workers."""
monkeypatch.setattr(sys, "argv", argv)
monkeypatch.setattr(sys, "executable", "/venv/bin/python")

assert (
processes.get_process_title(label) == f"/venv/bin/python reflex run [{label}]"
)


def test_set_process_title(monkeypatch):
"""Set the current process title without retaining its bootstrap command."""
setproctitle = mock.Mock()
monkeypatch.setattr("setproctitle.setproctitle", setproctitle)

processes.set_process_title("compiler")

setproctitle.assert_called_once_with(f"{sys.executable} reflex run [compiler]")


@pytest.mark.parametrize(
("cpu_count", "expected"),
[(None, 3), (0, 3), (1, 3), (2, 5), (3, 7), (4, 8), (64, 8)],
Expand Down
Loading