Stop rx from propagating a stale value while an async node is awaiting - #1173
Merged
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1173 +/- ##
==========================================
+ Coverage 86.73% 86.90% +0.16%
==========================================
Files 9 9
Lines 5369 5398 +29
==========================================
+ Hits 4657 4691 +34
+ Misses 712 707 -5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
hoxbro
reviewed
Aug 25, 2026
Rename _resolved_generation to _finished_generation so it does not read like a tense variant of _resolve_generation, and drop the comments that restated what the code already says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two defects in the same handful of lines of
rx._resolve_async/rx._resolve, both caused byan async node not distinguishing "the result I hold is current" from "the result I hold was
computed from inputs that have since changed". One leaks a stale value to consumers, the other
wastes computation. They share the generation machinery, so they are fixed together.
Problem 1: a stale value is propagated while an async node is awaiting
When a reactive expression contains an async operation, every consumer downstream of it is
notified with the previous value the moment an input changes, before the new result has
resolved. On the first change that previous value is
Undefined, so consumers are handed asentinel; on later changes they are handed a value computed from inputs that have already been
superseded.
The watcher fires twice for one input change: once immediately with the stale
2, then againwith
4when the await completes. The same happens forallow_refsparameters fed from theexpression, and for
bind-style consumers, because all three go through the same referencemachinery.
This is not cosmetic. A consumer that renders or persists what it is handed writes the wrong
value and then corrects itself, and a downstream operation is executed on the stale value, so
every node in a chain runs twice per change. With more than one async node in a pipeline the
redundant work multiplies.
Problem 2: superseded async tasks still run to completion
When an async node's inputs change several times without the event loop getting a chance to run
in between, every change spawns a task and every one of those bodies runs to completion
concurrently. Only the last result is used, so the rest is pure waste. For a body that hits a
database or an HTTP API, that is N redundant round trips per burst.
On main:
Four bodies ran to completion for one burst. Inserting
await asyncio.sleep(0)between theassignments gives
finished=[5], which is the intended behaviour, so the defect only shows whenupdates arrive within a single synchronous block. That is the common case in practice: a callback
that sets several parameters, a
param.updatebatch, or a widget that writes value and rangetogether. Note the notification side was already correct here; this is wasted computation, not
wrong output.
Root causes
Problem 1.
rx._resolve()catchesSkipand returnsself._current_, the last resolvedvalue. That is the documented behaviour for a user-raised
Skip(.rx.valuedeliberately keepsreporting the last value;
test_reactive_skip_valuepins it), but the same code path is what anasync node takes while its result is in flight, and
_rx_transformturned the resolved valuestraight into a reference:
The watchers behind that
bindare param watchers on the expression's upstream parameters, noton the async result. They fire synchronously when an input changes, pull
obj.rx.value, and getthe stale value back with nothing distinguishing it from a real one.
Skipwas raised internallyand then discarded.
Problem 2.
_lazy_resolvecancels the previous task before queueing a new one:But
_current_taskwas only assigned inside_resolve_async, which runs once the task isactually scheduled. A task that
async_executorhas created but the loop has not started yet wastherefore invisible to the next
_lazy_resolve: it read a_current_taskstill pointing at anolder, already-finished task, saw
.done()was true, and cancelled nothing. Since twosynchronous parameter assignments never yield to the loop, every task queued in that window
escaped cancellation.
The obvious alternative, having
_lazy_resolverecord the handle it just created, does not work:async_executorreturnsNone(param/_utils.py:611) and it is a user-replaceable hook thatboth Panel and the IPython integration swap out, so changing it to return a task would break
third-party executors.
Changes
Problem 1
The fix separates "what value does this node hold" from "did this node produce a value for the
current inputs". The former stays exactly as it was; only the latter is new, and only propagation
consults it.
rxgains a_skippedflag, set in theexcept Skiphandler and cleared on a successfulresolution. Alongside it,
_completed_generationrecords the generation whose result has actuallylanded, and the
_awaitingproperty compares it against_resolve_generationto tell whether anasync resolution is still in flight.
_resolve_asyncstamps_completed_generationat each of thethree sites where it assigns
_current_, immediately before triggering, including inside theasync-generator loop, so every yield counts as a resolution.
_resolve()then reports skip state in three places: a node whose_previs skipped skips too(rather than computing its operation on a superseded value), a clean node that is awaiting reports
itself skipped while still returning its cached value, and the
_shared-reuse branch mirrors theshared node's skip state.
_rx_transformraisesSkipinstead of handing over a value when the expression is skipped orresolves to
Skip/Undefined.Skipis already honoured throughout the reference machinery(
_sync_refs,_resolve_ref,_execute_watcher), so watchers, refs and bound functions allsimply do not fire, and fire once with the real value when it lands.
The
_shared-reuse branch needed restructuring: it usedraise Skipas plain control flowafter assigning
self._current_ = self._shared.rx.value, so with_skippedin place the clonewas mislabelled as skipped and a downstream async node then refused to schedule at all. The sync
path now returns its value directly and mirrors
self._shared._skipped; only the async path stillraises.
_current(the property Panel's render path reads viarx._callback) is deliberately untouched,so panes keep displaying the last good value while a recompute is in flight rather than blanking.
Problem 2
A task whose generation has already been superseded now declines to run rather than relying on
being cancelled. The generation counter is incremented synchronously in
_lazy_resolve, so by thetime a queued task starts it can always tell whether it is still the current one, and nothing has
awaited
objyet at that point so no body has begun.The guard runs before
self._current_task = task. That ordering matters: a superseded task thatclaimed
_current_taskwould clear it again in itsfinallyblock, hiding the genuinely live taskfrom the next
_lazy_resolveand reintroducing the same bug by a different route.Discarding
objis factored into a module-level_close_stalehelper, since it has three shapes:a coroutine closes with
obj.close()(which also suppresses the warning Python emits for acoroutine that is never awaited), an async generator needs
await obj.aclose(), andobjisNoneon the_sharedmirror path, where there is nothing to close. The stale branch of theasync-generator loop, which already did this inline, now calls the same helper.
_lazy_resolvekeeps its explicitprevious_task.cancel(). The guard only helps tasks that havenot started; cancellation is still what stops a body already suspended at an await. The two
mechanisms are complementary, and there is a test for each.
Behaviour change
A user-raised
Skipinside anrxoperation no longer notifies watchers with the stale value.The skip used to re-notify with the previous value. This is the same defect as the async case and what
Skipwas always meant to do (it's calledSkipafter all)..rx.valuestill returns the last value in both cases, so the existingSkipcontract and its tests are unaffected.
Tests
Eleven tests in
tests/testreactive.py, all failing before this change.Problem 1:
test_reactive_async_watcher_not_notified_while_awaitingtest_reactive_async_downstream_watcher_not_notified_while_awaitingtest_reactive_async_downstream_not_computed_while_awaiting, which counts operation invocationsto show the downstream node is no longer executed on the stale value
test_reactive_async_rapid_updates_notify_oncetest_reactive_async_ref_not_synced_while_awaiting, covering theallow_refspathtest_reactive_skip_value_does_not_notify_watcher, pinning the behaviour change aboveProblem 2:
test_reactive_async_superseded_updates_not_computed, asserting which bodies start and whichfinish, rather than timing anything
test_reactive_async_gen_superseded_updates_not_computed, where a superseded generator is closedbefore its first iteration so its body never runs at all
test_reactive_async_running_task_is_cancelled, the complementary path: with a yield to the loopthe body is genuinely suspended and must still be cancelled
test_async_shared_rx_superseded_updates_computed_once[False, True], a branching pipeline, whichexercises the
obj=Nonemirror path through the new guardFull suite: 1532 passed, 4 skipped, 2 xfailed, in both random and fixed test order. No existing
test needed modification. Verified separately that async generators still stream every yield, that
several consumers of one awaiting node are each notified exactly once, and that a chain of async
nodes notifies once with the final value. Panel's test suite is unaffected and
mypyis clean onparam/reactive.py.AI Disclosure
Developed with the assistance of Claude Opus 5