From 7c17f79cfef03d30f8d9d206ece5b2a134393a7e Mon Sep 17 00:00:00 2001 From: Johannes Gezachew Date: Sat, 3 Oct 2026 22:22:07 +0400 Subject: [PATCH 1/3] fix: rebuild a stateful-pages marker that is not valid UTF-8 _read_stateful_pages_marker() decoded the marker with Path.read_text() before json saw it, and caught only JSONDecodeError. A marker holding invalid UTF-8 therefore raised UnicodeDecodeError inside the backend worker, and since the marker stayed in place every later start failed the same way. Read the marker as bytes and let json.loads decode it, catching ValueError so both corrupt JSON and a bad encoding fall back to a full page evaluation. --- news/+stateful-pages-marker-decode.bugfix.md | 1 + reflex/compiler/compiler.py | 8 +++++--- tests/units/test_app.py | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 news/+stateful-pages-marker-decode.bugfix.md diff --git a/news/+stateful-pages-marker-decode.bugfix.md b/news/+stateful-pages-marker-decode.bugfix.md new file mode 100644 index 00000000000..5cb5579a25e --- /dev/null +++ b/news/+stateful-pages-marker-decode.bugfix.md @@ -0,0 +1 @@ +A `stateful_pages.json` marker that is not valid UTF-8 is now treated as corrupt and rebuilt, instead of raising `UnicodeDecodeError` on every backend start. diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index e232cea987b..ae00c74b8c8 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -1210,7 +1210,7 @@ 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. - A missing marker or one truncated by an older writer requires full page + A missing marker or one corrupted by an older writer requires full page evaluation. New writers replace the marker atomically. Returns: @@ -1218,8 +1218,10 @@ def _read_stateful_pages_marker() -> list[str] | None: """ marker = prerequisites.get_backend_dir() / constants.Dirs.STATEFUL_PAGES try: - return json.loads(marker.read_text()) - except (FileNotFoundError, json.JSONDecodeError): + return json.loads(marker.read_bytes()) + except (FileNotFoundError, ValueError): + # ValueError covers both JSONDecodeError and the UnicodeDecodeError of a + # marker that is not valid UTF-8. return None except PermissionError: if constants.IS_WINDOWS: diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 85eaeca933b..452b2ddcebf 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -5498,6 +5498,23 @@ def test_compile_dry_run_preserves_stateful_marker(compilable_app, mocker, exist assert marker.read_text() == '["previous"]' +@pytest.mark.parametrize( + "content", + [ + pytest.param(b'["index"', id="truncated"), + pytest.param(b"\xff\xfe garbage", id="not-utf-8"), + ], +) +def test_read_stateful_pages_marker_rejects_corrupt_marker( + tmp_path: Path, mocker: MockerFixture, content: bytes +): + """A corrupt marker asks for a full page evaluation instead of raising.""" + mocker.patch("reflex.utils.prerequisites.get_backend_dir", return_value=tmp_path) + (tmp_path / constants.Dirs.STATEFUL_PAGES).write_bytes(content) + + assert _read_stateful_pages_marker() is None + + def test_write_stateful_pages_marker_concurrent_readers_see_valid_json( tmp_path: Path, mocker: MockerFixture ): From ed0db4f0248a23732a26292bfd119baeeb234baa Mon Sep 17 00:00:00 2001 From: Johannes Gezachew Date: Sat, 3 Oct 2026 22:26:30 +0400 Subject: [PATCH 2/3] test: cover the undecodable marker next to the other marker tests test_read_stateful_pages_marker_sharing_violation patched Path.read_text, which the reader no longer calls, so the POSIX branch stopped seeing PermissionError. Patch read_bytes instead, and keep the undecodable-marker case in test_compiler.py beside the truncated one rather than in test_app.py. --- tests/units/compiler/test_compiler.py | 9 ++++++++- tests/units/test_app.py | 17 ----------------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index c3c1f9a3901..9f4b357d13c 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -42,11 +42,18 @@ def test_read_stateful_pages_marker_recovers_legacy_corruption( assert compiler._read_stateful_pages_marker() is None +def test_read_stateful_pages_marker_recovers_undecodable_marker(tmp_path, mocker): + """A marker that is not valid UTF-8 requests full page evaluation.""" + mocker.patch("reflex.utils.prerequisites.get_backend_dir", return_value=tmp_path) + (tmp_path / constants.Dirs.STATEFUL_PAGES).write_bytes(b"\xff\xfe garbage") + 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) + mocker.patch.object(Path, "read_bytes", side_effect=PermissionError) if windows: assert compiler._read_stateful_pages_marker() is None else: diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 452b2ddcebf..85eaeca933b 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -5498,23 +5498,6 @@ def test_compile_dry_run_preserves_stateful_marker(compilable_app, mocker, exist assert marker.read_text() == '["previous"]' -@pytest.mark.parametrize( - "content", - [ - pytest.param(b'["index"', id="truncated"), - pytest.param(b"\xff\xfe garbage", id="not-utf-8"), - ], -) -def test_read_stateful_pages_marker_rejects_corrupt_marker( - tmp_path: Path, mocker: MockerFixture, content: bytes -): - """A corrupt marker asks for a full page evaluation instead of raising.""" - mocker.patch("reflex.utils.prerequisites.get_backend_dir", return_value=tmp_path) - (tmp_path / constants.Dirs.STATEFUL_PAGES).write_bytes(content) - - assert _read_stateful_pages_marker() is None - - def test_write_stateful_pages_marker_concurrent_readers_see_valid_json( tmp_path: Path, mocker: MockerFixture ): From 72c880297aa76ef97ed622f9920009491fc00742 Mon Sep 17 00:00:00 2001 From: Johannes Gezachew Date: Sat, 3 Oct 2026 22:28:33 +0400 Subject: [PATCH 3/3] test: use a BOM-less payload for the undecodable marker json.loads() detects a UTF-16 BOM and decodes the bytes, so the old payload raised JSONDecodeError and never reached the UnicodeDecodeError branch the fix is about. --- tests/units/compiler/test_compiler.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 9f4b357d13c..b644a73f842 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -45,7 +45,8 @@ def test_read_stateful_pages_marker_recovers_legacy_corruption( def test_read_stateful_pages_marker_recovers_undecodable_marker(tmp_path, mocker): """A marker that is not valid UTF-8 requests full page evaluation.""" mocker.patch("reflex.utils.prerequisites.get_backend_dir", return_value=tmp_path) - (tmp_path / constants.Dirs.STATEFUL_PAGES).write_bytes(b"\xff\xfe garbage") + # No BOM: json.loads() would decode a UTF-16 one and raise JSONDecodeError. + (tmp_path / constants.Dirs.STATEFUL_PAGES).write_bytes(b"\xff garbage") assert compiler._read_stateful_pages_marker() is None