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.
Summary
When a Promise is cancelled while a thread is blocked in
Promise.sync(), the caller receivesconcurrent.futures.CancelledError- notasyncio.CancelledError, which is whatawait promiseraises and whatsync()'s documentation ("synchronous counterpart ofawait promise") implies.These are two different classes with very different semantics:
asyncio.CancelledErrorinherits fromBaseException(since Python 3.8), deliberately so that genericexcept Exception:handlers don't swallow cancellation.concurrent.futures.CancelledErrorinherits fromException(viaconcurrent.futures._base.Error).Mechanism
Promise.sync()(promising/promise.py:404-405) dispatches onto the Promise's loop and blocks on the resultingconcurrent.futures.Future:When the underlying coroutine is cancelled,
run_coroutine_threadsafe's chaining marks theconcurrent.futures.Futureas cancelled, and.result()re-raisesconcurrent.futures.CancelledError.Minimal repro (no Promising involved - this is the raw
run_coroutine_threadsafebehavior the sync APIs currently expose unchanged):Impact
asyncio.CancelledErroraroundsync()(the natural thing to do, given the "synchronous counterpart ofawait" contract) never catch the cancellation.except Exception:handlers silently swallow the cancellation, defeating the entire reasonasyncio.CancelledErrorwas moved toBaseException.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
CancelledErrorthe sync boundaries should raise, and document the choice:asyncio.CancelledError(uniform withawait, propagates pastexcept Exception)?concurrent.futures.CancelledError(uniform with the stdlib's own sync-over-async boundary, catchable asException)?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_triggercurrently expectsasyncio.CancelledErrorand fails with:If the decision lands on anything other than "always
asyncio.CancelledError", that test's assertion must be updated to match the decided contract.