Skip to content

Commit 3082c91

Browse files
Snapshot cancelling() before pending-drain to distinguish outer cancels
The pending-drain block in _connect_impl checked ``cancelling() > 0`` post-await to decide whether the CancelledError came from our own ``pending.cancel()`` or from an outer ``task.cancel()`` that propagated through our fut_waiter. The premise is sound — our pending.cancel() does NOT increment our task's cancelling counter — but the test is not function-scoped: ``Task.cancelling()`` is the cumulative count of cancels delivered to this task across its entire lifetime, modulo ``uncancel()``. Any prior code path on the same task that consumed a CancelledError without ``uncancel()`` leaves the counter non-zero on entry to _connect_impl, and the post-await ``> 0`` check fires unconditionally — re-raising our OWN consumed cancel as if an outer one had landed. Snapshot ``cancelling()`` BEFORE calling pending.cancel() and compare the post-await counter against the snapshot. Only a positive delta indicates a fresh outer cancel landed during ``await pending``; a stable counter means the CancelledError came from our own cancel and must be consumed so connect() can proceed. This is the canonical "snapshot-and-delta" pattern asyncio.timeout itself uses internally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e0e67ad commit 3082c91

1 file changed

Lines changed: 26 additions & 25 deletions

File tree

src/dqliteclient/connection.py

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,39 +1017,40 @@ async def _connect_impl(self) -> None:
10171017
pending = self._pending_drain
10181018
if pending is not None:
10191019
if not pending.done():
1020+
# Snapshot ``cancelling()`` BEFORE calling
1021+
# ``pending.cancel()`` so a non-zero residual count
1022+
# left over from an upstream caller's prior consumed-
1023+
# but-not-uncancelled cancel does not unconditionally
1024+
# trigger the post-await re-raise. Compare the
1025+
# post-await counter against this snapshot — only a
1026+
# delta indicates a fresh outer cancel landed during
1027+
# ``await pending``. ``Task.cancelling()`` is
1028+
# cumulative and not function-scoped; the canonical
1029+
# way to detect "did MY await get cancelled" is the
1030+
# delta pattern that ``asyncio.timeout`` itself uses
1031+
# internally.
1032+
self_task = asyncio.current_task()
1033+
cancelling_before = self_task.cancelling() if self_task is not None else 0
10201034
pending.cancel()
10211035
# Awaiting a cancelled task raises ``CancelledError``;
10221036
# that is the cancel WE delivered and must be consumed
10231037
# here so connect() can proceed. But an outer
10241038
# ``task.cancel()`` may have also landed on the current
1025-
# task — distinguish via ``Task.cancelling()`` (a
1026-
# READ-only counter of cancels delivered to the current
1027-
# task). Our own ``pending.cancel()`` cancelled the
1028-
# *inner* drain task and propagated CancelledError up
1029-
# through ``await pending``, but does NOT increment the
1030-
# current task's own cancel count; an outer
1031-
# ``task.cancel()`` against this coroutine, by contrast,
1032-
# increments ``cancelling()`` to >= 1. If > 0, the
1033-
# cancel was outer; let it propagate to the next
1034-
# checkpoint cleanly. We deliberately do NOT call
1035-
# ``Task.uncancel()`` here — that decrements the
1036-
# counter and would consume the outer cancel, leaving
1037-
# ``connect()`` to silently open a TCP connection the
1038-
# parent intended to abort.
1039+
# task — distinguish via the cancelling-counter delta
1040+
# described above. We deliberately do NOT call
1041+
# ``Task.uncancel()`` here — that would consume the
1042+
# outer cancel, leaving ``connect()`` to silently open
1043+
# a TCP connection the parent intended to abort.
10391044
try:
10401045
await pending
10411046
except asyncio.CancelledError:
1042-
# The CancelledError came either from our own
1043-
# ``pending.cancel()`` (which we want to consume
1044-
# so connect() can proceed) OR from an outer
1045-
# ``task.cancel()`` that propagated through our
1046-
# fut_waiter. ``Task.cancelling()`` distinguishes:
1047-
# it counts cancels delivered TO the current task,
1048-
# which our own ``pending.cancel()`` does not
1049-
# increment. If > 0, the cancel was outer; let
1050-
# it propagate to the next checkpoint.
1051-
self_task = asyncio.current_task()
1052-
if self_task is not None and self_task.cancelling() > 0:
1047+
# Re-raise only if the cancelling counter
1048+
# increased during ``await pending`` (a fresh
1049+
# outer cancel landed); a stable counter means the
1050+
# CancelledError came from our own
1051+
# ``pending.cancel()`` and must be consumed.
1052+
cancelling_after = self_task.cancelling() if self_task is not None else 0
1053+
if cancelling_after > cancelling_before:
10531054
raise
10541055
except Exception:
10551056
pass

0 commit comments

Comments
 (0)