Skip to content
Merged
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
1 change: 1 addition & 0 deletions news/+dev-mode-memory.performance.md
Original file line number Diff line number Diff line change
@@ -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`.
7 changes: 2 additions & 5 deletions reflex/utils/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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()
Expand Down
19 changes: 18 additions & 1 deletion reflex/utils/exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down
17 changes: 12 additions & 5 deletions reflex/utils/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
76 changes: 56 additions & 20 deletions tests/units/test_telemetry.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import asyncio
import importlib.metadata
import json
import sys
import threading
import uuid
from types import SimpleNamespace
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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]
16 changes: 16 additions & 0 deletions tests/units/utils/test_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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({})
Expand Down
Loading