Skip to content

Decide which CancelledError the sync boundaries (sync(), unpack_once_sync(), await_children_sync()) raise on cancellation #116

Description

@teremterem

Summary

When a Promise is cancelled while a thread is blocked in Promise.sync(), the caller receives concurrent.futures.CancelledError - not asyncio.CancelledError, which is what await promise raises and what sync()'s documentation ("synchronous counterpart of await promise") implies.

These are two different classes with very different semantics:

  • asyncio.CancelledError inherits from BaseException (since Python 3.8), deliberately so that generic except Exception: handlers don't swallow cancellation.
  • concurrent.futures.CancelledError inherits from Exception (via concurrent.futures._base.Error).

Mechanism

Promise.sync() (promising/promise.py:404-405) dispatches onto the Promise's loop and blocks on the resulting concurrent.futures.Future:

concurrent_future = asyncio.run_coroutine_threadsafe(awaitable_as_coroutine(self), self.loop)
return concurrent_future.result(timeout=timeout)

When the underlying coroutine is cancelled, run_coroutine_threadsafe's chaining marks the concurrent.futures.Future as cancelled, and .result() re-raises concurrent.futures.CancelledError.

Minimal repro (no Promising involved - this is the raw run_coroutine_threadsafe behavior the sync APIs currently expose unchanged):

fut = asyncio.run_coroutine_threadsafe(coro_that_gets_cancelled(), loop)
try:
    fut.result()
except BaseException as e:
    # <class 'concurrent.futures._base.CancelledError'>
    # isinstance(e, asyncio.CancelledError) -> False
    # isinstance(e, Exception)              -> True
    ...

Impact

  • Callers who catch asyncio.CancelledError around sync() (the natural thing to do, given the "synchronous counterpart of await" contract) never catch the cancellation.
  • Generic except Exception: handlers silently swallow the cancellation, defeating the entire reason asyncio.CancelledError was moved to BaseException.

Affected APIs

All sync boundaries built on run_coroutine_threadsafe(...).result():

  • Promise.sync() (promising/promise.py:404)
  • Promise.unpack_once_sync() (promising/promise.py:478)
  • await_children_sync() / PromisingContext.await_children_sync() (promising/promising_context.py:281, :703)

Decision needed

Before changing anything, we need to decide which specific CancelledError the sync boundaries should raise, and document the choice:

  • Always asyncio.CancelledError (uniform with await, propagates past except Exception)?
  • Always concurrent.futures.CancelledError (uniform with the stdlib's own sync-over-async boundary, catchable as Exception)?
  • Both, depending on the situation - e.g. one type when the Promise itself was cancelled vs. another when the sync wait was cancelled/abandoned, if such a distinction is even reachable?
  • Something else (e.g. a library-specific exception that inherits from one or both)?

Whatever the outcome, the three sync APIs above should behave identically, and Promise.result() / Promise.exception() accessors should stay consistent with it.

Test pinning the (currently aspirational) contract

tests/race_conditions/test_cancellation_races.py::test_cancel_racing_sync_trigger currently expects asyncio.CancelledError and fails with:

AssertionError: sync() must raise asyncio.CancelledError on cancellation, got: <class 'concurrent.futures._base.CancelledError'>

If the decision lands on anything other than "always asyncio.CancelledError", that test's assertion must be updated to match the decided contract.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions