From e77eb23d8051a34ed67543d8d1840b15db06d2a8 Mon Sep 17 00:00:00 2001 From: "shuwen.wu" Date: Fri, 25 Sep 2026 11:56:51 +0800 Subject: [PATCH 1/7] fix(StateManagerDisk): log error when corrupted state file is encountered Previously, a corrupted pickle file on disk would silently return None, treating it the same as a missing file. Now an error is logged with the token and path information before returning None, making corruption observable without breaking the recovery behavior of returning default state. --- news/+disk-state-corruption.bugfix.md | 1 + reflex/istate/manager/disk.py | 7 ++- tests/units/istate/manager/test_disk.py | 60 +++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 news/+disk-state-corruption.bugfix.md diff --git a/news/+disk-state-corruption.bugfix.md b/news/+disk-state-corruption.bugfix.md new file mode 100644 index 00000000000..0f31841116a --- /dev/null +++ b/news/+disk-state-corruption.bugfix.md @@ -0,0 +1 @@ +StateManagerDisk now logs an error when a corrupted state file is encountered, instead of silently resetting the state. diff --git a/reflex/istate/manager/disk.py b/reflex/istate/manager/disk.py index b6e3e38e883..36475bfc54e 100644 --- a/reflex/istate/manager/disk.py +++ b/reflex/istate/manager/disk.py @@ -157,8 +157,11 @@ async def load_state(self, token: StateToken[TOKEN_TYPE]) -> TOKEN_TYPE | None: try: with token_path.open(mode="rb") as file: return token.deserialize(fp=file) - except Exception: - pass + except Exception as e: + logger.error( + f"Failed to load state for {token!r} from {token_path}: {e!r}. " + "The corrupted state file will be replaced with a default state." + ) return None async def populate_substates( diff --git a/tests/units/istate/manager/test_disk.py b/tests/units/istate/manager/test_disk.py index fbade702eb0..6a9fdf19836 100644 --- a/tests/units/istate/manager/test_disk.py +++ b/tests/units/istate/manager/test_disk.py @@ -124,3 +124,63 @@ async def test_set_state_persists_untouched_base_state( assert isinstance(persisted_state, DiskPersistState) assert math.isclose(persisted_state.num, 9.5) await fresh_state_manager.close() + + +@pytest.mark.asyncio +async def test_load_state_logs_error_for_corrupted_file(tmp_path, monkeypatch, caplog): + """Test that load_state logs an error when a corrupted state file is encountered. + + Args: + tmp_path: A temporary directory. + monkeypatch: The pytest monkeypatch fixture. + caplog: The pytest caplog fixture. + """ + import logging + + monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) + state_manager = StateManagerDisk(_write_debounce_seconds=0) + token = StateToken(ident="client", cls=dict) + + # Write a corrupted pickle file directly to the states directory. + corrupted_content = b"not a valid pickle file" + token_path = state_manager.token_path(token) + token_path.parent.mkdir(parents=True, exist_ok=True) + token_path.write_bytes(corrupted_content) + + # load_state should return None and log an error. + result = await state_manager.load_state(token) + assert result is None + + # Verify that an error was logged. + error_logs = [r for r in caplog.records if r.levelno >= logging.ERROR] + assert len(error_logs) == 1 + assert "Failed to load state" in error_logs[0].message + assert str(token_path) in error_logs[0].message + + await state_manager.close() + + +@pytest.mark.asyncio +async def test_load_state_returns_none_for_missing_file(tmp_path, monkeypatch, caplog): + """Test that load_state returns None without logging an error for missing files. + + Args: + tmp_path: A temporary directory. + monkeypatch: The pytest monkeypatch fixture. + caplog: The pytest caplog fixture. + """ + import logging + + monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) + state_manager = StateManagerDisk(_write_debounce_seconds=0) + token = StateToken(ident="nonexistent_client", cls=dict) + + # load_state should return None without logging an error. + result = await state_manager.load_state(token) + assert result is None + + # Verify that no error was logged. + error_logs = [r for r in caplog.records if r.levelno >= logging.ERROR] + assert len(error_logs) == 0 + + await state_manager.close() From f83a09f66e837c0895ffdcc545598277f6616368 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Tue, 29 Sep 2026 08:54:15 +0800 Subject: [PATCH 2/7] fix: clarify disk state load error logs --- news/+disk-state-corruption.bugfix.md | 1 - news/7323.bugfix.md | 1 + reflex/istate/manager/disk.py | 6 ++-- tests/units/istate/manager/test_disk.py | 38 +++++++++++++++++-------- 4 files changed, 31 insertions(+), 15 deletions(-) delete mode 100644 news/+disk-state-corruption.bugfix.md create mode 100644 news/7323.bugfix.md diff --git a/news/+disk-state-corruption.bugfix.md b/news/+disk-state-corruption.bugfix.md deleted file mode 100644 index 0f31841116a..00000000000 --- a/news/+disk-state-corruption.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -StateManagerDisk now logs an error when a corrupted state file is encountered, instead of silently resetting the state. diff --git a/news/7323.bugfix.md b/news/7323.bugfix.md new file mode 100644 index 00000000000..d436ec9646a --- /dev/null +++ b/news/7323.bugfix.md @@ -0,0 +1 @@ +Disk state load failures now log the error before Reflex falls back to a default state for that load. diff --git a/reflex/istate/manager/disk.py b/reflex/istate/manager/disk.py index 36475bfc54e..934274ab233 100644 --- a/reflex/istate/manager/disk.py +++ b/reflex/istate/manager/disk.py @@ -159,8 +159,10 @@ async def load_state(self, token: StateToken[TOKEN_TYPE]) -> TOKEN_TYPE | None: return token.deserialize(fp=file) except Exception as e: logger.error( - f"Failed to load state for {token!r} from {token_path}: {e!r}. " - "The corrupted state file will be replaced with a default state." + "Failed to load state file %s: %s. " + "Falling back to a default state for this load.", + token_path.name, + type(e).__name__, ) return None diff --git a/tests/units/istate/manager/test_disk.py b/tests/units/istate/manager/test_disk.py index 6a9fdf19836..b4d068405a1 100644 --- a/tests/units/istate/manager/test_disk.py +++ b/tests/units/istate/manager/test_disk.py @@ -1,5 +1,6 @@ """Tests for the disk state manager.""" +import logging import math import os from pathlib import Path @@ -11,6 +12,9 @@ from reflex.state import BaseState from reflex.utils import prerequisites +CLIENT_ID = "client" +NONEXISTENT_CLIENT_ID = "nonexistent_client" + class DiskPersistState(BaseState): """A state for testing disk persistence.""" @@ -49,7 +53,7 @@ async def test_debounced_set_state_flushes_latest_value(tmp_path, monkeypatch): """ monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) state_manager = StateManagerDisk(_write_debounce_seconds=60) - token = StateToken(ident="client", cls=int) + token = StateToken(ident=CLIENT_ID, cls=int) await state_manager.set_state(token, 1) first_item = state_manager._write_queue[token] @@ -80,7 +84,7 @@ async def test_set_state_updates_cache_for_arbitrary_instance( """ monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) state_manager = StateManagerDisk(_write_debounce_seconds=write_debounce_seconds) - token = StateToken(ident="client", cls=dict) + token = StateToken(ident=CLIENT_ID, cls=dict) cached_state = await state_manager.get_state(token) state = {"value": 2} @@ -110,7 +114,7 @@ async def test_set_state_persists_untouched_base_state( """ monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) state_manager = StateManagerDisk(_write_debounce_seconds=write_debounce_seconds) - token = BaseStateToken(ident="client", cls=DiskPersistState) + token = BaseStateToken(ident=CLIENT_ID, cls=DiskPersistState) state = DiskPersistState(_reflex_internal_init=True) # pyright: ignore [reportCallIssue] object.__setattr__(state, "num", 9.5) state.dirty_vars.clear() @@ -135,11 +139,9 @@ async def test_load_state_logs_error_for_corrupted_file(tmp_path, monkeypatch, c monkeypatch: The pytest monkeypatch fixture. caplog: The pytest caplog fixture. """ - import logging - monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) state_manager = StateManagerDisk(_write_debounce_seconds=0) - token = StateToken(ident="client", cls=dict) + token = StateToken(ident=CLIENT_ID, cls=dict) # Write a corrupted pickle file directly to the states directory. corrupted_content = b"not a valid pickle file" @@ -152,10 +154,19 @@ async def test_load_state_logs_error_for_corrupted_file(tmp_path, monkeypatch, c assert result is None # Verify that an error was logged. - error_logs = [r for r in caplog.records if r.levelno >= logging.ERROR] + error_logs = [ + record + for record in caplog.records + if record.name == "reflex.istate.manager.disk" + and record.levelno >= logging.ERROR + ] assert len(error_logs) == 1 assert "Failed to load state" in error_logs[0].message - assert str(token_path) in error_logs[0].message + assert token_path.name in error_logs[0].message + assert token.ident not in error_logs[0].message + assert "corrupted" not in error_logs[0].message + assert "replaced" not in error_logs[0].message + assert "Falling back to a default state for this load" in error_logs[0].message await state_manager.close() @@ -169,18 +180,21 @@ async def test_load_state_returns_none_for_missing_file(tmp_path, monkeypatch, c monkeypatch: The pytest monkeypatch fixture. caplog: The pytest caplog fixture. """ - import logging - monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) state_manager = StateManagerDisk(_write_debounce_seconds=0) - token = StateToken(ident="nonexistent_client", cls=dict) + token = StateToken(ident=NONEXISTENT_CLIENT_ID, cls=dict) # load_state should return None without logging an error. result = await state_manager.load_state(token) assert result is None # Verify that no error was logged. - error_logs = [r for r in caplog.records if r.levelno >= logging.ERROR] + error_logs = [ + record + for record in caplog.records + if record.name == "reflex.istate.manager.disk" + and record.levelno >= logging.ERROR + ] assert len(error_logs) == 0 await state_manager.close() From 01a9f0fdde7efe05ca9a4331ef97780abe0314c3 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Tue, 29 Sep 2026 09:07:43 +0800 Subject: [PATCH 3/7] fix: retain sanitized disk load diagnostics --- reflex/istate/manager/disk.py | 9 ++++- tests/units/istate/manager/test_disk.py | 45 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/reflex/istate/manager/disk.py b/reflex/istate/manager/disk.py index 934274ab233..d8ac625d281 100644 --- a/reflex/istate/manager/disk.py +++ b/reflex/istate/manager/disk.py @@ -158,11 +158,18 @@ async def load_state(self, token: StateToken[TOKEN_TYPE]) -> TOKEN_TYPE | None: with token_path.open(mode="rb") as file: return token.deserialize(fp=file) except Exception as e: + exception_detail = " ".join(str(e).split()) + for secret in (str(token), token.ident): + if secret: + exception_detail = exception_detail.replace( + secret, "[redacted]" + ) logger.error( - "Failed to load state file %s: %s. " + "Failed to load state file %s: %s: %s. " "Falling back to a default state for this load.", token_path.name, type(e).__name__, + exception_detail, ) return None diff --git a/tests/units/istate/manager/test_disk.py b/tests/units/istate/manager/test_disk.py index b4d068405a1..cbc7b910e1f 100644 --- a/tests/units/istate/manager/test_disk.py +++ b/tests/units/istate/manager/test_disk.py @@ -171,6 +171,51 @@ async def test_load_state_logs_error_for_corrupted_file(tmp_path, monkeypatch, c await state_manager.close() +@pytest.mark.asyncio +async def test_load_state_logs_sanitized_exception_details( + tmp_path, monkeypatch, caplog +): + """Test that load errors keep diagnostic details without logging the token. + + Args: + tmp_path: A temporary directory. + monkeypatch: The pytest monkeypatch fixture. + caplog: The pytest caplog fixture. + """ + monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) + state_manager = StateManagerDisk(_write_debounce_seconds=0) + token = StateToken(ident=CLIENT_ID, cls=dict) + token_path = state_manager.token_path(token) + token_path.parent.mkdir(parents=True, exist_ok=True) + token_path.write_bytes(b"existing state") + + def fail_to_deserialize(cls, data=None, fp=None): + """Raise a representative missing-module error during state loading.""" + module_name = "missing_state_module" + error_message = f"No module named '{module_name}' while loading {CLIENT_ID}" + raise ModuleNotFoundError(error_message) + + monkeypatch.setattr(StateToken, "deserialize", classmethod(fail_to_deserialize)) + + assert await state_manager.load_state(token) is None + + error_logs = [ + record + for record in caplog.records + if record.name == "reflex.istate.manager.disk" + and record.levelno >= logging.ERROR + ] + assert len(error_logs) == 1 + assert "ModuleNotFoundError" in error_logs[0].message + assert "missing_state_module" in error_logs[0].message + assert token.ident not in error_logs[0].message + assert str(token) not in error_logs[0].message + assert token_path.name in error_logs[0].message + assert "Falling back to a default state for this load" in error_logs[0].message + + await state_manager.close() + + @pytest.mark.asyncio async def test_load_state_returns_none_for_missing_file(tmp_path, monkeypatch, caplog): """Test that load_state returns None without logging an error for missing files. From 256a8ec7f4e2f18f00896d3a27c2f0c8c1b64bc3 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Tue, 29 Sep 2026 10:21:24 +0800 Subject: [PATCH 4/7] fix: redact disk state load diagnostics --- reflex/istate/manager/disk.py | 3 +- tests/units/istate/manager/test_disk.py | 51 ++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/reflex/istate/manager/disk.py b/reflex/istate/manager/disk.py index d8ac625d281..cd3ebda7cc8 100644 --- a/reflex/istate/manager/disk.py +++ b/reflex/istate/manager/disk.py @@ -158,12 +158,13 @@ async def load_state(self, token: StateToken[TOKEN_TYPE]) -> TOKEN_TYPE | None: with token_path.open(mode="rb") as file: return token.deserialize(fp=file) except Exception as e: - exception_detail = " ".join(str(e).split()) + exception_detail = str(e) for secret in (str(token), token.ident): if secret: exception_detail = exception_detail.replace( secret, "[redacted]" ) + exception_detail = " ".join(exception_detail.split())[:512] logger.error( "Failed to load state file %s: %s: %s. " "Falling back to a default state for this load.", diff --git a/tests/units/istate/manager/test_disk.py b/tests/units/istate/manager/test_disk.py index cbc7b910e1f..79d0f1b789a 100644 --- a/tests/units/istate/manager/test_disk.py +++ b/tests/units/istate/manager/test_disk.py @@ -14,6 +14,8 @@ CLIENT_ID = "client" NONEXISTENT_CLIENT_ID = "nonexistent_client" +WHITESPACE_CLIENT_ID = "private session\ttoken" +OVERSIZED_MODULE_NAME = "oversized_module_name_" * 100 class DiskPersistState(BaseState): @@ -184,7 +186,7 @@ async def test_load_state_logs_sanitized_exception_details( """ monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) state_manager = StateManagerDisk(_write_debounce_seconds=0) - token = StateToken(ident=CLIENT_ID, cls=dict) + token = StateToken(ident=WHITESPACE_CLIENT_ID, cls=dict) token_path = state_manager.token_path(token) token_path.parent.mkdir(parents=True, exist_ok=True) token_path.write_bytes(b"existing state") @@ -192,7 +194,7 @@ async def test_load_state_logs_sanitized_exception_details( def fail_to_deserialize(cls, data=None, fp=None): """Raise a representative missing-module error during state loading.""" module_name = "missing_state_module" - error_message = f"No module named '{module_name}' while loading {CLIENT_ID}" + error_message = f"No module named '{module_name}' while loading {token.ident}" raise ModuleNotFoundError(error_message) monkeypatch.setattr(StateToken, "deserialize", classmethod(fail_to_deserialize)) @@ -210,12 +212,57 @@ def fail_to_deserialize(cls, data=None, fp=None): assert "missing_state_module" in error_logs[0].message assert token.ident not in error_logs[0].message assert str(token) not in error_logs[0].message + assert " ".join(token.ident.split()) not in error_logs[0].message assert token_path.name in error_logs[0].message assert "Falling back to a default state for this load" in error_logs[0].message await state_manager.close() +@pytest.mark.asyncio +async def test_load_state_caps_exception_details(tmp_path, monkeypatch, caplog): + """Test that exception details are capped before they are logged. + + Args: + tmp_path: A temporary directory. + monkeypatch: The pytest monkeypatch fixture. + caplog: The pytest caplog fixture. + """ + monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) + state_manager = StateManagerDisk(_write_debounce_seconds=0) + token = StateToken(ident=CLIENT_ID, cls=dict) + token_path = state_manager.token_path(token) + token_path.parent.mkdir(parents=True, exist_ok=True) + token_path.write_bytes(b"existing state") + module_name = OVERSIZED_MODULE_NAME + + def fail_to_deserialize(cls, data=None, fp=None): + """Raise a representative error with an oversized module name.""" + error_message = f"No module named '{module_name}'" + raise ModuleNotFoundError(error_message) + + monkeypatch.setattr(StateToken, "deserialize", classmethod(fail_to_deserialize)) + + assert await state_manager.load_state(token) is None + + error_logs = [ + record + for record in caplog.records + if record.name == "reflex.istate.manager.disk" + and record.levelno >= logging.ERROR + ] + assert len(error_logs) == 1 + prefix = f"Failed to load state file {token_path.name}: ModuleNotFoundError: " + suffix = ". Falling back to a default state for this load." + assert error_logs[0].message.startswith(prefix) + assert error_logs[0].message.endswith(suffix) + exception_detail = error_logs[0].message[len(prefix) : -len(suffix)] + assert len(exception_detail) <= 512 + assert "oversized_module_name_" in exception_detail + + await state_manager.close() + + @pytest.mark.asyncio async def test_load_state_returns_none_for_missing_file(tmp_path, monkeypatch, caplog): """Test that load_state returns None without logging an error for missing files. From a6a8368c0efd15d0c51f0bcd418b1d02866fd1c1 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Thu, 1 Oct 2026 17:44:34 +0800 Subject: [PATCH 5/7] fix(state): keep schema reloads silent and warn on disk recovery --- news/7323.bugfix.md | 2 +- reflex/istate/manager/disk.py | 9 +- tests/units/istate/manager/test_disk.py | 138 ++++++++++-------------- 3 files changed, 64 insertions(+), 85 deletions(-) diff --git a/news/7323.bugfix.md b/news/7323.bugfix.md index d436ec9646a..fe4c98ed398 100644 --- a/news/7323.bugfix.md +++ b/news/7323.bugfix.md @@ -1 +1 @@ -Disk state load failures now log the error before Reflex falls back to a default state for that load. +Unexpected disk state load failures now emit a warning before falling back to a default state. Schema changes during hot reload remain silent. diff --git a/reflex/istate/manager/disk.py b/reflex/istate/manager/disk.py index cd3ebda7cc8..03673f0d5ba 100644 --- a/reflex/istate/manager/disk.py +++ b/reflex/istate/manager/disk.py @@ -12,6 +12,7 @@ from typing import Any, Generic, cast from reflex_base.environment import state_manager_disk_debounce +from reflex_base.utils.exceptions import StateSchemaMismatchError from typing_extensions import Unpack, override from reflex.istate.manager import ( @@ -157,6 +158,8 @@ async def load_state(self, token: StateToken[TOKEN_TYPE]) -> TOKEN_TYPE | None: try: with token_path.open(mode="rb") as file: return token.deserialize(fp=file) + except StateSchemaMismatchError: + return None except Exception as e: exception_detail = str(e) for secret in (str(token), token.ident): @@ -164,10 +167,8 @@ async def load_state(self, token: StateToken[TOKEN_TYPE]) -> TOKEN_TYPE | None: exception_detail = exception_detail.replace( secret, "[redacted]" ) - exception_detail = " ".join(exception_detail.split())[:512] - logger.error( - "Failed to load state file %s: %s: %s. " - "Falling back to a default state for this load.", + logger.warning( + "Failed to load state file %s, falling back to a default state: %s(%r)", token_path.name, type(e).__name__, exception_detail, diff --git a/tests/units/istate/manager/test_disk.py b/tests/units/istate/manager/test_disk.py index 79d0f1b789a..7ddc30beb9f 100644 --- a/tests/units/istate/manager/test_disk.py +++ b/tests/units/istate/manager/test_disk.py @@ -6,17 +6,13 @@ from pathlib import Path import pytest +from reflex_base.utils.exceptions import StateSchemaMismatchError from reflex.istate.manager.disk import StateManagerDisk from reflex.istate.manager.token import BaseStateToken, StateToken from reflex.state import BaseState from reflex.utils import prerequisites -CLIENT_ID = "client" -NONEXISTENT_CLIENT_ID = "nonexistent_client" -WHITESPACE_CLIENT_ID = "private session\ttoken" -OVERSIZED_MODULE_NAME = "oversized_module_name_" * 100 - class DiskPersistState(BaseState): """A state for testing disk persistence.""" @@ -24,6 +20,31 @@ class DiskPersistState(BaseState): num: float = 3.15 +@pytest.mark.asyncio +async def test_load_state_schema_mismatch_is_silent(tmp_path, monkeypatch, caplog): + """Schema changes during hot reload should silently discard stale state. + + Args: + tmp_path: A temporary directory. + monkeypatch: The pytest monkeypatch fixture. + caplog: The pytest caplog fixture. + """ + monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) + manager = StateManagerDisk(_write_debounce_seconds=0) + token = StateToken(ident="client", cls=dict) + manager.token_path(token).write_bytes(b"stale state") + + def fail_to_deserialize(cls, data=None, fp=None): + """Raise the expected hot-reload schema mismatch.""" + raise StateSchemaMismatchError + + monkeypatch.setattr(StateToken, "deserialize", classmethod(fail_to_deserialize)) + with caplog.at_level(logging.WARNING, logger="reflex.istate.manager.disk"): + assert await manager.load_state(token) is None + assert not caplog.records + await manager.close() + + def test_states_directory_survives_chdir(tmp_path: Path, monkeypatch): """The states directory must not move when the process cwd changes. @@ -55,7 +76,7 @@ async def test_debounced_set_state_flushes_latest_value(tmp_path, monkeypatch): """ monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) state_manager = StateManagerDisk(_write_debounce_seconds=60) - token = StateToken(ident=CLIENT_ID, cls=int) + token = StateToken(ident="client", cls=int) await state_manager.set_state(token, 1) first_item = state_manager._write_queue[token] @@ -86,7 +107,7 @@ async def test_set_state_updates_cache_for_arbitrary_instance( """ monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) state_manager = StateManagerDisk(_write_debounce_seconds=write_debounce_seconds) - token = StateToken(ident=CLIENT_ID, cls=dict) + token = StateToken(ident="client", cls=dict) cached_state = await state_manager.get_state(token) state = {"value": 2} @@ -116,7 +137,7 @@ async def test_set_state_persists_untouched_base_state( """ monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) state_manager = StateManagerDisk(_write_debounce_seconds=write_debounce_seconds) - token = BaseStateToken(ident=CLIENT_ID, cls=DiskPersistState) + token = BaseStateToken(ident="client", cls=DiskPersistState) state = DiskPersistState(_reflex_internal_init=True) # pyright: ignore [reportCallIssue] object.__setattr__(state, "num", 9.5) state.dirty_vars.clear() @@ -133,8 +154,10 @@ async def test_set_state_persists_untouched_base_state( @pytest.mark.asyncio -async def test_load_state_logs_error_for_corrupted_file(tmp_path, monkeypatch, caplog): - """Test that load_state logs an error when a corrupted state file is encountered. +async def test_load_state_logs_warning_for_corrupted_file( + tmp_path, monkeypatch, caplog +): + """Test that load_state logs a warning when a corrupted state file is encountered. Args: tmp_path: A temporary directory. @@ -143,7 +166,7 @@ async def test_load_state_logs_error_for_corrupted_file(tmp_path, monkeypatch, c """ monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) state_manager = StateManagerDisk(_write_debounce_seconds=0) - token = StateToken(ident=CLIENT_ID, cls=dict) + token = StateToken(ident="client", cls=dict) # Write a corrupted pickle file directly to the states directory. corrupted_content = b"not a valid pickle file" @@ -151,24 +174,22 @@ async def test_load_state_logs_error_for_corrupted_file(tmp_path, monkeypatch, c token_path.parent.mkdir(parents=True, exist_ok=True) token_path.write_bytes(corrupted_content) - # load_state should return None and log an error. + # load_state should return None and log a warning. result = await state_manager.load_state(token) assert result is None # Verify that an error was logged. - error_logs = [ + warning_logs = [ record for record in caplog.records if record.name == "reflex.istate.manager.disk" - and record.levelno >= logging.ERROR + and record.levelno >= logging.WARNING ] - assert len(error_logs) == 1 - assert "Failed to load state" in error_logs[0].message - assert token_path.name in error_logs[0].message - assert token.ident not in error_logs[0].message - assert "corrupted" not in error_logs[0].message - assert "replaced" not in error_logs[0].message - assert "Falling back to a default state for this load" in error_logs[0].message + assert len(warning_logs) == 1 + assert "Failed to load state" in warning_logs[0].message + assert token_path.name in warning_logs[0].message + assert token.ident not in warning_logs[0].message + assert "falling back to a default state" in warning_logs[0].message await state_manager.close() @@ -186,7 +207,7 @@ async def test_load_state_logs_sanitized_exception_details( """ monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) state_manager = StateManagerDisk(_write_debounce_seconds=0) - token = StateToken(ident=WHITESPACE_CLIENT_ID, cls=dict) + token = StateToken(ident="private session\ttoken", cls=dict) token_path = state_manager.token_path(token) token_path.parent.mkdir(parents=True, exist_ok=True) token_path.write_bytes(b"existing state") @@ -201,64 +222,21 @@ def fail_to_deserialize(cls, data=None, fp=None): assert await state_manager.load_state(token) is None - error_logs = [ - record - for record in caplog.records - if record.name == "reflex.istate.manager.disk" - and record.levelno >= logging.ERROR - ] - assert len(error_logs) == 1 - assert "ModuleNotFoundError" in error_logs[0].message - assert "missing_state_module" in error_logs[0].message - assert token.ident not in error_logs[0].message - assert str(token) not in error_logs[0].message - assert " ".join(token.ident.split()) not in error_logs[0].message - assert token_path.name in error_logs[0].message - assert "Falling back to a default state for this load" in error_logs[0].message - - await state_manager.close() - - -@pytest.mark.asyncio -async def test_load_state_caps_exception_details(tmp_path, monkeypatch, caplog): - """Test that exception details are capped before they are logged. - - Args: - tmp_path: A temporary directory. - monkeypatch: The pytest monkeypatch fixture. - caplog: The pytest caplog fixture. - """ - monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) - state_manager = StateManagerDisk(_write_debounce_seconds=0) - token = StateToken(ident=CLIENT_ID, cls=dict) - token_path = state_manager.token_path(token) - token_path.parent.mkdir(parents=True, exist_ok=True) - token_path.write_bytes(b"existing state") - module_name = OVERSIZED_MODULE_NAME - - def fail_to_deserialize(cls, data=None, fp=None): - """Raise a representative error with an oversized module name.""" - error_message = f"No module named '{module_name}'" - raise ModuleNotFoundError(error_message) - - monkeypatch.setattr(StateToken, "deserialize", classmethod(fail_to_deserialize)) - - assert await state_manager.load_state(token) is None - - error_logs = [ + warning_logs = [ record for record in caplog.records if record.name == "reflex.istate.manager.disk" - and record.levelno >= logging.ERROR + and record.levelno >= logging.WARNING ] - assert len(error_logs) == 1 - prefix = f"Failed to load state file {token_path.name}: ModuleNotFoundError: " - suffix = ". Falling back to a default state for this load." - assert error_logs[0].message.startswith(prefix) - assert error_logs[0].message.endswith(suffix) - exception_detail = error_logs[0].message[len(prefix) : -len(suffix)] - assert len(exception_detail) <= 512 - assert "oversized_module_name_" in exception_detail + assert len(warning_logs) == 1 + assert warning_logs[0].levelno == logging.WARNING + assert "ModuleNotFoundError" in warning_logs[0].message + assert "missing_state_module" in warning_logs[0].message + assert token.ident not in warning_logs[0].message + assert str(token) not in warning_logs[0].message + assert " ".join(token.ident.split()) not in warning_logs[0].message + assert token_path.name in warning_logs[0].message + assert "falling back to a default state" in warning_logs[0].message await state_manager.close() @@ -274,19 +252,19 @@ async def test_load_state_returns_none_for_missing_file(tmp_path, monkeypatch, c """ monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) state_manager = StateManagerDisk(_write_debounce_seconds=0) - token = StateToken(ident=NONEXISTENT_CLIENT_ID, cls=dict) + token = StateToken(ident="nonexistent_client", cls=dict) # load_state should return None without logging an error. result = await state_manager.load_state(token) assert result is None # Verify that no error was logged. - error_logs = [ + warning_logs = [ record for record in caplog.records if record.name == "reflex.istate.manager.disk" - and record.levelno >= logging.ERROR + and record.levelno >= logging.WARNING ] - assert len(error_logs) == 0 + assert len(warning_logs) == 0 await state_manager.close() From 6a7c37e0c6ddc0342131d9192c47d2660e4ef053 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Thu, 1 Oct 2026 17:45:27 +0800 Subject: [PATCH 6/7] test(state): share disk warning assertions --- tests/units/istate/manager/test_disk.py | 46 +++++++++++++------------ 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/tests/units/istate/manager/test_disk.py b/tests/units/istate/manager/test_disk.py index 7ddc30beb9f..979b8ac9428 100644 --- a/tests/units/istate/manager/test_disk.py +++ b/tests/units/istate/manager/test_disk.py @@ -14,6 +14,23 @@ from reflex.utils import prerequisites +def disk_warning_logs(caplog: pytest.LogCaptureFixture) -> list[logging.LogRecord]: + """Return warning records from the disk manager. + + Args: + caplog: The pytest log capture fixture. + + Returns: + Captured disk manager warning records. + """ + return [ + record + for record in caplog.records + if record.name == "reflex.istate.manager.disk" + and record.levelno >= logging.WARNING + ] + + class DiskPersistState(BaseState): """A state for testing disk persistence.""" @@ -178,13 +195,8 @@ async def test_load_state_logs_warning_for_corrupted_file( result = await state_manager.load_state(token) assert result is None - # Verify that an error was logged. - warning_logs = [ - record - for record in caplog.records - if record.name == "reflex.istate.manager.disk" - and record.levelno >= logging.WARNING - ] + # Verify that a warning was logged. + warning_logs = disk_warning_logs(caplog) assert len(warning_logs) == 1 assert "Failed to load state" in warning_logs[0].message assert token_path.name in warning_logs[0].message @@ -222,12 +234,7 @@ def fail_to_deserialize(cls, data=None, fp=None): assert await state_manager.load_state(token) is None - warning_logs = [ - record - for record in caplog.records - if record.name == "reflex.istate.manager.disk" - and record.levelno >= logging.WARNING - ] + warning_logs = disk_warning_logs(caplog) assert len(warning_logs) == 1 assert warning_logs[0].levelno == logging.WARNING assert "ModuleNotFoundError" in warning_logs[0].message @@ -243,7 +250,7 @@ def fail_to_deserialize(cls, data=None, fp=None): @pytest.mark.asyncio async def test_load_state_returns_none_for_missing_file(tmp_path, monkeypatch, caplog): - """Test that load_state returns None without logging an error for missing files. + """Test that load_state returns None without logging a warning for missing files. Args: tmp_path: A temporary directory. @@ -254,17 +261,12 @@ async def test_load_state_returns_none_for_missing_file(tmp_path, monkeypatch, c state_manager = StateManagerDisk(_write_debounce_seconds=0) token = StateToken(ident="nonexistent_client", cls=dict) - # load_state should return None without logging an error. + # load_state should return None without logging a warning. result = await state_manager.load_state(token) assert result is None - # Verify that no error was logged. - warning_logs = [ - record - for record in caplog.records - if record.name == "reflex.istate.manager.disk" - and record.levelno >= logging.WARNING - ] + # Verify that no warning was logged. + warning_logs = disk_warning_logs(caplog) assert len(warning_logs) == 0 await state_manager.close() From 41039a51a39389f2eaca6fb50d81ca449a5cf040 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Fri, 2 Oct 2026 14:32:05 +0800 Subject: [PATCH 7/7] fix: handle disk state schema reloads --- reflex/istate/manager/disk.py | 11 ++----- tests/units/istate/manager/test_disk.py | 42 ------------------------- 2 files changed, 2 insertions(+), 51 deletions(-) diff --git a/reflex/istate/manager/disk.py b/reflex/istate/manager/disk.py index 03673f0d5ba..273d3ebe456 100644 --- a/reflex/istate/manager/disk.py +++ b/reflex/istate/manager/disk.py @@ -161,17 +161,10 @@ async def load_state(self, token: StateToken[TOKEN_TYPE]) -> TOKEN_TYPE | None: except StateSchemaMismatchError: return None except Exception as e: - exception_detail = str(e) - for secret in (str(token), token.ident): - if secret: - exception_detail = exception_detail.replace( - secret, "[redacted]" - ) logger.warning( - "Failed to load state file %s, falling back to a default state: %s(%r)", + "Failed to load state file %s, falling back to a default state: %r", token_path.name, - type(e).__name__, - exception_detail, + e, ) return None diff --git a/tests/units/istate/manager/test_disk.py b/tests/units/istate/manager/test_disk.py index 979b8ac9428..5197c630861 100644 --- a/tests/units/istate/manager/test_disk.py +++ b/tests/units/istate/manager/test_disk.py @@ -206,48 +206,6 @@ async def test_load_state_logs_warning_for_corrupted_file( await state_manager.close() -@pytest.mark.asyncio -async def test_load_state_logs_sanitized_exception_details( - tmp_path, monkeypatch, caplog -): - """Test that load errors keep diagnostic details without logging the token. - - Args: - tmp_path: A temporary directory. - monkeypatch: The pytest monkeypatch fixture. - caplog: The pytest caplog fixture. - """ - monkeypatch.setattr(prerequisites, "get_states_dir", lambda: tmp_path) - state_manager = StateManagerDisk(_write_debounce_seconds=0) - token = StateToken(ident="private session\ttoken", cls=dict) - token_path = state_manager.token_path(token) - token_path.parent.mkdir(parents=True, exist_ok=True) - token_path.write_bytes(b"existing state") - - def fail_to_deserialize(cls, data=None, fp=None): - """Raise a representative missing-module error during state loading.""" - module_name = "missing_state_module" - error_message = f"No module named '{module_name}' while loading {token.ident}" - raise ModuleNotFoundError(error_message) - - monkeypatch.setattr(StateToken, "deserialize", classmethod(fail_to_deserialize)) - - assert await state_manager.load_state(token) is None - - warning_logs = disk_warning_logs(caplog) - assert len(warning_logs) == 1 - assert warning_logs[0].levelno == logging.WARNING - assert "ModuleNotFoundError" in warning_logs[0].message - assert "missing_state_module" in warning_logs[0].message - assert token.ident not in warning_logs[0].message - assert str(token) not in warning_logs[0].message - assert " ".join(token.ident.split()) not in warning_logs[0].message - assert token_path.name in warning_logs[0].message - assert "falling back to a default state" in warning_logs[0].message - - await state_manager.close() - - @pytest.mark.asyncio async def test_load_state_returns_none_for_missing_file(tmp_path, monkeypatch, caplog): """Test that load_state returns None without logging a warning for missing files.