Fix branching off an async rx node - #1178
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1178 +/- ##
==========================================
+ Coverage 86.90% 86.91% +0.01%
==========================================
Files 9 9
Lines 5398 5403 +5
==========================================
+ Hits 4691 4696 +5
Misses 707 707 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| if self._is_async: | ||
| self._shared.rx.value # trigger async resolve | ||
| shared = self._shared | ||
| value = shared.rx.value # triggers async resolve |
There was a problem hiding this comment.
Will this always be an async resolve?
There was a problem hiding this comment.
Then the comments should be updated right?
There was a problem hiding this comment.
Depends on how you read it, it's only important for the the async case (so that's what it calls out) and is effectively a no-op for the synchronous case.
There was a problem hiding this comment.
Previously it was behind a self._is_async which made the comment clear. The changes in this PR make the comment more obscure. I think we should add your comment here in the code.
| assert first.rx.value == 2 | ||
|
|
||
| irx.rx.value = 3 | ||
| await async_wait_until(lambda: first.rx.value == 6) |
There was a problem hiding this comment.
As mentioned #1179 (comment). Having numbers only make it harder to read the test, than what it should be.
Co-authored-by: Simon Høxbro Hansen <hoxbro@protonmail.com> Co-authored-by: Philipp Rudiger <prudiger@anaconda.com>
Co-authored-by: Simon Høxbro Hansen <hoxbro@protonmail.com> Co-authored-by: Philipp Rudiger <prudiger@anaconda.com>
Co-authored-by: Philipp Rudiger <prudiger@anaconda.com>
A branch taken off an async node that has already settled never resolves. It reports
Undefinedforever, silently, and no internal state flags the condition: the node is not dirtyand not awaiting, so nothing will ever schedule a resolution for it.
Branching a tuple- or dict-returning node into its components (
node[0],node[1],node.attr) is the normal way to fan a single computation out to several consumers, and for asynchronous node it works and costs one compute. For an async node it depends entirely on when
the branch is created: before the node is driven it resolves, in flight it resolves, and after it
has settled it never resolves at all.
Cause
node[0]builds a two-node chain: a_clone(copy=True)mirror that carries the same asyncoperation with
_sharedpointing at the original, and thegetitemnode on top of it._clone(copy=True)passes_current=self._current, so__init__computesself._dirty = _current is None or _current is Undefinedand getsFalsebecause the sharednode has a value. A few lines later the async branch of
__init__discards that value:The mirror is born clean while holding
Undefined._resolvetherefore takes itselsebranchand returns
Undefinedwithout scheduling anything, and thegetitemnode on top seesobj is Undefinedand skips. Generations stay at0 == 0, so_awaitingisFalse, and only aninput change would ever mark the mirror dirty again.
The same applies to async generator nodes, which are cloned through the same branch of
__init__.Fix
Two changes in
param/reactive.py.__init__marks a node with an async operation dirty when it clears_current_. An asyncoperation has not resolved yet even when cloned from a node that has settled, so the node has to
be born dirty for the first read to resolve it.
_resolve's shared-input path then adopts the shared node's value synchronously when that nodehas settled, instead of always scheduling a task to copy it one event loop iteration later:
Adopting synchronously claims a generation (
_resolve_generation/_finished_generation), whichsupersedes a task an earlier read may have scheduled and keeps
_awaitingfrom reporting aresolution that is not in flight.
This also removes the second-read wart that motivated the workaround in downstream code: with
first, second = node[0], node[1], readingfirstto completion previously leftsecondreturning
Undefinedon its first read and the value only on its next. The shared node hassettled by the time
secondis first read, so it now resolves immediately.A branch created before the node is driven still returns
Undefinedon its first read; nothinghas been computed yet, and that is the ordinary demand-driven async pattern.
AI Disclosure
Developed with the assistance of Claude Opus 5 and manually tested.