
Check worker completion after asyncio cancellation
In short: A cancelled asyncio task can leave its worker running, so check worker completion before releasing capacity or retrying a side effect.
python ▸ import asyncio
from threading import Event
async def check_worker(cooperative=False):
started, release, stop, finished = (Event() for _ in range(4))
writes = []
def worker():
started.set()
try:
if not release.wait(2):
raise RuntimeError("test gate expired")
if not cooperative or not stop.is_set():
writes.append("synthetic write")
finally:
finished.set()
task = asyncio.create_task(asyncio.to_thread(worker))
try:
async with asyncio.timeout(1):
while not started.is_set():
await asyncio.sleep(0.001)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert task.cancelled() and not finished.is_set()
assert writes == []
stop.set()
finally:
release.set()
async with asyncio.timeout(3):
while not finished.is_set():
await asyncio.sleep(0.001)
assert writes == ([] if cooperative else ["synthetic write"])
return len(writes)
async def main():
assert await check_worker() == 1
assert await check_worker(cooperative=True) == 0
print("cancelled task: worker still active; writes 1; cooperative writes 0")
asyncio.run(main())A Python task reported cancelled() while its worker was still waiting to write. Once released, that worker appended one item anyway. This synthetic check ran on Python 3.14.6 on 18 October 2026; no database or external service was involved.
With asyncio.to_thread, cancellation of the awaiting task is not evidence that a running worker has stopped. Test those two states separately. My rule for a blocking integration: do not treat a cancelled waiter as permission to reuse its operation slot or repeat its side effect.
Why asyncio cancellation leaves a worker running
The CPython 3.14 implementation of to_thread delegates the callable to run_in_executor. The executor's future cancellation contract does not cancel an already executing call. That is the boundary a wrapper can hide.
This is different from waiting for coroutine cleanup. There, the work cooperates inside the event loop. Here, a synchronous callable continues outside it. Moving a blocking SDK call into a thread keeps it off the loop; it does not supply that SDK with a stop mechanism. Python's to_thread documentation describes the offloading behaviour, not a hard termination guarantee.
Test asyncio wrapper and worker completion separately
Save this as thread_check.py and run python3 thread_check.py. It uses the standard library only. Events hold the worker at a known point so cancellation cannot win merely because the worker had not started.
import asyncio
from threading import Event
async def check_worker(cooperative=False):
started, release, stop, finished = (Event() for _ in range(4))
writes = []
def worker():
started.set()
try:
if not release.wait(2):
raise RuntimeError("test gate expired")
if not cooperative or not stop.is_set():
writes.append("synthetic write")
finally:
finished.set()
task = asyncio.create_task(asyncio.to_thread(worker))
try:
async with asyncio.timeout(1):
while not started.is_set():
await asyncio.sleep(0.001)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert task.cancelled() and not finished.is_set()
assert writes == []
stop.set()
finally:
release.set()
async with asyncio.timeout(3):
while not finished.is_set():
await asyncio.sleep(0.001)
assert writes == ([] if cooperative else ["synthetic write"])
return len(writes)
async def main():
assert await check_worker() == 1
assert await check_worker(cooperative=True) == 0
print("cancelled task: worker still active; writes 1; cooperative writes 0")
asyncio.run(main())
Both cases passed locally. The first worker ignored the stop request and wrote after cancellation. The second checked the stop flag before its append and wrote nothing. Crucially, both wrappers were already cancelled before either worker was released. A test asserting only task.cancelled() would miss the difference.
The one-, two- and three-second limits are fixture watchdogs, not latency measurements. This checks explicit task cancellation, not a timed-out production request. finished marks the end of the worker's work; it does not mean the executor's reusable operating-system thread has been destroyed.
My judgement for a blocking SDK integration
In a hypothetical agent pipeline, I would keep two records: the caller has stopped waiting, and the underlying operation has reached a known outcome. Combining them into one “done” flag makes a retry look safer than the evidence warrants.
A cooperative stop flag helps only where the worker checks it. It cannot interrupt an arbitrary blocking SDK, reverse a committed write, or make a check followed by a remote write atomic. For an operation that can mutate external state, I would require an idempotency or reconciliation plan before retrying an uncertain outcome. This fixture does not verify either plan.
Likewise, releasing a concurrency slot when the wrapper is cancelled can let another operation start while the first still runs. Tie that accounting to worker completion, with bounded shutdown and explicit handling for operations that refuse to finish. The example's final wait is test cleanup, not a production supervisor.
What's in it for you
- Add a worker-completion assertion to cancellation tests, so a green wrapper test cannot conceal continuing work.
- Separate stop requests from confirmed outcomes before deciding whether capacity or a side effect can be reused.
A cancelled waiter is not a stopped worker.


