Skip to content

Fix an unhandled exception in an async rx operation - #1179

Open
philippjfr wants to merge 2 commits into
mainfrom
fix/async-unhandled-exception
Open

Fix an unhandled exception in an async rx operation#1179
philippjfr wants to merge 2 commits into
mainfrom
fix/async-unhandled-exception

Conversation

@philippjfr

Copy link
Copy Markdown
Member

An exception raised inside an async operation is never surfaced to the reader. The node reports
_awaiting == True forever, every read returns the stale value, and the only trace is an asyncio
"Task exception was never retrieved" warning on stderr.

async def boom(v):
    await asyncio.sleep(0)
    raise RuntimeError("boom")

e = rx(1).rx.pipe(boom)
e.rx.watch(lambda v: print("watch", v))
e.rx.value               # Undefined, as expected on the first read
await asyncio.sleep(0.1)
e.rx.value               # Undefined on main, forever
e._awaiting              # True on main, forever

The synchronous path does the right thing: _resolve stores the exception in _error_state and
re-raises it on every read until an invalidation clears it, so a failing operation reports its
failure and a new input recovers the pipeline. The async path has neither property.

Cause

_resolve_async catches only asyncio.CancelledError. Anything else escapes the task, so none of
the three publish paths (shared adoption, async generator, awaited coroutine) ever runs its tail:
_finished_generation is not advanced, _error_state is not set, and no trigger fires.

_awaiting is defined as _resolve_generation != _finished_generation, so leaving the finished
generation behind is what strands the node. Since #1173 that also makes the node report itself as
skipped on every read, which is why the stale value stops propagating but nothing replaces it.

Fix

One new handler in _resolve_async (param/reactive.py:1876), in order:

except Exception as e:
    if stale():
        return
    self._finished_generation = generation
    if self._dirty or self._root._dirty_obj:
        return
    self._error_state = e
    trigger.param.trigger('value')
  1. A stale generation returns without touching state, matching the existing stale paths: a newer
    resolution owns the state and the abandoned computation's error is irrelevant.
  2. _finished_generation is advanced either way, so _awaiting settles.
  3. If the node was invalidated while the computation was in flight, the error is dropped. This
    case matters because resolution is demand-driven: _resolve_generation only bumps when
    something reads the node, so a task can raise after a new input arrived and before anyone
    read the node again. Recording the error there would poison the node permanently, because only
    an invalidation clears _error_state and reads raise before ever reaching _lazy_resolve.
    Dropping it is safe: the next read resolves the new input and supersedes the failed one.
  4. Otherwise the error is recorded and the node triggers, mirroring _resolve. Every read
    re-raises until an invalidation clears _error_state, and a new input recovers the pipeline.
    For an async generator the raise ends the stream.

The exception is no longer re-raised out of the task, so the raise itself no longer produces a
"Task exception was never retrieved" warning. With a watcher attached, the trigger invokes it, the
watcher reads the value and raises, and that propagates out of the task to the loop's exception
handler. This is the async analogue of the synchronous path raising at the mutation point, and it
matches what already happens today when a watcher raises on a successful async resolution.

Errors reach downstream and branching consumers without extra work: a downstream node's
_prev._resolve() raises and is recorded by the existing except Exception in _resolve, and each
_shared mirror records the error when it adopts the shared node's value.

The trigger is None guard also moved above the _current_task claim. It was the first statement
inside the try with no await between, so this is behavior-identical, and it keeps the type
narrowing valid inside the new handler.

@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 72.72727% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.90%. Comparing base (83c4dca) to head (272e7c5).

Files with missing lines Patch % Lines
param/reactive.py 72.72% 3 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #1179   +/-   ##
=======================================
  Coverage   86.90%   86.90%           
=======================================
  Files           9        9           
  Lines        5398     5406    +8     
=======================================
+ Hits         4691     4698    +7     
- Misses        707      708    +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread param/reactive.py Outdated
Comment thread param/reactive.py Outdated
Comment thread param/reactive.py Outdated
Comment thread tests/testreactive.py Outdated
Comment thread tests/testreactive.py

irx.rx.value = 2
async_rx.rx.value
# Yield to the event loop so the failing task is suspended on its await and

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

not a fan of the word yield.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It's the correct word imo.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It is just so close to generators that it can have double meaning.

Co-authored-by: Simon Høxbro Hansen <hoxbro@protonmail.com>
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.

2 participants