From 616ff220f9f841ce8621b988711f39c15c2f23d8 Mon Sep 17 00:00:00 2001 From: Farhan Date: Fri, 11 Sep 2026 19:27:48 +0500 Subject: [PATCH] perf: cut dev-mode memory in the frontend and telemetry paths Set MIMALLOC_ARENA_EAGER_COMMIT=0 for the vite dev server and the export build so rolldown does not keep its dependency pre-bundling arena resident. Send telemetry through urllib so backend workers never import httpx on their first error, and import packaging lazily in telemetry. Measured on a blank app (PSS over the whole `reflex run` tree, page loaded once): node cold ~780 MB -> ~455 MB, warm ~415 MB -> ~335 MB. Claude-Session: https://claude.ai/code/session_015Gi5wTWZNBA61pVDdPLu8u --- news/+dev-mode-memory.performance.md | 1 + reflex/utils/build.py | 7 +-- reflex/utils/exec.py | 19 ++++++- reflex/utils/telemetry.py | 17 +++++-- tests/units/test_telemetry.py | 76 ++++++++++++++++++++-------- tests/units/utils/test_exec.py | 16 ++++++ 6 files changed, 105 insertions(+), 31 deletions(-) create mode 100644 news/+dev-mode-memory.performance.md diff --git a/news/+dev-mode-memory.performance.md b/news/+dev-mode-memory.performance.md new file mode 100644 index 00000000000..acfb132b6cd --- /dev/null +++ b/news/+dev-mode-memory.performance.md @@ -0,0 +1 @@ +Reduce `reflex run` and `reflex export` memory: the vite/react-router processes no longer keep their dependency pre-bundling arena resident (`MIMALLOC_ARENA_EAGER_COMMIT=0`, overridable from the environment), and error telemetry is sent through `urllib` so backend workers never import `httpx`. diff --git a/reflex/utils/build.py b/reflex/utils/build.py index 5046f33d78d..3673408051c 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -11,7 +11,7 @@ from reflex_base.config import get_config from reflex.utils import console, js_runtimes, path_ops, prerequisites, processes -from reflex.utils.exec import is_in_app_harness +from reflex.utils.exec import frontend_env, is_in_app_harness logger = logging.getLogger(__name__) @@ -273,10 +273,7 @@ def build(): ], cwd=wdir, shell=constants.IS_WINDOWS, - env={ - **os.environ, - "NO_COLOR": "1", - }, + env=frontend_env(os.environ), ) processes.show_progress("Creating Production Build", process, checkpoints) process.wait() diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index c93b11949c3..a270df3e52c 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -245,6 +245,23 @@ def _with_development_condition(environ: Mapping[str, str]) -> dict[str, str]: return env +def frontend_env(environ: Mapping[str, str]) -> dict[str, str]: + """Build the environment for the frontend toolchain processes. + + Rolldown, which vite and react-router run for dependency pre-bundling and + builds, allocates through mimalloc. Disabling eager arena commit keeps the + memory it touches during pre-bundling from staying resident for the life of + the dev server or build. A value already present in ``environ`` wins. + + Args: + environ: The base environment. + + Returns: + A copy of the environment for the vite/react-router processes. + """ + return {"MIMALLOC_ARENA_EAGER_COMMIT": "0", **environ, "NO_COLOR": "1"} + + # run_process_and_launch_url is assumed to be used # only to launch the frontend # If this is not the case, might have to change the logic @@ -267,7 +284,7 @@ def run_process_and_launch_url( while True: if process is None: kwargs: dict[str, Any] = { - "env": _with_development_condition({**os.environ, "NO_COLOR": "1"}) + "env": _with_development_condition(frontend_env(os.environ)) } if constants.IS_WINDOWS and backend_present: kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # pyright: ignore [reportAttributeAccessIssue] diff --git a/reflex/utils/telemetry.py b/reflex/utils/telemetry.py index f2f8b794301..319e0bb0964 100644 --- a/reflex/utils/telemetry.py +++ b/reflex/utils/telemetry.py @@ -9,6 +9,7 @@ import platform import sys import threading +import urllib.request import uuid from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor @@ -17,8 +18,6 @@ from pathlib import Path from typing import Any, TypedDict, cast -from packaging.requirements import Requirement -from packaging.utils import canonicalize_name from reflex_base import constants from reflex_base.config import get_config from reflex_base.environment import environment @@ -242,6 +241,9 @@ def get_reflex_package_versions() -> dict[str, str]: Returns: A mapping of Reflex subpackage name to installed version, sorted by name. """ + from packaging.requirements import Requirement + from packaging.utils import canonicalize_name + try: requirements = importlib.metadata.requires("reflex") or () except importlib.metadata.PackageNotFoundError: @@ -436,10 +438,15 @@ def _prepare_event( def _send_event(event_data: _Event) -> bool: - import httpx - + # urllib keeps httpx and its import cost out of the backend workers, which + # only ever send from here. + request = urllib.request.Request( + POSTHOG_API_URL, + data=json.dumps(event_data).encode(), + headers={"Content-Type": "application/json"}, + ) try: - httpx.post(POSTHOG_API_URL, json=event_data) + urllib.request.urlopen(request, timeout=5).close() except Exception: return False else: diff --git a/tests/units/test_telemetry.py b/tests/units/test_telemetry.py index 113d01c4d05..4fc3a125514 100644 --- a/tests/units/test_telemetry.py +++ b/tests/units/test_telemetry.py @@ -1,5 +1,7 @@ import asyncio import importlib.metadata +import json +import sys import threading import uuid from types import SimpleNamespace @@ -63,13 +65,25 @@ def event_defaults(mocker: MockerFixture) -> dict: @pytest.fixture -def httpx_post(mocker: MockerFixture): - """Mock ``httpx.post`` used by ``telemetry._send``. +def urlopen(mocker: MockerFixture): + """Mock ``urllib.request.urlopen`` used by ``telemetry._send_event``. Returns: - The mock for ``httpx.post`` so tests can assert on the posted payload. + The mock for ``urlopen`` so tests can assert on the posted payload. """ - return mocker.patch("httpx.post") + return mocker.patch("reflex.utils.telemetry.urllib.request.urlopen") + + +def posted_json(call) -> dict: + """Decode the JSON body of one recorded ``urlopen`` call. + + Args: + call: A ``mock.call`` recorded by the ``urlopen`` fixture. + + Returns: + The decoded request body. + """ + return json.loads(call.args[0].data) def test_telemetry(): @@ -193,16 +207,16 @@ def test_get_reflex_package_versions_handles_missing_reflex_metadata( ), ], ) -def test_send(event_defaults, httpx_post, event, kwargs, expected_props): +def test_send(event_defaults, urlopen, event, kwargs, expected_props): telemetry._send(event, telemetry_enabled=True, **kwargs) - httpx_post.assert_called_once() - posted = httpx_post.call_args.kwargs["json"] + urlopen.assert_called_once() + posted = posted_json(urlopen.call_args) assert posted["event"] == event for key, value in expected_props.items(): assert posted["properties"][key] == value -def test_send_does_not_leak_kwargs_between_events(event_defaults, httpx_post): +def test_send_does_not_leak_kwargs_between_events(event_defaults, urlopen): """Per-event kwargs must not leak into a subsequent event's payload.""" telemetry._send("export", telemetry_enabled=True, status="success", duration=1.0) telemetry._send( @@ -213,9 +227,9 @@ def test_send_does_not_leak_kwargs_between_events(event_defaults, httpx_post): duration=2.0, ) - assert httpx_post.call_count == 2 - first_props = httpx_post.call_args_list[0].kwargs["json"]["properties"] - second_props = httpx_post.call_args_list[1].kwargs["json"]["properties"] + assert urlopen.call_count == 2 + first_props = posted_json(urlopen.call_args_list[0])["properties"] + second_props = posted_json(urlopen.call_args_list[1])["properties"] assert first_props["status"] == "success" assert first_props["duration"] == pytest.approx(1.0) @@ -231,16 +245,16 @@ def test_send_does_not_leak_kwargs_between_events(event_defaults, httpx_post): assert "detail" not in event_defaults["properties"] -def test_send_drops_unknown_kwargs(event_defaults, httpx_post): +def test_send_drops_unknown_kwargs(event_defaults, urlopen): """Unknown kwargs must not land in the posted payload.""" telemetry._send("export", telemetry_enabled=True, foo="bar", secret="leak") - httpx_post.assert_called_once() - props = httpx_post.call_args.kwargs["json"]["properties"] + urlopen.assert_called_once() + props = posted_json(urlopen.call_args)["properties"] assert "foo" not in props assert "secret" not in props -def test_send_drops_none_kwargs(event_defaults, httpx_post): +def test_send_drops_none_kwargs(event_defaults, urlopen): """None-valued kwargs for allowed keys are omitted from the posted payload.""" telemetry._send( "export", @@ -252,8 +266,8 @@ def test_send_drops_none_kwargs(event_defaults, httpx_post): build_duration=0.05, zip_duration=None, ) - httpx_post.assert_called_once() - props = httpx_post.call_args.kwargs["json"]["properties"] + urlopen.assert_called_once() + props = posted_json(urlopen.call_args)["properties"] assert props["status"] == "success" assert props["build_duration"] == pytest.approx(0.05) assert "detail" not in props @@ -597,7 +611,7 @@ def test_maybe_alias_runs_at_most_once_per_process(mocker: MockerFixture): def test_maybe_alias_create_alias_payload( - event_defaults, httpx_post, mocker: MockerFixture + event_defaults, urlopen, mocker: MockerFixture ): """The posted $create_alias pairs the new UUID distinct_id with the legacy int.""" mocker.patch.object(telemetry, "has_uuid_distinct_id_semantics", return_value=False) @@ -611,8 +625,8 @@ def test_maybe_alias_create_alias_payload( # The $create_alias is now sent on the telemetry worker thread; wait for it. telemetry._flush() - httpx_post.assert_called_once() - payload = httpx_post.call_args.kwargs["json"] + urlopen.assert_called_once() + payload = posted_json(urlopen.call_args) assert payload["event"] == "$create_alias" props = payload["properties"] # The legacy integer is sent at full precision so PostHog re-coerces it to @@ -801,3 +815,25 @@ def test_flush_returns_false_when_worker_does_not_drain_in_time(): finally: release.set() blocker.result(timeout=5) + + +def test_send_event_posts_json_without_httpx(mocker: MockerFixture): + """Delivery goes through urllib, so backend workers never import httpx.""" + urlopen = mocker.patch("reflex.utils.telemetry.urllib.request.urlopen") + mocker.patch.dict(sys.modules, {"httpx": None}) + + assert telemetry._send_event({"api_key": "k", "event": "e"}) # pyright: ignore[reportArgumentType] + + request = urlopen.call_args.args[0] + assert request.full_url == telemetry.POSTHOG_API_URL + assert request.get_header("Content-type") == "application/json" + assert json.loads(request.data) == {"api_key": "k", "event": "e"} + urlopen.return_value.close.assert_called_once() + + +def test_send_event_swallows_delivery_errors(mocker: MockerFixture): + """A failed request is reported as False, never raised.""" + mocker.patch( + "reflex.utils.telemetry.urllib.request.urlopen", side_effect=OSError("down") + ) + assert not telemetry._send_event({"api_key": "k", "event": "e"}) # pyright: ignore[reportArgumentType] diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index 5dfc677c094..c758e61ee23 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_exec.py @@ -80,6 +80,22 @@ def serve(self): assert seen["value"] == "True" +def test_frontend_env_defaults_mimalloc_and_no_color(): + """The toolchain env disables eager arena commit unless the user set it.""" + env = exec_utils.frontend_env({"PATH": "/bin"}) + assert env == { + "PATH": "/bin", + "MIMALLOC_ARENA_EAGER_COMMIT": "0", + "NO_COLOR": "1", + } + assert ( + exec_utils.frontend_env({"MIMALLOC_ARENA_EAGER_COMMIT": "1"})[ + "MIMALLOC_ARENA_EAGER_COMMIT" + ] + == "1" + ) + + def test_with_development_condition_sets_node_and_bun_options(): """Both runtime option vars gain the development condition flag.""" env = exec_utils._with_development_condition({})