Skip to content

support async context for mutable proxies#6691

Open
harsh21234i wants to merge 7 commits into
reflex-dev:mainfrom
harsh21234i:fix/mutable-proxy-async-context-6689
Open

support async context for mutable proxies#6691
harsh21234i wants to merge 7 commits into
reflex-dev:mainfrom
harsh21234i:fix/mutable-proxy-async-context-6689

Conversation

@harsh21234i

Copy link
Copy Markdown

Fixes #6689

Summary

  • add async context-manager support to MutableProxy and
    ImmutableMutableProxy
  • refresh the proxy from its bound state field after entering the state
    context
  • cover ImmutableMutableProxy mutation through async with proxy

Testing

  • uv run pytest tests/units/
    test_state.py::test_immutable_mutable_proxy_async_context_manager tests/
    units/test_state.py::test_rebind_mutable_proxy tests/units/istate/
    test_proxy.py -q
  • uv run ruff check reflex/istate/proxy.py tests/units/test_state.py
  • uv run towncrier check --config pyproject.toml --dir . --compare-with
    upstream/main

@harsh21234i harsh21234i requested a review from a team as a code owner June 30, 2026 08:04

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e0d0b19a90

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread reflex/istate/proxy.py
@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds async with proxy support to MutableProxy and ImmutableMutableProxy so background tasks can enter the owning StateProxy context directly through a nested field or item proxy, refreshing the proxy's wrapped value from the live state before mutating it.

  • Introduces __aenter__/__aexit__ on MutableProxy backed by a path-tracking refresh mechanism; AccessSpec tuples record the attr/item navigation steps taken to reach each sub-proxy so __aenter__ can replay them against the freshly-acquired state.
  • Adds four new test cases covering successful mutation, intentional rejection of iteration-sourced proxies, recovery from a failed StateProxy.__aenter__, and _self_actx_state cleanup when the owning context's exit itself raises.

Confidence Score: 5/5

The core async context manager logic is correct and all critical edge cases (failed enter, failed cleanup, double-entry guard) are covered by tests.

Path tracking works correctly for attr/item access patterns. The _wrap_recursive_decorator gap for dict.get/setdefault results is an unlikely usage pattern and does not affect normal operation of the feature.

reflex/istate/proxy.py — the _wrap_recursive_decorator method and whether it should forward the item key as a path segment for wrap_mutable_attrs methods.

Important Files Changed

Filename Overview
reflex/istate/proxy.py Adds aenter/aexit to MutableProxy with path-tracking refresh logic; path tracking is correct for attr/item access but wrap_mutable_attrs method results (dict.get, dict.setdefault) inherit parent path and would silently refresh to wrong object
tests/units/test_state.py Adds four comprehensive async context manager tests covering happy-path mutation, iter-proxy rejection, aenter failure recovery, and cleanup-failure recovery
news/6689.feature.md Changelog entry for the new async context manager feature

Reviews (7): Last reviewed commit: "Clear mutable proxy context after cleanu..." | Re-trigger Greptile

Comment thread reflex/istate/proxy.py Outdated
Comment thread reflex/istate/proxy.py Outdated
@codspeed-hq

codspeed-hq Bot commented Jun 30, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 18.34%

❌ 1 regressed benchmark
✅ 25 untouched benchmarks
⏩ 8 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
test_var_access[mutable_dataclass_list] 207.1 ms 253.6 ms -18.34%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing harsh21234i:fix/mutable-proxy-async-context-6689 (0f899c6) with main (2d446c3)2

Open in CodSpeed

Footnotes

  1. 8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (b3fe805) during the generation of this report, so 2d446c3 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Comment thread reflex/istate/proxy.py
Comment thread reflex/istate/proxy.py Outdated
Comment thread reflex/istate/proxy.py Outdated
Comment thread reflex/istate/proxy.py
Comment thread reflex/istate/proxy.py Outdated
Comment thread reflex/istate/proxy.py Outdated
Comment thread reflex/istate/proxy.py Outdated
Comment thread reflex/istate/proxy.py Outdated
@harsh21234i

Copy link
Copy Markdown
Author

Addressed the review feedback in d8f12f3.

Changes made:

  • Added typed AccessSpec path entries for attr, item, and iter.
  • Updated _wrap_recursive to append a single new path segment internally.
  • Added _self_actx_state to internal proxy attrs.
  • Rejected nested async with proxy usage explicitly.
  • Made proxy __aexit__ idempotent when already exited.
  • Made async refresh path replay defensive and fail clearly for iteration-sourced proxies.
  • Added coverage for nested context rejection, idempotent exit, and iteration-derived proxy rejection.

Checks passed:

  • uv run pytest tests/units/test_state.py::test_immutable_mutable_proxy_async_context_manager tests/units/ test_state.py::test_immutable_mutable_proxy_async_context_rejects_iter_proxy tests/units/test_state.py::test_rebind_mutable_proxy tests/units/istate/ test_proxy.py -q
  • uv run ruff check reflex/istate/proxy.py tests/units/test_state.py
  • uv run ruff format --check reflex/istate/proxy.py tests/units/test_state.py
  • uv run pyright reflex/istate/proxy.py tests/units/test_state.py tests/units/istate/test_proxy.py

@harsh21234i harsh21234i requested a review from masenf July 2, 2026 04:02
Comment thread reflex/istate/proxy.py
@FarhanAliRaza

Copy link
Copy Markdown
Contributor

@codspeedbot fix this regression

Comment thread reflex/istate/proxy.py
@harsh21234i

Copy link
Copy Markdown
Author

hey can you go through this @masenf @FarhanAliRaza

@FarhanAliRaza FarhanAliRaza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the overall design here is solid: delegating to the bound StateProxy, replaying a recorded access path on the refreshed state, and poisoning iteration-sourced proxies so they fail loudly is the right shape, and the exception paths are carefully handled and tested. Tests, ruff, and pyright all pass locally.

Requesting changes for one confirmed correctness bug (reproduced locally): proxies returned by dict.get()/setdefault() carry their parent's access path, so async with on them silently rebinds the proxy to the parent container — in the worst case writes land in the wrong container with no error. Details inline, along with a few smaller comments.

Comment thread reflex/istate/proxy.py
Comment on lines +513 to +521
if (
isinstance(refreshed_value, MutableProxy)
and self._self_field_name == refreshed_value._self_field_name
):
super().__setattr__("__wrapped__", refreshed_value.__wrapped__)
self._self_state = refreshed_value._self_state
self._self_path = refreshed_value._self_path
else:
self._raise_refresh_error()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed bug (reproduced): proxies returned by dict.get() / setdefault() silently rebind to the parent container here.

Those methods are in __wrap_mutable_attrs__ and route through _wrap_recursive_decorator, which calls self._wrap_recursive(wrapped(*args, **kwargs)) with no new_path_segment — so the child proxy inherits the parent's _self_path. This guard can't catch the mismatch because _self_field_name is the top-level field name for every descendant proxy:

data_proxy = state_proxy.data          # dict[str, list[int]] = {"a": [1], "b": [2]}
child = data_proxy.get("a")            # wraps [1], but path=() like its parent
async with child:
    # child.__wrapped__ is now {"a": [1], "b": [2]} — the whole dict!
    child.append(3)                    # AttributeError

child.append(...) at least raises AttributeError, but with homogeneous nesting (dict[str, dict]) writes like child["x"] = 5 land silently in the wrong container — data corruption with no error. Note data_proxy["a"] (via __getitem__) works correctly; only the two decorator-routed methods are affected.

Suggested fix: either pass ("item", args[0]) as the segment in _wrap_recursive_decorator (always correct for setdefault, correct for get whenever the value actually came from the dict), or — safer and minimal — tag decorator-returned proxies with the unresolvable sentinel (like the iter marker) so __aenter__ raises the existing "Unable to refresh" error instead of silently rebinding. The sentinel handles get(key, default) returning the default, which can't be represented as a path at all.

Comment thread reflex/istate/proxy.py
"""
if self._self_actx_state is not None:
msg = (
"Mutable proxy is already mutable. Do not nest `async with proxy` "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: if the same proxy object is shared between two asyncio tasks, task B hits this immediately with "Do not nest" — which is misleading for the cross-task case — whereas async with state_proxy in task B would have blocked on _self_actx_lock and proceeded. Uncommon in practice (each StateProxy.__getattr__ access mints a fresh proxy), but worth either clarifying the message or keying the guard to the current task.

Comment thread reflex/istate/proxy.py
"""Enter the async context manager protocol through the bound state.

Returns:
This proxy refreshed from the current state field.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this raises RuntimeError (nesting guard, refresh failure) but the docstring has no Raises: section.

Comment thread reflex/istate/proxy.py
self._self_actx_state = context_state
aenter_ok = False
try:
state = await context_state.__aenter__()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting: for a MutableProxy from a regular (non-background) handler, _self_state is a raw BaseState, whose __aenter__ is a no-op returning self — so async with proxy succeeds but does nothing meaningful (no lock, no refresh). Functionally harmless since mutations persist through the normal handler flow, but it reads as if it locks something. Might deserve at least a comment here, or a docs note.

Comment thread reflex/istate/proxy.py
path = (
_ROOT_ITER_PATH
if new_path_segment is _ITER_ACCESS_SPEC and not path
else (*path, new_path_segment)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perf note: every nested mutable attribute/item access now allocates a path tuple (here plus the ("attr", name) / ("item", key) tuples at the call sites), paid by all event handlers even though only background-task async with proxy ever consumes the path. It's marginal next to the proxy allocation that already happens per access, but flagging since this is the state read hot path — fine if you've considered it acceptable.

Comment thread reflex/istate/proxy.py
for value in super().__iter__(): # pyright: ignore[reportAttributeAccessIssue]
# Recursively wrap mutable items retrieved through this proxy.
yield self._wrap_recursive(value)
yield self._wrap_recursive(value, _ITER_ACCESS_SPEC)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker — the poison marker is a sound choice — but for lists specifically you could record ("item", i) via enumerate instead, which would make iteration-sourced list proxies enterable (for row in self.data: async with row: ...). Could be a follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

(Immutable)MutableProxy should support __aenter__ protocol

3 participants