A timeout around asyncio.to_thread() can end the await while a function already running in the worker thread continues. Treat the caller's timeout, the worker's stop request, and confirmed worker completion as separate states. Do not immediately retry a write just because its await timed out.
Check which operation was cancelled
The CPython issue linked in Sources reports a cancelled run_in_executor() future whose thread keeps printing. The report used Python 3.11 on macOS; test your actual runtime before assigning the cause. In CPython 3.14.3, to_thread() delegates to the event loop's executor after preparing the call and context. This makes the executor boundary relevant to a timeout around the higher-level helper too.
An asyncio task is an event-loop object. Cancelling that object is not an instruction that forcibly interrupts arbitrary synchronous code in another thread. The executor's future cannot cancel a call that is already executing. Pending work that has not started is a different case. First record the Python version, how work is submitted, whether the function actually started, and what side effects it owns.
Reproduce a cancelled await with a live worker
Run this standalone script on Python 3.11 or later. It performs no network requests or file writes. An event marks entry into the worker, so the timeout is applied after startup rather than being confused with cancellation of a queued job. A second event keeps the worker waiting until the test releases it.
import asyncio
import threading
async def main():
started = threading.Event()
release = threading.Event()
finished = threading.Event()
loop = asyncio.get_running_loop()
entered = asyncio.Event()
def worker():
started.set()
loop.call_soon_threadsafe(entered.set)
try:
if not release.wait(5):
raise RuntimeError("fixture release deadline exceeded")
return "worker completed"
finally:
finished.set()
task = asyncio.create_task(asyncio.to_thread(worker))
try:
await asyncio.wait_for(entered.wait(), 2)
assert started.is_set() and not finished.is_set()
try:
await asyncio.wait_for(task, 0.02)
except TimeoutError:
assert task.cancelled()
assert not finished.is_set()
print("await cancelled; worker has not finished")
else:
raise AssertionError("expected a timeout")
finally:
release.set()
await asyncio.gather(task, return_exceptions=True)
if started.is_set():
assert await asyncio.to_thread(finished.wait, 2)
assert finished.is_set()
print("worker completion confirmed separately")
asyncio.run(main())The first message proves that a cancelled task and an unfinished worker coexist in this fixture. The cleanup releases the worker and checks its completion independently. Awaiting the cancelled task again does not recover the synchronous return value. Do not replace the release with an endless loop when experimenting; a demonstration should leave no worker behind.
The short timeout is a test trigger, not a production latency recommendation. Startup and cleanup have separate bounded checks. If a heavily loaded environment cannot satisfy them, diagnose that failure rather than treating every assertion failure as evidence of a different cancellation contract.
Keep a handle and request cooperative shutdown
When the synchronous function is under your control, pass a threading.Event that it checks between bounded work units. In this second standalone fixture, the worker waits for that stop event instead of doing real I/O. shield() lets the caller time out without cancelling the task handle that will later carry the result.
import asyncio
import threading
async def main():
stop = threading.Event()
loop = asyncio.get_running_loop()
entered = asyncio.Event()
def worker():
loop.call_soon_threadsafe(entered.set)
# Real code must check stop between bounded work units.
if not stop.wait(5):
raise RuntimeError("fixture stop deadline exceeded")
return "stopped cooperatively"
task = asyncio.create_task(asyncio.to_thread(worker))
timed_out = False
try:
await asyncio.wait_for(entered.wait(), 2)
try:
await asyncio.wait_for(asyncio.shield(task), 0.02)
except TimeoutError:
timed_out = True
assert not task.done()
finally:
stop.set()
# Keep the handle and collect completion or the worker's error.
result = await asyncio.wait_for(asyncio.shield(task), 2)
assert timed_out
assert result == "stopped cooperatively"
assert task.done() and not task.cancelled()
print(result)
asyncio.run(main())In application code, setting the event is a request. The worker must actually observe it and return. Use the blocking library's own timeout or cancellation facilities for each operation; a stop event alone cannot wake an unrelated indefinitely blocked call. Do not call a blocking threading.Event.wait() directly in the event-loop thread. The worker uses that operation here; the coroutine waits through asyncio primitives.
This fixture's final await also has a deadline. If real work exceeds its cleanup deadline, the owner still needs a retained handle, an exception-collection path, and a policy for unfinished work. Do not report confirmed shutdown merely because the cleanup wait timed out. Likewise, further external cancellation during cleanup needs an application-specific ownership path. This example does not implement a service-wide job supervisor.
Choose a retry and shutdown policy before writing again
After a timeout, inspect the operation's completion record or idempotency key before retrying an external write. A timeout says that the caller did not obtain a result in time; it does not prove the operation had no effect. The examples avoid that ambiguity by doing no external work.
Using a task group does not make a running synchronous thread forcibly interruptible. Similarly, executor shutdown with pending-future cancellation does not cancel already running calls. Returning from main() can enter executor shutdown, which helps explain why a timeout message may appear before the process exits. Keep completion verification explicit rather than using process exit as your only diagnostic.
If a required hard stop cannot be implemented cooperatively, assess an isolated process and its resource and partial-write cleanup separately. Do not use thread timeouts as proof of a hard execution deadline. This article does not validate process termination, a third-party driver's cancellation support, or production shutdown behavior.
Keep the adjacent boundaries separate
For sibling coroutine failures, use the gather task ownership guide. For an operating-system child process that survives an output timeout, use the Popen timeout and cleanup guide. Those involve different work owners and do not replace a worker-thread stop protocol.
The embedded fixtures were executed locally against the Python versions recorded with this draft. They establish the demonstrated task and thread states, not the safety of cancelling arbitrary application work. Before deployment, test your own blocking call, cancellation during cleanup, result collection, and duplicate-write prevention.