Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
22a1ac6
perf: preload the app and fork production granian workers
FarhanAliRaza Sep 10, 2026
9824978
perf: fork the dev backend reload worker instead of using a forkserver
FarhanAliRaza Sep 11, 2026
3d76959
chore: name the news fragment after the PR
FarhanAliRaza Sep 11, 2026
0c2cb91
Merge branch 'main' into farhan/prod-fork-preload
FarhanAliRaza Sep 30, 2026
579c420
Defer run telemetry past the fork and drain the executor first
FarhanAliRaza Sep 30, 2026
493a4c9
Merge branch 'farhan/prod-fork-preload' into farhan/dev-fork-workers
FarhanAliRaza Sep 30, 2026
70206b6
Honor the explicit dev start method and drain telemetry before the de…
FarhanAliRaza Sep 30, 2026
8e4d870
Bound the pre-fork telemetry drain
FarhanAliRaza Sep 30, 2026
c7de30f
Keep the telemetry pool published while it shuts down
FarhanAliRaza Sep 30, 2026
8db32ab
Merge branch 'farhan/prod-fork-preload' into farhan/dev-fork-workers
FarhanAliRaza Sep 30, 2026
b8ef3cc
Spawn production workers when a telemetry send outlives the drain
FarhanAliRaza Sep 30, 2026
8a5444f
Merge branch 'farhan/prod-fork-preload' into farhan/dev-fork-workers
FarhanAliRaza Sep 30, 2026
075d4fa
Spawn the dev reload worker when a telemetry send outlives the drain
FarhanAliRaza Sep 30, 2026
c3457a4
Set the start method before preloading and deflake the stalled drain …
FarhanAliRaza Sep 30, 2026
234ff44
Keep telemetry from starting a thread between the drain and the fork
FarhanAliRaza Oct 1, 2026
f3712b5
Silence the multithreaded-fork warning in the telemetry fork test
FarhanAliRaza Oct 1, 2026
2c37292
Merge branch 'farhan/prod-fork-preload' into farhan/dev-fork-workers
FarhanAliRaza Oct 1, 2026
82888e5
Keep telemetry paused in the dev supervisor across reload forks
FarhanAliRaza Oct 1, 2026
b21e971
Share one fake granian setup between the dev fork tests
FarhanAliRaza Oct 1, 2026
a223278
Merge branch 'main' into farhan/dev-fork-workers
FarhanAliRaza Oct 1, 2026
91e321c
Send the deferred run event from a detached process
FarhanAliRaza Oct 1, 2026
e919fb7
Send the detached run event in-process where nothing forks
FarhanAliRaza Oct 1, 2026
0f42637
Merge branch 'farhan/prod-fork-preload' into farhan/dev-fork-workers
FarhanAliRaza Oct 1, 2026
687db6c
Use the detached sender only in a process paused to fork
FarhanAliRaza Oct 1, 2026
487bf81
Merge branch 'farhan/prod-fork-preload' into farhan/dev-fork-workers
FarhanAliRaza Oct 1, 2026
b5f800a
Reap the detached sender reliably and keep its tests offline
FarhanAliRaza Oct 1, 2026
3c5451b
Merge branch 'farhan/prod-fork-preload' into farhan/dev-fork-workers
FarhanAliRaza Oct 1, 2026
2562af4
Merge branch 'main' into farhan/prod-fork-preload
FarhanAliRaza Oct 1, 2026
eece011
Merge branch 'farhan/prod-fork-preload' into farhan/dev-fork-workers
FarhanAliRaza Oct 1, 2026
aebe6ec
Keep the run event's sender attached under a PID 1 parent
FarhanAliRaza Oct 1, 2026
437b1c7
Only stay attached under reflex's own output supervisor as PID 1
FarhanAliRaza Oct 1, 2026
1e45ee1
Skip the attached-sender test where os.fork is missing
FarhanAliRaza Oct 1, 2026
03b5af8
Merge branch 'farhan/prod-fork-preload' into farhan/dev-fork-workers
FarhanAliRaza Oct 1, 2026
28391df
Merge branch 'main' into farhan/prod-fork-preload
FarhanAliRaza Oct 1, 2026
048718e
Shorten the prod fork preload news fragment to two sentences
FarhanAliRaza Oct 1, 2026
bba7657
Merge branch 'farhan/prod-fork-preload' into farhan/dev-fork-workers
FarhanAliRaza Oct 1, 2026
2f7c5e9
Merge branch 'main' into farhan/dev-fork-workers
claude Oct 5, 2026
3bb4fe9
Merge main into PR #7113 to refresh required CI
masenf Oct 9, 2026
c7bfd29
Merge main into PR #7113 and drop the superseded run-prod sender
FarhanAliRaza Oct 14, 2026
c9e07c0
Merge remote-tracking branch 'upstream/main' into farhan/dev-fork-wor…
FarhanAliRaza Oct 14, 2026
6a923a8
Merge branch 'main' into farhan/dev-fork-workers
FarhanAliRaza Oct 19, 2026
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/7113.performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The `reflex run` backend reload worker is now forked from the supervisor on Linux instead of started through a forkserver, so framework code is shared copy-on-write and the extra forkserver processes are gone. Set `REFLEX_BACKEND_START_METHOD=spawn` or `REFLEX_STRICT_HOT_RELOAD=1` for apps that are not fork-safe.
1 change: 1 addition & 0 deletions reflex/reflex.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ def _compile_app(*, avoid_dirty_check: bool = True):
if exec.should_use_granian() and avoid_dirty_check:
import concurrent.futures

exec.set_dev_start_method()
with concurrent.futures.ProcessPoolExecutor(max_workers=1) as executor:
compile_future = executor.submit(
_compile_app_worker, app_task, args, kwargs
Expand Down
75 changes: 59 additions & 16 deletions reflex/utils/exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,9 @@ def run_backend(

# Run the backend in development mode.
if should_use_granian():
# Fix the start method first so a backend-only run, which skips the
# compile pool, preloads under the same rule as the reload worker.
set_dev_start_method()
# Forked workers inherit imported modules from the supervisor. Spawned
# and forkserver workers do not, so preloading the app there only keeps
# the full framework graph resident in the long-lived supervisor.
Expand Down Expand Up @@ -774,12 +777,11 @@ def run_granian_backend(host: str, port: int, loglevel: LogLevel):
port: The app port
loglevel: The log level.
"""
import multiprocessing

logger.debug("Using Granian for backend")

if environment.REFLEX_STRICT_HOT_RELOAD.get():
import multiprocessing

multiprocessing.set_start_method("spawn", force=True)
set_dev_start_method()
Comment thread
FarhanAliRaza marked this conversation as resolved.

from granian.constants import Interfaces
from granian.log import LogLevels
Expand Down Expand Up @@ -914,6 +916,14 @@ def shutdown(self, exit_code: int = 0):
self._close_shared_socket()
super().shutdown(exit_code)

# The app itself is not imported here: the reload worker must load it
# fresh on every restart. Only the framework pages are shared.
# The supervisor forks again on every reload; the run-dev event was sent
# before this point and nothing in it sends telemetry afterwards.
if multiprocessing.get_start_method() == "fork" and not _freeze_for_fork():
logger.debug("A telemetry send is still running; spawning the worker.")
multiprocessing.set_start_method("spawn", force=True)

reset_dev_backend_reload_marker()
environment.REFLEX_DEV_BACKEND_RELOAD_ACTIVE.set(True)

Expand Down Expand Up @@ -1029,7 +1039,7 @@ def run_uvicorn_backend_prod(


def _backend_start_method() -> str | None:
"""Resolve the multiprocessing start method for production backend workers.
"""Resolve the multiprocessing start method for backend workers.

Returns:
The start method to force, or None to keep the interpreter default.
Expand All @@ -1046,6 +1056,48 @@ def _backend_start_method() -> str | None:
return None


def set_dev_start_method() -> None:
"""Fix the multiprocessing start method for the development backend.

An explicit ``REFLEX_BACKEND_START_METHOD`` wins; otherwise strict hot
reload spawns workers and the platform rule of ``_backend_start_method``
applies. Call this before the first child process starts, or a forkserver
started for the compile pool stays alive for the whole session.
"""
import multiprocessing

if (
environment.REFLEX_STRICT_HOT_RELOAD.get()
and environment.REFLEX_BACKEND_START_METHOD.get() is None
):
start_method = "spawn"
else:
start_method = _backend_start_method()
if start_method is not None:
multiprocessing.set_start_method(start_method, force=True)


def _freeze_for_fork() -> bool:
"""Stop the telemetry thread and freeze the heap before forking workers.

Forking while the telemetry thread may hold a lock can deadlock the child.
Without the freeze, worker GC passes write to the inherited objects'
headers, which copies the shared pages private again.

Returns:
Whether forking is safe; False when a telemetry thread is still alive.
"""
import gc

from reflex.utils import telemetry

if not telemetry._shutdown_executor():
return False
gc.collect()
gc.freeze()
return True


def _preload_for_fork(app_target: str | None) -> bool:
"""Import the app in the supervisor so forked workers share its pages.

Expand All @@ -1056,25 +1108,16 @@ def _preload_for_fork(app_target: str | None) -> bool:
Returns:
Whether forking is safe; False when a telemetry thread is still alive.
"""
import gc

from reflex_base.utils import serializers

from reflex.utils import prerequisites, telemetry
from reflex.utils import prerequisites

if app_target is None:
prerequisites.get_app()
serializers._prepare_serializers_for_fork()
# Forking while the telemetry thread may hold a lock can deadlock the child.
# Nothing in the supervisor sends telemetry after this point, so no thread
# exists at any later fork either (e.g. a worker respawn).
if not telemetry._shutdown_executor():
return False
# Freezing keeps worker GC passes from writing to the preloaded objects'
# headers, which would copy-on-write the shared pages private again.
gc.collect()
gc.freeze()
return True
return _freeze_for_fork()


def run_granian_backend_prod(
Expand Down
39 changes: 39 additions & 0 deletions tests/units/test_reflex.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,45 @@ def test_compile_app_worker_flushes_telemetry_on_failure(mocker):
flush.assert_called_once_with()


def test_compile_app_sets_start_method_before_compile_pool(mocker: MockerFixture):
"""The start method is fixed before the compile pool spawns any process."""
from unittest import mock

from reflex.utils import exec as exec_utils
from reflex.utils import prerequisites

calls: list[str] = []
mocker.patch.object(exec_utils, "should_use_granian", return_value=True)
mocker.patch.object(exec_utils, "should_prerender_routes", return_value=False)
mocker.patch.object(
exec_utils,
"set_dev_start_method",
side_effect=lambda: calls.append("start_method"),
)
mocker.patch.object(prerequisites, "compile_or_validate_app")

class FakeExecutor:
def __init__(self, *_args, **_kwargs):
calls.append("pool")

def __enter__(self):
return self

def __exit__(self, *_exc):
return False

def submit(self, *_args, **_kwargs):
future = mock.Mock()
future.result.return_value = True
return future

mocker.patch("concurrent.futures.ProcessPoolExecutor", FakeExecutor)

reflex._compile_app()

assert calls == ["start_method", "pool"]


@pytest.mark.parametrize(
("running_mode", "launcher"),
[
Expand Down
139 changes: 139 additions & 0 deletions tests/units/utils/test_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ def test_run_backend_skips_app_preload_for_spawn(
run_granian = mocker.patch.object(exec_utils, "run_granian_backend")
mocker.patch.object(exec_utils, "notify_backend")
mocker.patch.object(multiprocessing, "get_start_method", return_value="spawn")
mocker.patch.object(multiprocessing, "set_start_method")
monkeypatch.delenv(environment.REFLEX_BACKEND_START_METHOD.name, raising=False)
monkeypatch.setenv(environment.REFLEX_STRICT_HOT_RELOAD.name, "False")

real_import = builtins.__import__
Expand Down Expand Up @@ -162,6 +164,8 @@ def test_run_backend_preloads_app_for_fork(
mocker.patch.object(exec_utils, "run_granian_backend")
mocker.patch.object(exec_utils, "notify_backend")
mocker.patch.object(multiprocessing, "get_start_method", return_value="fork")
mocker.patch.object(multiprocessing, "set_start_method")
monkeypatch.delenv(environment.REFLEX_BACKEND_START_METHOD.name, raising=False)
monkeypatch.setenv(environment.REFLEX_STRICT_HOT_RELOAD.name, "False")

imported: list[str] = []
Expand Down Expand Up @@ -249,6 +253,141 @@ def serve(self):
assert seen["value"] == "True"


@pytest.mark.parametrize(
("strict", "default_method", "expected"),
[
(True, "forkserver", "start:spawn"),
(False, "forkserver", "start:fork"),
(False, "fork", "start:fork"),
(False, "spawn", None),
],
)
def test_set_dev_start_method(
mocker: MockerFixture,
monkeypatch: pytest.MonkeyPatch,
strict: bool,
default_method: str,
expected: str | None,
):
"""Strict hot reload spawns; otherwise the platform rule decides."""
monkeypatch.setenv(environment.REFLEX_STRICT_HOT_RELOAD.name, str(strict))
monkeypatch.delenv(environment.REFLEX_BACKEND_START_METHOD.name, raising=False)
calls: list[str] = []
mocker.patch.object(
multiprocessing, "get_start_method", return_value=default_method
)
mocker.patch.object(
multiprocessing,
"set_start_method",
side_effect=lambda method, force=False: calls.append(f"start:{method}"),
)

exec_utils.set_dev_start_method()

assert calls == ([expected] if expected else [])


def test_set_dev_start_method_explicit_method_beats_strict_hot_reload(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
):
"""REFLEX_BACKEND_START_METHOD wins even when strict hot reload is on."""
monkeypatch.setenv(environment.REFLEX_STRICT_HOT_RELOAD.name, "True")
monkeypatch.setenv(environment.REFLEX_BACKEND_START_METHOD.name, "forkserver")
set_start_method = mocker.patch.object(multiprocessing, "set_start_method")

exec_utils.set_dev_start_method()

set_start_method.assert_called_once_with("forkserver", force=True)


def _fake_granian_dev(
mocker: MockerFixture,
calls: list[str],
start_method: str,
telemetry_stopped: bool = True,
):
"""Patch granian and the dev launcher's collaborators, recording call order."""
granian_server = pytest.importorskip("granian.server")

class FakeGranian:
def __init__(self, *_args, **_kwargs):
pass

def on_reload(self, _callback):
pass

def serve(self):
calls.append("serve")

mocker.patch.object(granian_server, "Server", FakeGranian)
mocker.patch.object(
exec_utils, "get_app_instance_from_file", return_value="app:app"
)
mocker.patch.object(exec_utils, "get_reload_paths", return_value=[])
mocker.patch.object(exec_utils, "reset_dev_backend_reload_marker")
mocker.patch.object(
multiprocessing,
"set_start_method",
side_effect=lambda method, force=False: calls.append(f"start:{method}"),
)
mocker.patch.object(multiprocessing, "get_start_method", return_value=start_method)
mocker.patch.object(
prerequisites, "get_app", side_effect=lambda: calls.append("preload")
)
mocker.patch.object(
telemetry,
"_shutdown_executor",
side_effect=lambda: calls.append("drain") or telemetry_stopped,
)
mocker.patch.object(gc, "freeze", side_effect=lambda: calls.append("freeze"))


@pytest.mark.parametrize(
("start_method", "expected"),
[
("fork", ["start:fork", "drain", "freeze", "serve"]),
("spawn", ["start:spawn", "serve"]),
],
)
def test_run_granian_backend_freezes_only_for_fork(
mocker: MockerFixture,
monkeypatch: pytest.MonkeyPatch,
start_method: str,
expected: list[str],
):
"""Forked reload workers share a frozen heap; the app is never preloaded.

The telemetry thread (e.g. from the run-dev event) is drained and stopped
first, so the supervisor has no telemetry thread when it forks the worker.
"""
monkeypatch.setenv(environment.REFLEX_BACKEND_START_METHOD.name, start_method)
monkeypatch.setenv(environment.REFLEX_STRICT_HOT_RELOAD.name, "False")
calls: list[str] = []
_fake_granian_dev(mocker, calls, start_method)

exec_utils.run_granian_backend(
host="0.0.0.0", port=8000, loglevel=exec_utils.LogLevel.INFO
)

assert calls == expected


def test_run_granian_backend_spawns_when_telemetry_is_stuck(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
):
"""A telemetry thread that outlives the drain makes the dev fork unsafe."""
monkeypatch.setenv(environment.REFLEX_BACKEND_START_METHOD.name, "fork")
monkeypatch.setenv(environment.REFLEX_STRICT_HOT_RELOAD.name, "False")
calls: list[str] = []
_fake_granian_dev(mocker, calls, "fork", telemetry_stopped=False)

exec_utils.run_granian_backend(
host="0.0.0.0", port=8000, loglevel=exec_utils.LogLevel.INFO
)

assert calls == ["start:fork", "drain", "start:spawn", "serve"]


def test_run_granian_backend_binds_listen_socket_in_supervisor(
tmp_path: Path, mocker: MockerFixture
):
Expand Down
Loading