From b7d35cab590c000573e624802d75a4c0fdf2f11f Mon Sep 17 00:00:00 2001 From: Farhan Date: Tue, 6 Oct 2026 17:52:15 +0000 Subject: [PATCH 1/2] Serialize production frontend work sharing one .web directory Two exports (or an export and a prod/preview start) in the same project used to race on .web: the second replaced build/ and env.json while the first was still compiling or zipping, so one of them shipped the other's frontend. Hold an exclusive, reentrant file lock on .web/.reflex-build.lock from compilation through ZIP creation, release it before serving, and never create .web for a backend-only export that has nothing to protect. Split out of #7054 (lock only; the opt-in build cache that needs it follows separately). Claude-Session: https://claude.ai/code/session_018nWB9r2HcC5tkT72UxWgGj --- docs/hosting/self-hosting.md | 10 ++ news/+export-workspace-lock.bugfix.md | 1 + reflex/reflex.py | 58 +++++--- reflex/utils/build_lock.py | 119 +++++++++++++++++ reflex/utils/export.py | 81 ++++++------ tests/units/test_reflex.py | 109 +++++++++++++++ tests/units/utils/test_build_lock.py | 183 ++++++++++++++++++++++++++ tests/units/utils/test_export.py | 161 +++++++++++++++++++++- 8 files changed, 663 insertions(+), 59 deletions(-) create mode 100644 news/+export-workspace-lock.bugfix.md create mode 100644 reflex/utils/build_lock.py create mode 100644 tests/units/utils/test_build_lock.py diff --git a/docs/hosting/self-hosting.md b/docs/hosting/self-hosting.md index 3521f217195..7c00e2fde13 100644 --- a/docs/hosting/self-hosting.md +++ b/docs/hosting/self-hosting.md @@ -136,6 +136,16 @@ this, use the `--no-zip` parameter. This provides the frontend in the `.web/build/client/` directory and the backend can be found in the root directory of the project. +Production exports that share a `.web` directory wait for one another from +compilation through ZIP creation, on macOS, Linux, and Windows. Backend-only +exports also wait while archiving an existing `.web/backend` directory, but do +not create `.web` when it is absent. Production and preview startup hold the +same lock while compiling and building and release it before serving. The lock +file `.web/.reflex-build.lock` stays in place between commands; do not remove it +while a command is running. Initialization, development hot reload, and other +tools writing to `.web` are outside this lock, so avoid running them during an +export. + The export also writes a pre-compressed `.gz` copy of the compressible text assets (JS, CSS, HTML, JSON, SVG, and similar), so configure the static host to serve those directly where it supports it. Set the diff --git a/news/+export-workspace-lock.bugfix.md b/news/+export-workspace-lock.bugfix.md new file mode 100644 index 00000000000..6113547cb15 --- /dev/null +++ b/news/+export-workspace-lock.bugfix.md @@ -0,0 +1 @@ +Production exports, `reflex run --env prod`, and preview startup that share a `.web` directory now wait for one another instead of overwriting each other's compiled frontend and archives. The lock file `.web/.reflex-build.lock` stays in place between commands. diff --git a/reflex/reflex.py b/reflex/reflex.py index 6aad6461cb5..819ff13a6eb 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -6,7 +6,7 @@ import signal import sys from collections.abc import Callable -from contextlib import contextmanager +from contextlib import contextmanager, nullcontext from importlib import import_module from importlib.util import find_spec from pathlib import Path @@ -508,21 +508,33 @@ def _run_preview(running_mode: constants.RunningMode, port: int, host: str): """ import atexit - from reflex.utils import build, exec, processes, telemetry + from reflex.utils import ( + build, + build_lock, + exec, + prerequisites, + processes, + telemetry, + ) config = get_config() - config._set_persistent(frontend_port=port, backend_port=port) + with ( + build_lock.frontend_build_lock(prerequisites.get_web_dir()) + if running_mode.has_frontend() + else nullcontext() + ): + config._set_persistent(frontend_port=port, backend_port=port) - # Mount the compiled frontend into the dev backend so no Vite server is needed. - environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.set( - running_mode.has_frontend() and running_mode.has_backend() - ) + # Mount the compiled frontend into the dev backend so no Vite server is needed. + environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.set( + running_mode.has_frontend() and running_mode.has_backend() + ) - if running_mode.has_frontend(): - # Compile the app and produce the initial frontend build. - _compile_app() - build.setup_frontend_prod(Path.cwd()) + if running_mode.has_frontend(): + # Compile the app and produce the initial frontend build. + _compile_app() + build.setup_frontend_prod(Path.cwd()) # Post a telemetry event. telemetry.send("run-preview") @@ -547,16 +559,28 @@ def _run_preview(running_mode: constants.RunningMode, port: int, host: str): def _run_prod(running_mode: constants.RunningMode, port: int, host: str): import atexit - from reflex.utils import build, exec, processes, telemetry + from reflex.utils import ( + build, + build_lock, + exec, + prerequisites, + processes, + telemetry, + ) config = get_config() - config._set_persistent(frontend_port=port, backend_port=port) + with ( + build_lock.frontend_build_lock(prerequisites.get_web_dir()) + if running_mode.has_frontend() + else nullcontext() + ): + config._set_persistent(frontend_port=port, backend_port=port) - if running_mode.has_frontend(): - # Get the app module. - _compile_app(avoid_dirty_check=False) - build.setup_frontend_prod(Path.cwd()) + if running_mode.has_frontend(): + # Get the app module. + _compile_app(avoid_dirty_check=False) + build.setup_frontend_prod(Path.cwd()) _skip_compile() diff --git a/reflex/utils/build_lock.py b/reflex/utils/build_lock.py new file mode 100644 index 00000000000..0b0d45c06d0 --- /dev/null +++ b/reflex/utils/build_lock.py @@ -0,0 +1,119 @@ +"""Serialize production frontend work that shares one ``.web`` directory.""" + +from __future__ import annotations + +import errno +import logging +import os +import stat +import sys +import threading +import time +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +from reflex_base import constants + +logger = logging.getLogger(__name__) + +LOCK_FILE = ".reflex-build.lock" +_lock_state = threading.local() +_lock_descriptors: set[int] = set() + + +def _reset_build_locks_after_fork() -> None: + """Close inherited descriptors without unlocking the parent's file descriptions.""" + for descriptor in _lock_descriptors: + os.close(descriptor) + _lock_descriptors.clear() + _lock_state.paths = set() + + +if not constants.IS_WINDOWS: + os.register_at_fork(after_in_child=_reset_build_locks_after_fork) + + +def _lock_descriptor(descriptor: int, *, unlock: bool = False) -> None: + """Acquire or release the platform's exclusive file lock. + + Args: + descriptor: Open lock-file descriptor, positioned at byte zero. + unlock: Whether to release a previously acquired lock. + + Raises: + OSError: The lock operation fails for a reason other than contention. + """ + if sys.platform == "win32": + import msvcrt + + if unlock: + msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1) + return + while True: + try: + msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1) + except OSError as error: # noqa: PERF203 - retry a contended OS lock + if error.errno != errno.EACCES: + raise + # Poll every 100 ms; builds can exceed LK_LOCK's ten-second limit. + time.sleep(0.1) + else: + return + else: + import fcntl + + fcntl.flock(descriptor, fcntl.LOCK_UN if unlock else fcntl.LOCK_EX) + + +@contextmanager +def frontend_build_lock(web_dir: Path) -> Iterator[None]: + """Serialize production workspace access, including builds with caching disabled. + + Nested calls from the same thread reuse its lock. Other threads and processes + open independent descriptors and wait on the same persistent lock file. + + Args: + web_dir: Shared frontend working directory. + + Yields: + Control while this thread owns the production workspace. + + Raises: + OSError: A regular lock file cannot be opened or locked safely. + """ + owner_pid = os.getpid() + web_dir = web_dir.resolve() + held: set[Path] = getattr(_lock_state, "paths", set()) + if web_dir in held: + yield + return + web_dir.mkdir(parents=True, exist_ok=True) + lock_path = web_dir / LOCK_FILE + descriptor = os.open( + lock_path, os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600 + ) + _lock_descriptors.add(descriptor) + try: + info = os.fstat(descriptor) + path_info = lock_path.lstat() + if ( + not stat.S_ISREG(info.st_mode) + or not stat.S_ISREG(path_info.st_mode) + or not os.path.samestat(info, path_info) + ): + msg = "Production build lock must be a regular file" + raise OSError(msg) + _lock_descriptor(descriptor) + held.add(web_dir) + _lock_state.paths = held + try: + yield + finally: + if os.getpid() == owner_pid: + held.remove(web_dir) + _lock_descriptor(descriptor, unlock=True) + finally: + if os.getpid() == owner_pid: + _lock_descriptors.discard(descriptor) + os.close(descriptor) diff --git a/reflex/utils/export.py b/reflex/utils/export.py index 565d0cb0915..0eaa1f29d1e 100644 --- a/reflex/utils/export.py +++ b/reflex/utils/export.py @@ -3,7 +3,7 @@ import logging import time from collections.abc import Iterator -from contextlib import contextmanager +from contextlib import contextmanager, nullcontext from pathlib import Path from reflex_base import constants @@ -11,7 +11,7 @@ from reflex_base.environment import environment from reflex_base.utils import console -from reflex.utils import build, exec, prerequisites, telemetry +from reflex.utils import build, build_lock, exec, prerequisites, telemetry logger = logging.getLogger(__name__) @@ -49,23 +49,6 @@ def export( # Set the log level. console.set_log_level(loglevel) - # Set env mode in the environment - environment.REFLEX_ENV_MODE.set(env) - - # Override the config url values if provided. - if api_url is not None: - config._set_persistent(api_url=str(api_url)) - logger.debug(f"overriding API URL: {config.api_url}") - if deploy_url is not None: - config._set_persistent(deploy_url=str(deploy_url)) - logger.debug(f"overriding deploy URL: {config.deploy_url}") - - # Show system info - exec.output_system_info() - - # Compile the app in production mode and export it. - console.rule("[bold]Compiling production app and preparing for export.") - start = time.monotonic() phase_durations: dict[str, float] = {} status = "success" @@ -80,26 +63,46 @@ def _time_phase(name: str) -> Iterator[None]: phase_durations[name] = time.monotonic() - t0 try: - if frontend: - with _time_phase("compile_duration"): - # Ensure module can be imported and app.compile() is called. - prerequisites.get_compiled_app( - prerender_routes=prerender_routes, trigger="export" - ) - with _time_phase("setup_duration"): - # Set up .web directory and install frontend dependencies. - build.setup_frontend(Path.cwd()) - with _time_phase("build_duration"): - build.build() - if zipping: - with _time_phase("zip_duration"): - build.zip_app( - frontend=frontend, - backend=backend, - zip_dest_dir=zip_dest_dir, - include_db_file=upload_db_file, - backend_excluded_dirs=backend_excluded_dirs, - ) + web_dir = prerequisites.get_web_dir() + with ( + build_lock.frontend_build_lock(web_dir) + if frontend or (backend and zipping and web_dir.is_dir()) + else nullcontext() + ): + # Set env mode in the environment. + environment.REFLEX_ENV_MODE.set(env) + + # Override the config url values if provided. + if api_url is not None: + config._set_persistent(api_url=str(api_url)) + logger.debug(f"overriding API URL: {config.api_url}") + if deploy_url is not None: + config._set_persistent(deploy_url=str(deploy_url)) + logger.debug(f"overriding deploy URL: {config.deploy_url}") + + exec.output_system_info() + console.rule("[bold]Compiling production app and preparing for export.") + + if frontend: + with _time_phase("compile_duration"): + # Ensure module can be imported and app.compile() is called. + prerequisites.get_compiled_app( + prerender_routes=prerender_routes, trigger="export" + ) + with _time_phase("setup_duration"): + # Set up .web directory and install frontend dependencies. + build.setup_frontend(Path.cwd()) + with _time_phase("build_duration"): + build.build() + if zipping: + with _time_phase("zip_duration"): + build.zip_app( + frontend=frontend, + backend=backend, + zip_dest_dir=zip_dest_dir, + include_db_file=upload_db_file, + backend_excluded_dirs=backend_excluded_dirs, + ) except Exception as exc: status = "failure" detail = type(exc).__name__ diff --git a/tests/units/test_reflex.py b/tests/units/test_reflex.py index 04a088aacbe..d1125ea61f4 100644 --- a/tests/units/test_reflex.py +++ b/tests/units/test_reflex.py @@ -15,6 +15,7 @@ import psutil import pytest from pytest_mock import MockerFixture +from reflex_base import constants from reflex import reflex from reflex.testing import DEFAULT_TIMEOUT @@ -963,3 +964,111 @@ def test_db_commands_run_without_sqlmodel( assert result.exit_code == 0, result.output assert "pip install reflex[db]" not in caplog.text + + +@pytest.fixture +def patched_production_startup(mocker: MockerFixture) -> dict: + """Patch process startup while retaining the production command sequence. + + Returns: + Mocked startup operations and the frontend lock context. + """ + config = mocker.patch("reflex.reflex.get_config", return_value=mocker.Mock()) + mocker.patch("reflex.reflex._skip_compile") + mocker.patch("atexit.register") + mocker.patch("reflex.utils.telemetry.send") + mocker.patch("reflex.utils.exec.notify_app_running") + mocker.patch("reflex.utils.exec.notify_frontend") + mount_frontend = mocker.patch( + "reflex.reflex.environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP" + ).set + return { + "config": config.return_value, + "mount_frontend": mount_frontend, + "lock": mocker.patch("reflex.utils.build_lock.frontend_build_lock"), + "compile": mocker.patch("reflex.reflex._compile_app"), + "setup": mocker.patch("reflex.utils.build.setup_frontend_prod"), + "backend_prod": mocker.patch("reflex.utils.exec.run_backend_prod"), + "backend_preview": mocker.patch("reflex.utils.exec.run_backend"), + "frontend": mocker.patch("reflex.utils.exec.run_frontend_prod"), + } + + +@pytest.mark.parametrize("runner_name", ["_run_prod", "_run_preview"]) +@pytest.mark.parametrize( + "running_mode", + [constants.RunningMode.FULLSTACK, constants.RunningMode.FRONTEND_ONLY], +) +def test_production_startup_locks_compile_and_build_before_serving( + patched_production_startup, runner_name: str, running_mode: constants.RunningMode +): + """Only compile and build hold the lock; serving starts after release.""" + context = patched_production_startup["lock"].return_value + + def require_lock(*args, **kwargs): + """Compilation and setup share the same unreleased lock.""" + context.__enter__.assert_called_once() + context.__exit__.assert_not_called() + + def require_release(*args, **kwargs): + """Serving must not retain the frontend lock for the server lifetime.""" + context.__exit__.assert_called_once_with(None, None, None) + + patched_production_startup["compile"].side_effect = require_lock + patched_production_startup["setup"].side_effect = require_lock + patched_production_startup["config"]._set_persistent.side_effect = require_lock + patched_production_startup["mount_frontend"].side_effect = require_lock + for server in ("backend_prod", "backend_preview", "frontend"): + patched_production_startup[server].side_effect = require_release + + getattr(reflex, runner_name)(running_mode, 8000, "127.0.0.1") + + patched_production_startup["lock"].assert_called_once() + patched_production_startup["setup"].assert_called_once() + patched_production_startup["config"]._set_persistent.assert_called_once_with( + frontend_port=8000, backend_port=8000 + ) + server = ( + "frontend" + if running_mode == constants.RunningMode.FRONTEND_ONLY + else "backend_prod" + if runner_name == "_run_prod" + else "backend_preview" + ) + patched_production_startup[server].assert_called_once() + + +@pytest.mark.parametrize("runner_name", ["_run_prod", "_run_preview"]) +@pytest.mark.parametrize("failing_target", ["compile", "setup"]) +def test_production_startup_releases_lock_after_failed_build( + patched_production_startup, runner_name: str, failing_target: str +): + """A startup failure releases the lock and does not start a server.""" + error = RuntimeError("build failed") + patched_production_startup[failing_target].side_effect = error + with pytest.raises(RuntimeError, match="build failed"): + getattr(reflex, runner_name)(constants.RunningMode.FULLSTACK, 8000, "127.0.0.1") + + context = patched_production_startup["lock"].return_value + context.__enter__.assert_called_once() + context.__exit__.assert_called_once() + assert context.__exit__.call_args.args[:2] == (RuntimeError, error) + for server in ("backend_prod", "backend_preview", "frontend"): + patched_production_startup[server].assert_not_called() + + +@pytest.mark.parametrize("runner_name", ["_run_prod", "_run_preview"]) +def test_backend_only_startup_does_not_lock_frontend( + patched_production_startup, runner_name: str +): + """Backend-only startup does not access the frontend working directory.""" + getattr(reflex, runner_name)(constants.RunningMode.BACKEND_ONLY, 8000, "127.0.0.1") + + patched_production_startup["lock"].assert_not_called() + patched_production_startup["compile"].assert_not_called() + patched_production_startup["setup"].assert_not_called() + patched_production_startup["config"]._set_persistent.assert_called_once_with( + frontend_port=8000, backend_port=8000 + ) + if runner_name == "_run_preview": + patched_production_startup["mount_frontend"].assert_called_once_with(False) diff --git a/tests/units/utils/test_build_lock.py b/tests/units/utils/test_build_lock.py new file mode 100644 index 00000000000..91862687f15 --- /dev/null +++ b/tests/units/utils/test_build_lock.py @@ -0,0 +1,183 @@ +"""Regression coverage for the shared production frontend workspace lock.""" + +from __future__ import annotations + +import errno +import multiprocessing +import os +import sys +from types import SimpleNamespace + +import pytest + +from reflex.utils import build_lock + + +def _acquire_spawned_build_lock(web_dir, attempted, entered): + """Acquire a workspace lock from an independent interpreter. + + Args: + web_dir: Shared workspace path. + attempted: Event set before attempting acquisition. + entered: Event set after acquisition. + """ + attempted.set() + with build_lock.frontend_build_lock(web_dir): + entered.set() + + +def test_spawned_build_lock_serializes(tmp_path): + """Independent processes serialize on every platform; nested calls are reentrant.""" + context = multiprocessing.get_context("spawn") + attempted, entered = context.Event(), context.Event() + child = context.Process( + target=_acquire_spawned_build_lock, args=(tmp_path, attempted, entered) + ) + try: + with ( + build_lock.frontend_build_lock(tmp_path), + build_lock.frontend_build_lock(tmp_path / "."), + ): + child.start() + assert attempted.wait(15) + assert not entered.wait(0.3) + assert entered.wait(15) + finally: + if child.pid is not None: + child.join(15) + if child.is_alive(): + child.terminate() + child.join(5) + assert child.exitcode == 0 + + +def test_lock_is_released_after_failure(tmp_path): + """An exception inside the locked block releases the lock for the next caller.""" + with ( + pytest.raises(RuntimeError, match="boom"), + build_lock.frontend_build_lock(tmp_path), + ): + msg = "boom" + raise RuntimeError(msg) + assert getattr(build_lock._lock_state, "paths", set()) == set() + with build_lock.frontend_build_lock(tmp_path): + assert tmp_path.resolve() in build_lock._lock_state.paths + + +def test_windows_lock_waits_for_contention_and_releases(tmp_path, monkeypatch, mocker): + """Windows retries contention beyond ten attempts and releases after errors.""" + locking = mocker.Mock( + side_effect=[*[OSError(errno.EACCES, "busy")] * 11, None, None] + ) + sleep = mocker.patch("time.sleep") + with ( + monkeypatch.context() as patch, + pytest.raises(RuntimeError, match="build failed"), + ): + patch.setattr(build_lock.sys, "platform", "win32") + patch.setitem( + sys.modules, + "msvcrt", + SimpleNamespace(locking=locking, LK_NBLCK=2, LK_UNLCK=0), + ) + with build_lock.frontend_build_lock(tmp_path): + assert locking.call_count == 12 + message = "build failed" + raise RuntimeError(message) + assert locking.call_count == 13 + assert locking.call_args.args[1:] == (0, 1) + assert sleep.call_count == 11 + + +def test_windows_lock_failure_stops_workspace_access(tmp_path, monkeypatch, mocker): + """Unexpected Windows lock failures propagate without entering the workspace.""" + locking = mocker.Mock(side_effect=OSError(errno.EBADF, "invalid descriptor")) + with ( + monkeypatch.context() as patch, + pytest.raises(OSError, match="invalid descriptor"), + ): + patch.setattr(build_lock.sys, "platform", "win32") + patch.setitem( + sys.modules, + "msvcrt", + SimpleNamespace(locking=locking, LK_NBLCK=2, LK_UNLCK=0), + ) + with build_lock.frontend_build_lock(tmp_path): + pytest.fail("Entered workspace without its lock") + + +@pytest.mark.skipif(os.name == "nt", reason="Requires POSIX symlinks") +def test_lock_symlink_cannot_bypass_serialization(tmp_path): + """An unsafe lock path stops the caller instead of proceeding without exclusion.""" + web = tmp_path / ".web" + web.mkdir() + external = tmp_path / "external-lock" + external.write_text("keep") + previous_mode = external.stat().st_mode + (web / ".reflex-build.lock").symlink_to(external) + with pytest.raises(OSError), build_lock.frontend_build_lock(web): + pytest.fail("Entered workspace through a symlinked lock") + assert external.read_text() == "keep" + assert external.stat().st_mode == previous_mode + + +@pytest.mark.skipif(os.name == "nt", reason="Requires flock") +def test_lock_failure_propagates(tmp_path, mocker): + """Lock acquisition failure must propagate before touching the workspace.""" + mocker.patch("fcntl.flock", side_effect=OSError("lock unavailable")) + with ( + pytest.raises(OSError, match="lock unavailable"), + build_lock.frontend_build_lock(tmp_path), + ): + pytest.fail("Entered workspace without its lock") + + +@pytest.mark.skipif(os.name == "nt", reason="Requires fork and flock") +def test_forked_child_does_not_inherit_lock_ownership(tmp_path): + """Nested calls are reentrant, but a forked child must wait for its parent.""" + context = multiprocessing.get_context("fork") + attempted = context.Event() + entered = context.Event() + + def acquire_in_child(): + attempted.set() + with build_lock.frontend_build_lock(tmp_path): + entered.set() + + child = context.Process(target=acquire_in_child) + try: + with ( + build_lock.frontend_build_lock(tmp_path), + build_lock.frontend_build_lock(tmp_path / "."), + ): + child.start() + assert attempted.wait(10) + assert not entered.wait(0.3) + assert entered.wait(10) + finally: + if child.pid is not None: + child.join(10) + if child.is_alive(): + child.terminate() + child.join(5) + assert child.exitcode == 0 + + +@pytest.mark.skipif(os.name == "nt", reason="Requires raw fork") +def test_forked_child_can_leave_inherited_lock_context(tmp_path): + """A child leaving its parent's context must not operate on closed descriptors.""" + pid = None + child_status = 0 + try: + with build_lock.frontend_build_lock(tmp_path): + pid = os.fork() + except OSError: + if pid != 0: + raise + child_status = 1 + finally: + if pid == 0: + os._exit(child_status) + assert pid is not None + _, status = os.waitpid(pid, 0) + assert os.waitstatus_to_exitcode(status) == 0 diff --git a/tests/units/utils/test_export.py b/tests/units/utils/test_export.py index 9776eb0c043..f12b3eacb3e 100644 --- a/tests/units/utils/test_export.py +++ b/tests/units/utils/test_export.py @@ -2,10 +2,17 @@ from __future__ import annotations +import multiprocessing +import os +import zipfile +from pathlib import Path +from unittest.mock import Mock, patch + import pytest from pytest_mock import MockerFixture +from reflex_base import constants -from reflex.utils import export +from reflex.utils import build_lock, export @pytest.fixture @@ -16,6 +23,9 @@ def patched_export(mocker: MockerFixture) -> dict: Dict of patched mocks keyed by short name. """ return { + "frontend_build_lock": mocker.patch( + "reflex.utils.build_lock.frontend_build_lock" + ), "get_compiled_app": mocker.patch( "reflex.utils.export.prerequisites.get_compiled_app" ), @@ -27,8 +37,8 @@ def patched_export(mocker: MockerFixture) -> dict: "reflex.utils.export.exec.output_system_info" ), "env_mode_set": mocker.patch( - "reflex.utils.export.environment.REFLEX_ENV_MODE.set" - ), + "reflex.utils.export.environment.REFLEX_ENV_MODE" + ).set, "get_config": mocker.patch( "reflex.utils.export.get_config", return_value=mocker.Mock() ), @@ -126,3 +136,148 @@ def test_export_no_zip_emits_only_compile_and_build_durations(patched_export): assert isinstance(kwargs["setup_duration"], float) assert isinstance(kwargs["build_duration"], float) assert kwargs["zip_duration"] is None + + +@pytest.mark.parametrize( + ("frontend", "zipping", "phases"), + [ + (True, True, ["get_compiled_app", "setup_frontend", "build", "zip_app"]), + (True, False, ["get_compiled_app", "setup_frontend", "build"]), + ], +) +def test_export_holds_frontend_lock_through_packaging( + patched_export, frontend: bool, zipping: bool, phases: list[str] +): + """Generated inputs and both archives belong to one locked export.""" + lock = patched_export["frontend_build_lock"] + context = lock.return_value + + def require_lock(*args, **kwargs): + """Require each export phase to run before the lock is released.""" + context.__enter__.assert_called_once() + context.__exit__.assert_not_called() + + for phase in phases: + patched_export[phase].side_effect = require_lock + patched_export["env_mode_set"].side_effect = require_lock + config = patched_export["get_config"].return_value + config._set_persistent.side_effect = require_lock + + export.export( + frontend=frontend, + zipping=zipping, + api_url="https://api.example.com", + deploy_url="https://app.example.com", + ) + + lock.assert_called_once_with(export.prerequisites.get_web_dir()) + context.__exit__.assert_called_once_with(None, None, None) + assert config._set_persistent.call_count == 2 + + +@pytest.mark.parametrize( + ("workspace_exists", "backend", "zipping", "needs_lock"), + [ + (False, True, True, False), + (True, True, True, True), + (True, False, True, False), + (True, True, False, False), + ], +) +def test_backend_only_export_locks_existing_workspace_for_packaging( + patched_export, tmp_path, mocker, workspace_exists, backend, zipping, needs_lock +): + """Backend packaging locks existing generated files without creating a workspace.""" + web = tmp_path / ".web" + if workspace_exists: + web.mkdir() + mocker.patch.object(export.prerequisites, "get_web_dir", return_value=web) + + export.export(frontend=False, backend=backend, zipping=zipping) + + lock = patched_export["frontend_build_lock"] + if needs_lock: + lock.assert_called_once_with(web) + lock.return_value.__exit__.assert_called_once_with(None, None, None) + else: + lock.assert_not_called() + assert web.exists() == workspace_exists + + +def _export_backend_in_process(root: Path, attempted, finished): + """Create a real backend archive from an independent interpreter. + + Args: + root: The application root containing the shared frontend workspace. + attempted: Event set immediately before exporting. + finished: Event set after the backend archive has been written. + """ + os.chdir(root) + with ( + patch.object(export.prerequisites, "get_web_dir", return_value=root / ".web"), + patch.object(export, "get_config", return_value=Mock()), + patch.object(export.exec, "output_system_info"), + patch.object(export.telemetry, "send"), + ): + attempted.set() + export.export(frontend=False, zip_dest_dir=str(root)) + finished.set() + + +def test_backend_archive_waits_for_frontend_compilation(tmp_path): + """Backend ZIPs wait for the compiler to publish its temporary stateful marker.""" + web = tmp_path / ".web" + backend = web / constants.Dirs.BACKEND + backend.mkdir(parents=True) + marker = backend / constants.Dirs.STATEFUL_PAGES + marker.write_text('["old"]') + temporary = marker.with_suffix(".tmp") + temporary.write_text('["new"]') + (tmp_path / "app.py").write_text("# Backend source\n", newline="\r\n") + context = multiprocessing.get_context("spawn") + attempted, finished = context.Event(), context.Event() + child = context.Process( + target=_export_backend_in_process, args=(tmp_path, attempted, finished) + ) + try: + with build_lock.frontend_build_lock(web): + child.start() + assert attempted.wait(15) + assert not finished.wait(0.5), "Backend ZIP raced with frontend compilation" + temporary.replace(marker) + assert finished.wait(15) + finally: + if child.pid is not None: + child.join(15) + if child.is_alive(): + child.terminate() + child.join(5) + assert child.exitcode == 0 + + with zipfile.ZipFile(tmp_path / constants.ComponentName.BACKEND.zip()) as archive: + assert archive.read(marker.relative_to(tmp_path).as_posix()) == b'["new"]' + assert temporary.relative_to(tmp_path).as_posix() not in archive.namelist() + assert archive.read("app.py") == (tmp_path / "app.py").read_bytes() + + +@pytest.mark.parametrize( + "failing_target", ["get_compiled_app", "setup_frontend", "build", "zip_app"] +) +def test_export_releases_frontend_lock_before_failure_telemetry( + patched_export, failing_target: str +): + """A failed export releases shared files before reporting its failure.""" + context = patched_export["frontend_build_lock"].return_value + error = RuntimeError("export failed") + patched_export[failing_target].side_effect = error + + def require_release(*args, **kwargs): + """Telemetry runs outside the frontend lock even on failure.""" + context.__enter__.assert_called_once() + context.__exit__.assert_called_once() + + patched_export["send"].side_effect = require_release + with pytest.raises(RuntimeError, match="export failed"): + export.export() + + assert context.__exit__.call_args.args[:2] == (RuntimeError, error) From a639b013a4da32f119b71ed6669c3b22a9596920 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:58:39 +0000 Subject: [PATCH 2/2] Drop the PERF203 noqa that ruff no longer reports PERF203 only applies before Python 3.11, where try/except in a loop costs per iteration. With ruff targeting py311 since #7449 the directive is unused, which RUF100 rejects. Claude-Session: https://claude.ai/code/session_01PXcFKAgnzhgdnSvhuCmxWE --- reflex/utils/build_lock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reflex/utils/build_lock.py b/reflex/utils/build_lock.py index 0b0d45c06d0..a18754e1eca 100644 --- a/reflex/utils/build_lock.py +++ b/reflex/utils/build_lock.py @@ -53,7 +53,7 @@ def _lock_descriptor(descriptor: int, *, unlock: bool = False) -> None: while True: try: msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1) - except OSError as error: # noqa: PERF203 - retry a contended OS lock + except OSError as error: if error.errno != errno.EACCES: raise # Poll every 100 ms; builds can exceed LK_LOCK's ten-second limit.