From d959afc7b9aabad0315e5bcedf0503afcfd299e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 20:35:06 +0000 Subject: [PATCH 1/7] Fix stateful pages marker race with multiple backend workers In prod backend-only mode with several Granian workers, every worker takes the "evaluate all pages" path when .web is absent and writes .web/backend/stateful_pages.json with mode "w", truncating it. A worker starting slightly later saw the backend dir, read an empty or partial marker, and died with JSONDecodeError. Write the marker to a temporary file in the same directory and swap it into place with Path.replace so readers only ever see a complete file. Read the marker with a single read_text call and treat FileNotFoundError as "no marker yet", falling through to evaluating all pages, which also closes the window between one worker creating the backend dir and swapping its marker in. The marker is now always written, including for stateless apps, so that fall-through does not slow their startup. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GSjqov3yBj4cBasJqzyrrQ --- news/+stateful-pages-marker-race.bugfix.md | 1 + reflex/app.py | 32 +++++++-- reflex/compiler/compiler.py | 34 ++++++--- tests/units/compiler/test_compiler.py | 32 ++++++++- tests/units/test_app.py | 80 ++++++++++++++++++++++ 5 files changed, 162 insertions(+), 17 deletions(-) create mode 100644 news/+stateful-pages-marker-race.bugfix.md diff --git a/news/+stateful-pages-marker-race.bugfix.md b/news/+stateful-pages-marker-race.bugfix.md new file mode 100644 index 00000000000..32e5d78563e --- /dev/null +++ b/news/+stateful-pages-marker-race.bugfix.md @@ -0,0 +1 @@ +Fix a startup race in backend-only mode with multiple workers where a worker could read a truncated `.web/backend/stateful_pages.json` and crash with `JSONDecodeError`. The marker is now written atomically, and a worker that finds no marker evaluates all pages instead of assuming there are none. diff --git a/reflex/app.py b/reflex/app.py index f378a74ab9f..363eba39915 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -12,7 +12,9 @@ import json import logging import operator +import os import sys +import tempfile import time import traceback import urllib.parse @@ -25,6 +27,7 @@ Sequence, ) from contextvars import Token +from pathlib import Path from types import SimpleNamespace from typing import TYPE_CHECKING, Any, overload @@ -1714,14 +1717,29 @@ def _compile( clear_hash_caches() def _write_stateful_pages_marker(self): - """Write list of routes that create dynamic states for the backend to use later.""" - if self._state is not None: - stateful_pages_marker = ( - prerequisites.get_backend_dir() / constants.Dirs.STATEFUL_PAGES - ) - stateful_pages_marker.parent.mkdir(parents=True, exist_ok=True) - with stateful_pages_marker.open("w") as f: + """Write list of routes that create dynamic states for the backend to use later. + + Multiple backend workers may write the marker at the same time, so the + content is written to a temporary file and swapped into place with + ``Path.replace`` to ensure readers only ever see a complete marker. + """ + stateful_pages_marker = ( + prerequisites.get_backend_dir() / constants.Dirs.STATEFUL_PAGES + ) + stateful_pages_marker.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_path = tempfile.mkstemp( + dir=stateful_pages_marker.parent, + prefix=f"{stateful_pages_marker.name}.", + suffix=".tmp", + ) + tmp_marker = Path(tmp_path) + try: + with os.fdopen(fd, "w") as f: json.dump(list(self._stateful_pages), f) + tmp_marker.replace(stateful_pages_marker) + except BaseException: + tmp_marker.unlink(missing_ok=True) + raise def add_all_routes_endpoint(self): """Add an endpoint to the app that returns all the routes.""" diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 487b3845963..8d7e8fdc7e3 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -1206,6 +1206,23 @@ def _register_plugin_routes(app: App, plugins: Sequence[Plugin]) -> None: app._register_plugin_pages(plugins) +def _read_stateful_pages_marker() -> list[str] | None: + """Read the routes that create state classes from a previous compile. + + The marker is swapped into place atomically, so it is either complete or + absent. It may be absent because no compile has happened yet or because a + concurrently starting worker has not finished writing it. + + Returns: + The stateful routes, or None if no marker has been written yet. + """ + marker = prerequisites.get_backend_dir() / constants.Dirs.STATEFUL_PAGES + try: + return json.loads(marker.read_text()) + except FileNotFoundError: + return None + + def compile_app( app: App, *, @@ -1231,15 +1248,14 @@ def compile_app( app._pages = {} should_compile = app._should_compile() - backend_dir = prerequisites.get_backend_dir() - if not dry_run and not should_compile and backend_dir.exists(): - stateful_pages_marker = backend_dir / constants.Dirs.STATEFUL_PAGES - if stateful_pages_marker.exists(): - with stateful_pages_marker.open("r") as file: - stateful_pages = json.load(file) - for route in stateful_pages: - logger.debug(f"BE Evaluating stateful page: {route}") - app._compile_page(route, save_page=False) + if not dry_run and not should_compile: + stateful_pages = _read_stateful_pages_marker() + else: + stateful_pages = None + if stateful_pages is not None: + for route in stateful_pages: + logger.debug(f"BE Evaluating stateful page: {route}") + app._compile_page(route, save_page=False) if app._state is not None: utils._restore_bundled_libraries() utils._compile_initial_state(app._state) diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 5b02be0ad80..6d5e29af9cc 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -888,7 +888,37 @@ def test_compile_registers_plugin_routes_on_backend_early_return( if with_stateful_marker: compile_page.assert_called_once_with("plugin-page", save_page=False) else: - compile_page.assert_not_called() + compile_page.assert_any_call("plugin-page", save_page=False) + + +@pytest.mark.usefixtures("clean_registration_context") +def test_backend_compile_evaluates_all_pages_when_marker_missing( + tmp_path: Path, mocker: MockerFixture +): + """A backend dir without a complete marker falls through to evaluating every page. + + Another worker may have created the backend dir but not yet swapped its + marker into place, so a missing marker must not be mistaken for "no + stateful pages". + """ + app = rx.App(enable_state=False) + app.add_page(lambda: rx.fragment(), route="index") + mocker.patch.object(app, "_apply_decorated_pages") + mocker.patch.object(app, "_should_compile", return_value=False) + compile_page = mocker.patch.object(app, "_compile_page") + mocker.patch.object(app, "_add_optional_endpoints") + mocker.patch.object(prerequisites, "get_backend_dir", return_value=tmp_path) + mocker.patch.object( + compiler, "get_config", return_value=rx.Config(app_name="testing", plugins=[]) + ) + + assert compiler.compile_app(app, use_rich=False) is False + + assert {call.args[0] for call in compile_page.call_args_list} == { + "index", + constants.Page404.SLUG, + } + assert json.loads((tmp_path / constants.Dirs.STATEFUL_PAGES).read_text()) == [] @pytest.mark.usefixtures("clean_registration_context") diff --git a/tests/units/test_app.py b/tests/units/test_app.py index e5ead218a92..68bcff0227e 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -11,6 +11,7 @@ import multiprocessing import pickle import re +import threading import unittest.mock import uuid from collections.abc import Generator @@ -4799,3 +4800,82 @@ def test_compile_emits_stage_spans( parent = spans[name].parent assert parent is not None assert parent.span_id == root.get_span_context().span_id + + +def test_write_stateful_pages_marker_never_truncates_final_path( + tmp_path: Path, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +): + """The marker is swapped into place atomically, never opened for writing.""" + mocker.patch("reflex.utils.prerequisites.get_backend_dir", return_value=tmp_path) + marker = tmp_path / constants.Dirs.STATEFUL_PAGES + original_open = Path.open + write_opens: list[str] = [] + + def spy_open(self: Path, mode: str = "r", *args, **kwargs): + if self == marker and mode != "r": + write_opens.append(mode) + return original_open(self, mode, *args, **kwargs) + + monkeypatch.setattr(Path, "open", spy_open) + app = App(_state=rx.State) + app._stateful_pages = dict.fromkeys(["index", "about"]) + + app._write_stateful_pages_marker() + + assert write_opens == [] + assert json.loads(marker.read_text()) == ["index", "about"] + assert [p.name for p in tmp_path.iterdir()] == [constants.Dirs.STATEFUL_PAGES] + + +def test_write_stateful_pages_marker_is_always_written( + tmp_path: Path, mocker: MockerFixture +): + """Stateless apps write an empty marker so backend workers skip page evaluation.""" + mocker.patch("reflex.utils.prerequisites.get_backend_dir", return_value=tmp_path) + app = App(enable_state=False) + + app._write_stateful_pages_marker() + + assert json.loads((tmp_path / constants.Dirs.STATEFUL_PAGES).read_text()) == [] + + +def test_write_stateful_pages_marker_concurrent_readers_see_valid_json( + tmp_path: Path, mocker: MockerFixture +): + """Concurrent writers and readers of the marker never observe a partial file.""" + mocker.patch("reflex.utils.prerequisites.get_backend_dir", return_value=tmp_path) + marker = tmp_path / constants.Dirs.STATEFUL_PAGES + routes = [f"route-{i}" for i in range(4000)] + app = App(_state=rx.State) + app._stateful_pages = dict.fromkeys(routes) + stop = threading.Event() + errors: list[BaseException] = [] + + def writer(): + for _ in range(50): + app._write_stateful_pages_marker() + + def reader(): + while not stop.is_set(): + try: + content = marker.read_text() + except FileNotFoundError: + continue + try: + assert json.loads(content) == routes + except (AssertionError, json.JSONDecodeError) as exc: + errors.append(exc) + return + + writers = [threading.Thread(target=writer) for _ in range(4)] + readers = [threading.Thread(target=reader) for _ in range(4)] + for thread in readers + writers: + thread.start() + for thread in writers: + thread.join() + stop.set() + for thread in readers: + thread.join() + + assert errors == [] + assert json.loads(marker.read_text()) == routes From fcef7f46ff7630a37ee9d76e191f05f250023a0d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 20:51:16 +0000 Subject: [PATCH 2/7] Rename news fragment to PR number Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GSjqov3yBj4cBasJqzyrrQ --- news/{+stateful-pages-marker-race.bugfix.md => 7142.bugfix.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename news/{+stateful-pages-marker-race.bugfix.md => 7142.bugfix.md} (100%) diff --git a/news/+stateful-pages-marker-race.bugfix.md b/news/7142.bugfix.md similarity index 100% rename from news/+stateful-pages-marker-race.bugfix.md rename to news/7142.bugfix.md From 11b3a89edaa882d52ffe8d4ea2bbbe4f13edaf67 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Wed, 16 Sep 2026 15:40:06 -0700 Subject: [PATCH 3/7] Handle marker recovery, permissions, and dry-run review findings --- news/7142.bugfix.md | 2 +- reflex/app.py | 4 +- reflex/compiler/compiler.py | 12 ++-- tests/units/compiler/test_compiler.py | 10 ++++ tests/units/test_app.py | 79 +++++++++++++++++++++------ 5 files changed, 81 insertions(+), 26 deletions(-) diff --git a/news/7142.bugfix.md b/news/7142.bugfix.md index 32e5d78563e..735584fa951 100644 --- a/news/7142.bugfix.md +++ b/news/7142.bugfix.md @@ -1 +1 @@ -Fix a startup race in backend-only mode with multiple workers where a worker could read a truncated `.web/backend/stateful_pages.json` and crash with `JSONDecodeError`. The marker is now written atomically, and a worker that finds no marker evaluates all pages instead of assuming there are none. +Fix backend startup crashes from concurrent or truncated stateful-page marker writes. Markers are replaced atomically, remain readable by separate backend users, and are rebuilt when missing or corrupt; dry-run compilation leaves them unchanged. diff --git a/reflex/app.py b/reflex/app.py index 0059bf45273..ba9d23bec68 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -1761,10 +1761,12 @@ def _write_stateful_pages_marker(self): prefix=f"{stateful_pages_marker.name}.", suffix=".tmp", ) + os.close(fd) tmp_marker = Path(tmp_path) try: - with os.fdopen(fd, "w") as f: + with tmp_marker.open("w", encoding="utf-8") as f: json.dump(list(self._stateful_pages), f) + tmp_marker.chmod(0o644) tmp_marker.replace(stateful_pages_marker) except BaseException: tmp_marker.unlink(missing_ok=True) diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 8d7e8fdc7e3..4c470dc03a0 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -1209,17 +1209,16 @@ def _register_plugin_routes(app: App, plugins: Sequence[Plugin]) -> None: def _read_stateful_pages_marker() -> list[str] | None: """Read the routes that create state classes from a previous compile. - The marker is swapped into place atomically, so it is either complete or - absent. It may be absent because no compile has happened yet or because a - concurrently starting worker has not finished writing it. + A missing marker or one truncated by an older writer requires full page + evaluation. New writers replace the marker atomically. Returns: - The stateful routes, or None if no marker has been written yet. + The stateful routes, or None if no valid marker has been written yet. """ marker = prerequisites.get_backend_dir() / constants.Dirs.STATEFUL_PAGES try: return json.loads(marker.read_text()) - except FileNotFoundError: + except (FileNotFoundError, json.JSONDecodeError): return None @@ -1330,7 +1329,8 @@ def compile_app( app._evaluated_pages.update(compile_ctx.compiled_pages) app._stateful_pages.update(compile_ctx.stateful_routes) - app._write_stateful_pages_marker() + if not dry_run: + app._write_stateful_pages_marker() app._add_optional_endpoints() app._validate_var_dependencies() diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 31e2bc6b60c..443a36b844a 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -31,6 +31,16 @@ from reflex.utils import prerequisites +@pytest.mark.parametrize("content", ["", '["index",']) +def test_read_stateful_pages_marker_recovers_legacy_corruption( + tmp_path, mocker, content +): + """A marker truncated by an older writer requests full page evaluation.""" + mocker.patch("reflex.utils.prerequisites.get_backend_dir", return_value=tmp_path) + (tmp_path / constants.Dirs.STATEFUL_PAGES).write_text(content) + assert compiler._read_stateful_pages_marker() is None + + @pytest.mark.parametrize( ("fields", "test_default", "test_rest"), [ diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 5cc39cff8d2..2c0dc8e2a7d 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -9,12 +9,15 @@ import json import logging import multiprocessing +import os import pickle import re +import tempfile import threading import unittest.mock import uuid from collections.abc import Generator +from concurrent.futures import ThreadPoolExecutor from contextlib import nullcontext as does_not_raise from importlib.util import find_spec from pathlib import Path @@ -4989,6 +4992,50 @@ def test_write_stateful_pages_marker_is_always_written( assert json.loads((tmp_path / constants.Dirs.STATEFUL_PAGES).read_text()) == [] +@pytest.mark.skipif(os.name == "nt", reason="Unix file permissions") +def test_write_stateful_pages_marker_is_shared_readable(tmp_path, mocker): + """Backend workers running as another user can read the compiled marker.""" + mocker.patch("reflex.utils.prerequisites.get_backend_dir", return_value=tmp_path) + app = App(enable_state=False) + app._write_stateful_pages_marker() + assert (tmp_path / constants.Dirs.STATEFUL_PAGES).stat().st_mode & 0o777 == 0o644 + + +def test_write_stateful_pages_marker_closes_descriptor_on_open_failure( + tmp_path, mocker +): + """Failure to open the temporary marker must not leak its raw descriptor.""" + mocker.patch("reflex.utils.prerequisites.get_backend_dir", return_value=tmp_path) + created = mocker.spy(tempfile, "mkstemp") + mocker.patch("os.fdopen", side_effect=OSError("open failed")) + mocker.patch.object(Path, "open", side_effect=OSError("open failed")) + with pytest.raises(OSError, match="open failed"): + App(enable_state=False)._write_stateful_pages_marker() + descriptor, _ = created.spy_return + try: + with pytest.raises(OSError, match="Bad file descriptor"): + os.fstat(descriptor) + finally: + with contextlib.suppress(OSError): + os.close(descriptor) + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.parametrize("existing", [False, True]) +def test_compile_dry_run_preserves_stateful_marker(compilable_app, mocker, existing): + """A dry compile neither creates nor replaces the backend route marker.""" + app, web_dir = compilable_app + mocker.patch("reflex.utils.prerequisites.get_web_dir", return_value=web_dir) + marker = web_dir / constants.Dirs.BACKEND / constants.Dirs.STATEFUL_PAGES + if existing: + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text('["previous"]') + app._compile(dry_run=True) + assert marker.exists() == existing + if existing: + assert marker.read_text() == '["previous"]' + + def test_write_stateful_pages_marker_concurrent_readers_see_valid_json( tmp_path: Path, mocker: MockerFixture ): @@ -4999,33 +5046,29 @@ def test_write_stateful_pages_marker_concurrent_readers_see_valid_json( app = App(_state=rx.State) app._stateful_pages = dict.fromkeys(routes) stop = threading.Event() - errors: list[BaseException] = [] def writer(): + """Repeatedly replace the marker.""" for _ in range(50): app._write_stateful_pages_marker() def reader(): + """Check that every observed marker is complete.""" while not stop.is_set(): try: content = marker.read_text() except FileNotFoundError: continue - try: - assert json.loads(content) == routes - except (AssertionError, json.JSONDecodeError) as exc: - errors.append(exc) - return - - writers = [threading.Thread(target=writer) for _ in range(4)] - readers = [threading.Thread(target=reader) for _ in range(4)] - for thread in readers + writers: - thread.start() - for thread in writers: - thread.join() - stop.set() - for thread in readers: - thread.join() - - assert errors == [] + assert json.loads(content) == routes + + with ThreadPoolExecutor(max_workers=8) as pool: + readers = [pool.submit(reader) for _ in range(4)] + try: + writers = [pool.submit(writer) for _ in range(4)] + for future in writers: + future.result() + finally: + stop.set() + for future in readers: + future.result() assert json.loads(marker.read_text()) == routes From 43775863f42c19da9e207fcce8ab441ceef74188 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Wed, 16 Sep 2026 16:06:52 -0700 Subject: [PATCH 4/7] Retry transient Windows marker sharing violations --- reflex/app.py | 10 +++++++++- tests/units/test_app.py | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/reflex/app.py b/reflex/app.py index ba9d23bec68..c41c56b3577 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -1767,7 +1767,15 @@ def _write_stateful_pages_marker(self): with tmp_marker.open("w", encoding="utf-8") as f: json.dump(list(self._stateful_pages), f) tmp_marker.chmod(0o644) - tmp_marker.replace(stateful_pages_marker) + for attempt in range(100): + try: + tmp_marker.replace(stateful_pages_marker) + break + except PermissionError: + if not constants.IS_WINDOWS or attempt == 99: + raise + # Windows readers temporarily prevent replacing their open file. + time.sleep(0.01) except BaseException: tmp_marker.unlink(missing_ok=True) raise diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 2c0dc8e2a7d..a8b2dd270cf 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -5013,7 +5013,7 @@ def test_write_stateful_pages_marker_closes_descriptor_on_open_failure( App(enable_state=False)._write_stateful_pages_marker() descriptor, _ = created.spy_return try: - with pytest.raises(OSError, match="Bad file descriptor"): + with pytest.raises(OSError): os.fstat(descriptor) finally: with contextlib.suppress(OSError): @@ -5021,6 +5021,40 @@ def test_write_stateful_pages_marker_closes_descriptor_on_open_failure( assert list(tmp_path.iterdir()) == [] +@pytest.mark.parametrize("windows, failures", [(True, 1), (True, 100), (False, 1)]) +def test_write_stateful_pages_marker_sharing_violation( + tmp_path, mocker, windows, failures +): + """Windows sharing violations are retried without hiding persistent failures.""" + mocker.patch("reflex.utils.prerequisites.get_backend_dir", return_value=tmp_path) + mocker.patch("reflex.app.constants.IS_WINDOWS", windows) + sleep = mocker.patch("reflex.app.time.sleep") + original_replace = Path.replace + attempts = 0 + + def replace(path, target): + """Simulate a reader holding the Windows marker open.""" + nonlocal attempts + attempts += 1 + if attempts <= failures: + raise PermissionError("marker is open") + return original_replace(path, target) + + mocker.patch.object(Path, "replace", replace) + app = App(enable_state=False) + if windows and failures == 1: + app._write_stateful_pages_marker() + assert json.loads((tmp_path / constants.Dirs.STATEFUL_PAGES).read_text()) == [] + assert attempts == 2 + sleep.assert_called_once_with(0.01) + else: + with pytest.raises(PermissionError, match="marker is open"): + app._write_stateful_pages_marker() + assert attempts == (100 if windows else 1) + assert sleep.call_count == attempts - 1 + assert list(tmp_path.iterdir()) == [] + + @pytest.mark.parametrize("existing", [False, True]) def test_compile_dry_run_preserves_stateful_marker(compilable_app, mocker, existing): """A dry compile neither creates nor replaces the backend route marker.""" From 85743d097013bcb7fbce7d11862683395a5f4ddb Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Wed, 16 Sep 2026 16:22:02 -0700 Subject: [PATCH 5/7] Handle transient Windows marker read failures --- reflex/compiler/compiler.py | 5 +++++ tests/units/compiler/test_compiler.py | 12 ++++++++++++ tests/units/test_app.py | 26 +++++++++++++++++++------- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 4c470dc03a0..cb2d8386aed 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -1220,6 +1220,11 @@ def _read_stateful_pages_marker() -> list[str] | None: return json.loads(marker.read_text()) except (FileNotFoundError, json.JSONDecodeError): return None + except PermissionError: + if constants.IS_WINDOWS: + # A concurrent atomic replacement can temporarily block Windows readers. + return None + raise def compile_app( diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 443a36b844a..00d704bd4ef 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -41,6 +41,18 @@ def test_read_stateful_pages_marker_recovers_legacy_corruption( assert compiler._read_stateful_pages_marker() is None +@pytest.mark.parametrize("windows", [False, True]) +def test_read_stateful_pages_marker_sharing_violation(mocker, windows): + """An unavailable Windows marker requests evaluation without hiding POSIX errors.""" + mocker.patch.object(constants, "IS_WINDOWS", windows) + mocker.patch.object(Path, "read_text", side_effect=PermissionError) + if windows: + assert compiler._read_stateful_pages_marker() is None + else: + with pytest.raises(PermissionError): + compiler._read_stateful_pages_marker() + + @pytest.mark.parametrize( ("fields", "test_default", "test_rest"), [ diff --git a/tests/units/test_app.py b/tests/units/test_app.py index a8b2dd270cf..c54d50b21e6 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -72,6 +72,7 @@ from reflex.compiler.compiler import ( _compile_app, _memoize_stateful_app_wraps, + _read_stateful_pages_marker, _resolve_app_wrap_components, ) from reflex.compiler.plugins import default_page_plugins @@ -5021,7 +5022,7 @@ def test_write_stateful_pages_marker_closes_descriptor_on_open_failure( assert list(tmp_path.iterdir()) == [] -@pytest.mark.parametrize("windows, failures", [(True, 1), (True, 100), (False, 1)]) +@pytest.mark.parametrize(("windows", "failures"), [(True, 1), (True, 100), (False, 1)]) def test_write_stateful_pages_marker_sharing_violation( tmp_path, mocker, windows, failures ): @@ -5033,11 +5034,23 @@ def test_write_stateful_pages_marker_sharing_violation( attempts = 0 def replace(path, target): - """Simulate a reader holding the Windows marker open.""" + """Simulate a reader holding the Windows marker open. + + Args: + path: The temporary marker. + target: The final marker. + + Returns: + The replacement path. + + Raises: + PermissionError: While the simulated reader has the marker open. + """ nonlocal attempts attempts += 1 if attempts <= failures: - raise PermissionError("marker is open") + msg = "marker is open" + raise PermissionError(msg) return original_replace(path, target) mocker.patch.object(Path, "replace", replace) @@ -5089,11 +5102,10 @@ def writer(): def reader(): """Check that every observed marker is complete.""" while not stop.is_set(): - try: - content = marker.read_text() - except FileNotFoundError: + content = _read_stateful_pages_marker() + if content is None: continue - assert json.loads(content) == routes + assert content == routes with ThreadPoolExecutor(max_workers=8) as pool: readers = [pool.submit(reader) for _ in range(4)] From cd36cc2231dc7125d21824320c213fc7918bda8a Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Wed, 16 Sep 2026 16:38:09 -0700 Subject: [PATCH 6/7] Bound marker concurrency readers to worker startup activity --- tests/units/test_app.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index c54d50b21e6..58f07b1c6e0 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -5092,6 +5092,7 @@ def test_write_stateful_pages_marker_concurrent_readers_see_valid_json( routes = [f"route-{i}" for i in range(4000)] app = App(_state=rx.State) app._stateful_pages = dict.fromkeys(routes) + app._write_stateful_pages_marker() stop = threading.Event() def writer(): @@ -5101,7 +5102,11 @@ def writer(): def reader(): """Check that every observed marker is complete.""" - while not stop.is_set(): + # Backend workers read on startup; an infinite read storm can starve + # Windows replacement because its readers do not share delete access. + for _ in range(50): + if stop.is_set(): + break content = _read_stateful_pages_marker() if content is None: continue From 817509f4763dcfd7cdd0f9710ef8ae6705449d3b Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Wed, 16 Sep 2026 17:02:42 -0700 Subject: [PATCH 7/7] Coordinate marker readers and writers on each test round --- reflex/app.py | 1 + tests/units/test_app.py | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/reflex/app.py b/reflex/app.py index c41c56b3577..cf08b167e89 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -1775,6 +1775,7 @@ def _write_stateful_pages_marker(self): if not constants.IS_WINDOWS or attempt == 99: raise # Windows readers temporarily prevent replacing their open file. + # Wait 10 milliseconds before retrying. time.sleep(0.01) except BaseException: tmp_marker.unlink(missing_ok=True) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 58f07b1c6e0..c45e05c5502 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -5093,11 +5093,12 @@ def test_write_stateful_pages_marker_concurrent_readers_see_valid_json( app = App(_state=rx.State) app._stateful_pages = dict.fromkeys(routes) app._write_stateful_pages_marker() - stop = threading.Event() + round_started = threading.Barrier(8, timeout=10) def writer(): """Repeatedly replace the marker.""" for _ in range(50): + round_started.wait() app._write_stateful_pages_marker() def reader(): @@ -5105,8 +5106,7 @@ def reader(): # Backend workers read on startup; an infinite read storm can starve # Windows replacement because its readers do not share delete access. for _ in range(50): - if stop.is_set(): - break + round_started.wait() content = _read_stateful_pages_marker() if content is None: continue @@ -5119,7 +5119,7 @@ def reader(): for future in writers: future.result() finally: - stop.set() + round_started.abort() for future in readers: future.result() assert json.loads(marker.read_text()) == routes