Skip to content

Commit f2a9517

Browse files
authored
Deepcopy EventSpec args when creating Event.from_event_type (#6458)
* Deepcopy EventSpec args when creating Event.from_event_type Fix regression from moving event loop to backend where mutable values in Event args yielded to the queue were preserved, causing subsequent changes to attempt to modify the original value. Previously all yielded events were serialized and deserialized before processing, so this created new copies automatically. The new behavior is to explicitly deepcopy values that are not known to be immutable. * remove tuple from IMMUTABLE_PAYLOAD_TYPES it may contain mutable items and thus should be copied
1 parent 3702d23 commit f2a9517

2 files changed

Lines changed: 195 additions & 1 deletion

File tree

packages/reflex-base/src/reflex_base/event/__init__.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Define event classes to connect the frontend and backend."""
22

3+
import copy
34
import dataclasses
45
import inspect
56
import sys
@@ -132,7 +133,17 @@ def from_event_type(
132133
msg = f"Unexpected event type, {type(e)}."
133134
raise ValueError(msg)
134135
name = format.format_event_handler(e.handler)
135-
payload = {k._js_expr: v._decode() for k, v in e.args}
136+
# Deepcopy mutable values to detach them from any state-bound
137+
# proxies (e.g. ImmutableMutableProxy from a background task's
138+
# StateProxy).
139+
payload = {
140+
k._js_expr: (
141+
decoded
142+
if isinstance(decoded := v._decode(), _IMMUTABLE_PAYLOAD_TYPES)
143+
else copy.deepcopy(decoded)
144+
)
145+
for k, v in e.args
146+
}
136147

137148
# Create an event and append it to the list.
138149
out.append(
@@ -150,6 +161,18 @@ def from_event_type(
150161
_EMPTY_EVENTS = LiteralVar.create([])
151162
_EMPTY_EVENT_ACTIONS = LiteralVar.create({})
152163

164+
# Values of these types are safe to pass into an Event payload by reference:
165+
# they are immutable and cannot be wrapped by a state-bound MutableProxy.
166+
_IMMUTABLE_PAYLOAD_TYPES = (
167+
str,
168+
int,
169+
float,
170+
bool,
171+
bytes,
172+
frozenset,
173+
type(None),
174+
)
175+
153176
BACKGROUND_TASK_MARKER = "_reflex_background_task"
154177
EVENT_ACTIONS_MARKER = "_rx_event_actions"
155178
UPLOAD_FILES_CLIENT_HANDLER = "uploadFiles"

tests/units/test_state.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2609,6 +2609,177 @@ async def test_background_task_no_chain():
26092609
await bts.bad_chain2()
26102610

26112611

2612+
class YieldFromBackgroundState(BaseState):
2613+
"""A state used to verify the type of `self` in a yielded event handler."""
2614+
2615+
counter: int = 0
2616+
follow_up_self_type: str = ""
2617+
follow_up_was_proxy: bool = True
2618+
dict_field: dict[str, int] = {"a": 1}
2619+
2620+
@rx.event(background=True)
2621+
async def trigger(self):
2622+
"""A background handler that yields a non-background handler.
2623+
2624+
Yields:
2625+
A reference to the non-background follow_up handler.
2626+
"""
2627+
# Sanity check: the background handler itself receives a StateProxy.
2628+
assert isinstance(self, StateProxy)
2629+
yield YieldFromBackgroundState.follow_up()
2630+
2631+
@rx.event(background=True)
2632+
async def trigger_inside_lock(self):
2633+
"""A background handler that yields a non-background handler from inside `async with self`.
2634+
2635+
Yields:
2636+
A reference to the non-background follow_up handler.
2637+
"""
2638+
assert isinstance(self, StateProxy)
2639+
async with self:
2640+
# Inside the lock, `self` is still a StateProxy (now mutable).
2641+
assert isinstance(self, StateProxy)
2642+
self.counter += 1
2643+
yield YieldFromBackgroundState.follow_up()
2644+
2645+
@rx.event(background=True)
2646+
async def trigger_with_arg(self):
2647+
"""A background handler that yields a non-background handler with a state mutable as arg.
2648+
2649+
Yields:
2650+
A reference to the non-background follow_up_with_arg handler,
2651+
passing `self.dict_field` (a state-owned mutable) as the argument.
2652+
"""
2653+
# Access the mutable through the StateProxy (returns ImmutableMutableProxy)
2654+
# and pass it as an argument to the yielded non-background handler.
2655+
yield YieldFromBackgroundState.follow_up_with_arg(self.dict_field)
2656+
2657+
@rx.event
2658+
def follow_up(self):
2659+
"""A non-background handler invoked via yield from a background handler.
2660+
2661+
Writes to state directly (no `async with self`); this only works if
2662+
`self` is the real state, not a StateProxy.
2663+
"""
2664+
# Record what we observed *before* mutating, in case the write fails.
2665+
self.follow_up_was_proxy = isinstance(self, StateProxy)
2666+
self.follow_up_self_type = type(self).__name__
2667+
# If `self` were a StateProxy outside an `async with self` block, this
2668+
# would raise ImmutableStateError.
2669+
self.counter += 1
2670+
2671+
@rx.event
2672+
def follow_up_with_arg(self, arg: dict[str, int]):
2673+
"""A non-background handler that mutates an argument passed to it.
2674+
2675+
Args:
2676+
arg: A dict argument that the handler will mutate.
2677+
"""
2678+
# Mutating the arg should succeed: it must NOT be an
2679+
# ImmutableMutableProxy bound to the (now-immutable) trigger StateProxy.
2680+
arg["b"] = 2
2681+
# Persist a copy onto the (real) state so the test can verify what was seen.
2682+
self.dict_field = dict(arg)
2683+
2684+
2685+
@pytest.mark.asyncio
2686+
@pytest.mark.parametrize(
2687+
("trigger_handler", "expected_counter"),
2688+
[
2689+
("trigger", 1),
2690+
("trigger_inside_lock", 2),
2691+
],
2692+
)
2693+
async def test_yielded_non_background_event_receives_real_state(
2694+
mock_app: rx.App,
2695+
token: str,
2696+
mock_base_state_event_processor: BaseStateEventProcessor,
2697+
state_manager: StateManager,
2698+
trigger_handler: str,
2699+
expected_counter: int,
2700+
):
2701+
"""A non-background event yielded by a background event must run on the real state.
2702+
2703+
The yielded handler must NOT receive a StateProxy and must be able to
2704+
modify state directly without `async with self`. This holds whether the
2705+
yield happens outside or inside the background handler's `async with self`.
2706+
2707+
Args:
2708+
mock_app: An app that will be returned by `get_app()`.
2709+
token: A token.
2710+
mock_base_state_event_processor: The event processor.
2711+
state_manager: A state manager instance.
2712+
trigger_handler: The name of the background handler to invoke.
2713+
expected_counter: The expected counter value after both handlers run.
2714+
"""
2715+
async with mock_base_state_event_processor as processor:
2716+
future = await processor.enqueue(
2717+
token,
2718+
Event(
2719+
name=f"{YieldFromBackgroundState.get_full_name()}.{trigger_handler}",
2720+
payload={},
2721+
),
2722+
)
2723+
# Wait for the trigger and its yielded follow_up to fully complete.
2724+
await future.wait_all()
2725+
2726+
if environment.REFLEX_OPLOCK_ENABLED.get():
2727+
await state_manager.close()
2728+
2729+
state = await state_manager.get_state(
2730+
BaseStateToken(ident=token, cls=YieldFromBackgroundState)
2731+
)
2732+
assert isinstance(state, YieldFromBackgroundState)
2733+
# Direct mutation by the yielded handler succeeded and was persisted.
2734+
assert state.counter == expected_counter
2735+
# The yielded handler did not receive a StateProxy.
2736+
assert state.follow_up_was_proxy is False
2737+
assert state.follow_up_self_type == YieldFromBackgroundState.__name__
2738+
2739+
2740+
@pytest.mark.asyncio
2741+
async def test_yielded_event_arg_from_background_state_is_mutable(
2742+
mock_app: rx.App,
2743+
token: str,
2744+
mock_base_state_event_processor: BaseStateEventProcessor,
2745+
state_manager: StateManager,
2746+
):
2747+
"""A mutable arg passed by a background event must be mutable in the yielded handler.
2748+
2749+
Regression: when a background handler yields ``Handler(self.some_dict)``,
2750+
``self.some_dict`` is an ``ImmutableMutableProxy`` tied to the trigger's
2751+
``StateProxy``. Once the trigger releases the lock, that proxy refuses
2752+
writes -- so the yielded non-background handler can't mutate the arg it
2753+
was given. The arg must be unwrapped (or otherwise made mutable) before
2754+
being delivered to the yielded handler.
2755+
2756+
Args:
2757+
mock_app: An app that will be returned by `get_app()`.
2758+
token: A token.
2759+
mock_base_state_event_processor: The event processor.
2760+
state_manager: A state manager instance.
2761+
"""
2762+
async with mock_base_state_event_processor as processor:
2763+
future = await processor.enqueue(
2764+
token,
2765+
Event(
2766+
name=f"{YieldFromBackgroundState.get_full_name()}.trigger_with_arg",
2767+
payload={},
2768+
),
2769+
)
2770+
await future.wait_all()
2771+
2772+
if environment.REFLEX_OPLOCK_ENABLED.get():
2773+
await state_manager.close()
2774+
2775+
state = await state_manager.get_state(
2776+
BaseStateToken(ident=token, cls=YieldFromBackgroundState)
2777+
)
2778+
assert isinstance(state, YieldFromBackgroundState)
2779+
# The yielded handler successfully mutated the dict it was passed.
2780+
assert state.dict_field == {"a": 1, "b": 2}
2781+
2782+
26122783
def test_mutable_list(mutable_state: MutableTestState):
26132784
"""Test that mutable lists are tracked correctly.
26142785

0 commit comments

Comments
 (0)