Why asyncio.wait_for Can Outlive Its Timeout
A wrapper can still be pending after its timeout has already initiated cancellation. That sentence is the operational fact this article qualifies. await asyncio.wait_for(work(), timeout=t) is easy to read as “the call is over by t.” The Python contract is narrower: on timeout, wait_for cancels the awaited work and participates in the cancellation handshake before delivering its own final outcome. The Python 3.13 documentation explicitly warns that total wait time may exceed the timeout because wait_for waits for cancellation to complete, and it propagates exceptions raised during cancellation.
The distinction matters to service maintainers because a timer firing, a worker reaching a terminal state, and a caller receiving a value or exception are three different lifecycle points. This article isolates those points with one explicitly owned worker per case, fresh asyncio.Event barriers, and no network, database, thread, or distributed-system behavior. The reproducible baseline is CPython 3.13.5. The complete A–E fixture printed below was executed in a disposable child process on 2026-10-16 under an external subprocess.run(..., timeout=10) supervisor. The parent validated its complete ledger, and every explicitly owned task was reconciled. This is an offline laboratory run, not a production test campaign.
The acceptance rule is deliberately conservative: documented cooperative behavior can be ACCEPTED under a cooperative contract; incorrect cleanup or swallowed cancellation calls for REPAIR; a hard-deadline claim or an unjoined task is HOLD; and incomplete evidence caused by the harness is RERUN after the harness is repaired. None of these cases is evidence of a Python bug.
Name the deadline claim before choosing a timeout
The first design task is not choosing 0.03 versus 3.0. It is naming what the owner promises. A cooperative timeout budget says, in effect, “after this budget expires, initiate cancellation and wait for the cooperative cancellation path to resolve.” An observation timeout says, “stop this particular wait and report whether work is still pending.” A worker terminal state says the owned task has actually returned, raised, or become cancelled. An external harness safeguard limits how long a disposable test process is allowed to occupy the test runner; it does not redefine the semantics inside the event loop.
Python’s asyncio.wait_for documentation states both halves of the contract: a timeout cancels the awaited object, and the function waits until cancellation has actually resolved, so total wait time may exceed the supplied timeout. It also states that an exception arising during cancellation is propagated. That is already enough to reject the claim “timeout=t proves return-or-raise by wall-clock t.” It supports a cooperative timeout mechanism, not a universal hard completion deadline.
Ownership must be equally explicit. The caller in this laboratory retains the worker task and, for cases A–D, the wrapper task that calls wait_for. A terminal outcome is not accepted until those task references have had their result, cancellation, or exception retrieved. That ownership focus is adjacent to Refonte’s discussion of sibling task lifecycle after gather fails, but this experiment intentionally removes sibling policy and asks a narrower question: what happens between the timeout trigger and the final outcome of one wait_for wrapper? The linked article is context, not authority for the asyncio contract.
The decision vocabulary is therefore precise. ACCEPT means the evidence matches the declared cooperative or observation contract and all owned tasks are terminal and reconciled. REPAIR means cleanup or cancellation handling violates the intended contract. HOLD means the proposed service claim is stronger than the evidence, especially a hard-deadline promise, or ownership is incomplete. RERUN is reserved for a harness defect, timeout, missing record, or baseline mismatch that prevents interpretation.
Freeze the interpreter and the process boundary
The Python 3.13 documentation branch is rolling, rather than an immutable snapshot of one patch release. Its changing page labels must not be used as the experiment’s interpreter version. The executed baseline here was CPython 3.13.5; the version-specific implementation reference is the tagged v3.13.5 Lib/asyncio/tasks.py. Its wait_for docstring explicitly describes a branch that the high-level reference summarizes less completely: if the task suppresses cancellation and returns a value, that value is returned. For a positive timeout, the implementation awaits the supplied task inside its timeout context. The branch documentation and tagged source were checked on 2026-10-16. Neither is being described as the latest Python release.
The complete observed run reported:
Baseline field | Observed value |
Python implementation | CPython |
Python version | 3.13.5 |
Platform | Linux 6.18.44 x86_64, glibc 2.41 |
Event-loop class | asyncio.unix_events._UnixSelectorEventLoop |
Optimization flag | 0 |
Loop debug mode | False |
wait_for trigger | 0.03 seconds |
Pending-observation window in B–D | 0.02 seconds |
Parent process safeguard | 10 seconds |
Those values describe this run, not a cross-platform guarantee. The event loop was a normal selector loop created through asyncio.run; no custom policy or third-party loop was involved. The loop.time() documentation defines the value as the loop’s internal monotonic clock, and delayed callbacks are scheduled against that clock. It does not turn coroutine scheduling into a real-time system.
The process boundary is equally important. An in-loop timeout cannot supervise an event loop that ceases to make progress, because the mechanism that would deliver the timeout also depends on that loop running. The laboratory therefore places the whole A–E demonstration in a disposable child Python process and supervises it from a parent process with subprocess.run(..., timeout=10). Python’s subprocess.run contract says that an expired timeout causes the child to be killed and waited for before TimeoutExpired is re-raised, while also warning that initial process creation itself may not be interruptible and can delay when the timeout is observed. That outer limit is a harness safeguard, not proof of an exact real-time deadline.
Save both scripts in the same writable directory and leave optimization disabled. Python’s optimization option removes assert statements; this harness rejects an optimized parent or child instead of silently disabling its oracle. The child uses isolated mode and unbuffered output, with debug=False and the default task factory. Obtain the required interpreter separately; after it is installed, the laboratory needs no downloads or third-party packages.
Build the barrier-controlled worker and evidence ledger
The fixture uses five fresh cases. A worker waits on work_release. In the cancellation cases, a CancelledError causes the worker to record cancel_seen, signal cleanup_started, and wait on cleanup_release. Only after the harness releases that Event does the worker choose one of three branches: re-raise cancellation, return "late-result", or raise RuntimeError("cleanup-failed"). The wrapper is a separately retained task around asyncio.wait_for(worker, 0.03).
That shape deliberately tests the cancellation handshake rather than scheduler speed. asyncio.CancelledError is special: current Python 3.13 documentation says it is a BaseException subclass and should generally be re-raised after cleanup when caught. The C branch violates that recommended propagation discipline on purpose so that the harness can show what a caller actually receives if cancellation is suppressed.
Save the following complete child script as wait_for_lab_child.py. This is the fixture executed for the observations below. It uses only the standard library, retains every task reference it explicitly creates, retrieves terminal outcomes, and releases barriers in finally before attempting orderly joins. The literal assertions are laboratory oracles, not a production-facing input-policy interface. Each event is flushed as it occurs, and each raw case is emitted before its oracle is checked, preserving useful evidence even if a later case cannot finish.
from future import annotations
import asyncio
import json
import platform
import sys
from dataclasses import dataclass, field
from typing import Any
TIMEOUT = 0.03
OBSERVATION_TIMEOUT = 0.02
CASE_IDS = ("A", "B", "C", "D", "E")
BEHAVIORS = {
"A": "normal", "B": "reraise", "C": "suppress",
"D": "cleanup_error", "E": "normal",
}
def emit(record: dict[str, Any]) -> None:
print(json.dumps(record, sort_keys=True), flush=True)
@dataclass(frozen=True)
class EventLogger:
case_id: str
start: float
loop: asyncio.AbstractEventLoop
events: list[dict[str, Any]] = field(default_factory=list)
def record(self, event: str, **details: Any) -> None:
entry = {
"seq": len(self.events) + 1, "event": event,
"offset_s": round(self.loop.time() - self.start, 6),
**details,
}
self.events.append(entry)
emit({"record_type": "event", "case_id": self.case_id,
**entry})
async def collect_task(task: asyncio.Task[Any]) -> dict[str, Any]:
# Do not mistake cancellation of this observer for an outcome.
await asyncio.wait({task})
assert task.done()
try:
value = task.result()
except BaseException as exc:
return {"kind": "exception", "type": type(exc).__name__,
"module": type(exc).__module__, "message": str(exc)}
return {"kind": "value", "value": value}
async def controlled_worker(
logger: EventLogger, work_release: asyncio.Event,
cancel_seen: asyncio.Event, cleanup_started: asyncio.Event,
cleanup_release: asyncio.Event, behavior: str,
normal_result: str,
) -> str:
logger.record("worker_started")
try:
await work_release.wait()
logger.record("ordinary_work_released")
logger.record("worker_returning", value=normal_result)
return normal_result
except asyncio.CancelledError:
logger.record("cancel_seen")
cancel_seen.set()
logger.record("cleanup_started")
cleanup_started.set()
await cleanup_release.wait()
logger.record("cleanup_released")
if behavior == "reraise":
logger.record("cancel_reraised")
raise
if behavior == "suppress":
logger.record("worker_returning", value="late-result")
return "late-result"
if behavior == "cleanup_error":
logger.record("cleanup_error_raised")
raise RuntimeError("cleanup-failed")
raise AssertionError("unexpected cancellation")
async def wait_for_wrapper(
worker: asyncio.Task[str], logger: EventLogger,
) -> str:
logger.record("wrapper_wait_for_started", timeout_s=TIMEOUT)
try:
value = await asyncio.wait_for(worker, timeout=TIMEOUT)
except BaseException as exc:
logger.record("wrapper_terminal_exception",
exception_type=type(exc).__name__)
raise
logger.record("wrapper_terminal_value", value=value)
return value
async def run_case(case_id: str) -> dict[str, Any]:
loop = asyncio.get_running_loop()
logger = EventLogger(case_id, loop.time(), loop)
work_release = asyncio.Event()
cancel_seen = asyncio.Event()
cleanup_started = asyncio.Event()
cleanup_release = asyncio.Event()
if case_id == "A":
work_release.set()
owned: dict[str, asyncio.Task[Any]] = {}
outcomes: dict[str, dict[str, Any]] = {}
observation = None
try:
worker = asyncio.create_task(
controlled_worker(
logger, work_release, cancel_seen, cleanup_started,
cleanup_release, BEHAVIORS[case_id],
"after-observation" if case_id == "E" else "on-time",
), name=f"case-{case_id}-worker",
)
owned["worker"] = worker
if case_id != "E":
wrapper = asyncio.create_task(
wait_for_wrapper(worker, logger),
name=f"case-{case_id}-wrapper",
)
owned["wrapper"] = wrapper
if case_id != "A":
await cleanup_started.wait()
logger.record("harness_saw_cleanup_started")
done, pending = await asyncio.wait(
{wrapper}, timeout=OBSERVATION_TIMEOUT,
)
logger.record(
"wrapper_observation_complete",
wrapper_pending=wrapper in pending,
done_count=len(done),
cleanup_release_is_set=cleanup_release.is_set(),
)
assert cancel_seen.is_set()
assert done == set() and pending == {wrapper}
assert not cleanup_release.is_set()
logger.record("harness_releasing_cleanup")
cleanup_release.set()
outcomes["wrapper"] = await collect_task(wrapper)
logger.record("owner_collected_wrapper",
outcome=outcomes["wrapper"])
else:
done, pending = await asyncio.wait(
{worker}, timeout=TIMEOUT,
)
observation = {"kind": "observation",
"done_count": len(done),
"pending_count": len(pending)}
logger.record("observation_returned",
worker_pending=worker in pending,
worker_done=worker.done(),
worker_cancelled=worker.cancelled())
assert done == set() and pending == {worker}
assert not cancel_seen.is_set()
logger.record("owner_releasing_ordinary_work")
work_release.set()
outcomes["worker"] = await collect_task(worker)
logger.record("owner_collected_worker",
outcome=outcomes["worker"])
finally:
work_release.set()
cleanup_release.set()
for role, task in owned.items():
if role not in outcomes:
outcomes[role] = await collect_task(task)
logger.record("finally_collected", role=role,
outcome=outcomes[role])
states = {
role: {"name": task.get_name(), "done": task.done(),
"cancelled": task.cancelled(),
"retrieved": role in outcomes}
for role, task in owned.items()
}
return {
"record_type": "case", "case_id": case_id,
"events": logger.events,
"wrapper_outcome": outcomes.get("wrapper", observation),
"worker_outcome": outcomes["worker"],
"worker": states["worker"], "owned_tasks": states,
"cancel_seen": cancel_seen.is_set(),
"owned_task_unreconciled": [
role for role, state in states.items()
if not (state["done"] and state["retrieved"])
],
}
def verify_case(case: dict[str, Any]) -> None:
# Independent literal oracle, not computed from worker behavior.
expected = {
"A": ("on-time", "on-time", False),
"B": ("TimeoutError", "CancelledError", True),
"C": ("late-result", "late-result", False),
"D": ("RuntimeError", "RuntimeError", False),
"E": ("observation", "after-observation", False),
}
cid = case["case_id"]
wrapper_label, worker_label, cancelled = expected[cid]
def check_outcome(outcome: dict[str, Any], label: str) -> None:
if label in {"TimeoutError", "CancelledError", "RuntimeError"}:
module = ("asyncio.exceptions" if label == "CancelledError"
else "builtins")
message = "cleanup-failed" if label == "RuntimeError" else ""
assert outcome == {"kind": "exception", "type": label,
"module": module, "message": message}
elif label == "observation":
assert outcome == {"kind": "observation", "done_count": 0,
"pending_count": 1}
else:
assert outcome == {"kind": "value", "value": label}
check_outcome(case["wrapper_outcome"], wrapper_label)
check_outcome(case["worker_outcome"], worker_label)
roles = {"worker"} if cid == "E" else {"worker", "wrapper"}
assert set(case["owned_tasks"]) == roles
for role, state in case["owned_tasks"].items():
assert state == {
"name": f"case-{cid}-{role}", "done": True,
"cancelled": cancelled if role == "worker" else False,
"retrieved": True,
}
assert case["worker"] == case["owned_tasks"]["worker"]
assert case["owned_task_unreconciled"] == []
assert case["cancel_seen"] is (cid in {"B", "C", "D"})
events = case["events"]
names = [event["event"] for event in events]
assert [e["seq"] for e in events] == list(range(1, len(events) + 1))
if cid in {"B", "C", "D"}:
branch = {"B": "cancel_reraised", "C": "worker_returning",
"D": "cleanup_error_raised"}[cid]
terminal = ("wrapper_terminal_value" if cid == "C"
else "wrapper_terminal_exception")
assert names == [
"worker_started", "wrapper_wait_for_started", "cancel_seen",
"cleanup_started", "harness_saw_cleanup_started",
"wrapper_observation_complete", "harness_releasing_cleanup",
"cleanup_released", branch, terminal,
"owner_collected_wrapper", "owner_collected_worker",
]
observation = events[5]
assert observation["wrapper_pending"] is True
assert observation["done_count"] == 0
assert observation["cleanup_release_is_set"] is False
elif cid == "E":
assert names == [
"worker_started", "observation_returned",
"owner_releasing_ordinary_work", "ordinary_work_released",
"worker_returning", "owner_collected_worker",
]
assert events[1]["worker_pending"] is True
assert events[1]["worker_done"] is False
assert events[1]["worker_cancelled"] is False
else:
assert names.count("ordinary_work_released") == 1
assert "cleanup_started" not in names
assert names.index("ordinary_work_released") < names.index(
"wrapper_terminal_value")
assert names[-2:] == ["owner_collected_wrapper",
"owner_collected_worker"]
async def main() -> None:
if (platform.python_implementation() != "CPython"
or sys.version_info[:3] != (3, 13, 5)
or sys.flags.optimize != 0):
emit({"record_type": "failure", "status": "BASELINE_MISMATCH",
"version": sys.version, "optimize": sys.flags.optimize})
raise SystemExit(2)
loop = asyncio.get_running_loop()
emit({
"record_type": "metadata",
"implementation": platform.python_implementation(),
"python_version": platform.python_version(),
"python_version_full": sys.version.splitlines()[0],
"executable": sys.executable, "platform": platform.platform(),
"event_loop_class":
f"{type(loop).__module__}.{type(loop).__qualname__}",
"optimize": sys.flags.optimize, "debug": loop.get_debug(),
"task_factory": repr(loop.get_task_factory()),
"timeouts_s": {"wait_for": TIMEOUT,
"observation": OBSERVATION_TIMEOUT},
})
completed = []
for case_id in CASE_IDS:
case = await run_case(case_id)
emit(case) # Preserve the raw case before checking its oracle.
verify_case(case)
completed.append(case_id)
emit({"record_type": "summary", "status": "PASS", "cases": completed})
if name == "__main__":
try:
asyncio.run(main(), debug=False)
except Exception as exc:
emit({"record_type": "failure", "status": "HARNESS_FAILURE",
"type": type(exc).__name__, "message": str(exc)})
raise
The expected outcomes and event names in verify_case are literal expectations, not values inferred from BEHAVIORS or copied from a passing run. The parent reuses this same oracle and adds record-integrity checks. Reuse is not an independent implementation of the oracle; review those literals against the contract and the case descriptions. A PASS record means the diagnostic expectations matched, not that C’s cancellation policy or D’s failing cleanup is suitable for a service.
Use event order as the primary oracle
The strongest evidence in cases B–D is not “about 50 milliseconds elapsed.” It is the causal sequence created by the Events. The worker cannot pass await cleanup_release.wait() until the fixture sets cleanup_release. After cleanup_started is observed, the harness calls asyncio.wait({wrapper}, timeout=0.02) while intentionally leaving cleanup_release unset. In the observed run, the wrapper remained in the pending set in B, C, and D. Only then did the harness set the cleanup release barrier and collect the final wrapper outcome.
That ordering survives scheduler delay within the declared fixture. A delayed scheduler can move the recorded loop.time() offsets later; it cannot set an Event that no actor has released. The Event.wait contract explains why an unset barrier blocks normal continuation. Another cancellation could interrupt that wait, but the fixture does not inject one, and its oracle rejects a missing cleanup_released event. Offsets remain observations and diagnostic context. The event-loop documentation supports monotonic measurements without promising a narrow scheduling-latency interval around a callback deadline.
The wrapper observation is also intentionally non-destructive. asyncio.wait returns done and pending sets and does not cancel pending futures when its timeout occurs. That property lets the harness ask “is the wrapper still pending while cleanup is withheld?” without injecting a second cancellation into the wrapper.
Establish ordinary completion before triggering cancellation
Case A is the negative control for the timeout story. Its fresh work_release Event is already set before the owned worker task is scheduled. The worker therefore has no reason to enter its cancellation branch. The observed wrapper outcome was the value "on-time"; the worker was done() == True, cancelled() == False, and cancel_seen remained unset. Every owned task was reconciled.
Case B changes the conditions needed to force the cancellation path. The work Event remains unset, so the worker blocks. The separately retained wrapper starts wait_for(worker, 0.03). In the observed run, the timeout mechanism reached the worker’s cancellation handler at a loop.time() offset of 0.030249 seconds, and cleanup_started followed at 0.030298. These numbers are measurements, not tolerance bands. The important fact is that the cleanup Event was still held.
The harness then used a 0.02-second observation timeout to inspect the wrapper without cancelling it. The observation returned with the wrapper still pending at an offset of 0.050528, and the ledger recorded cleanup_release_is_set == False. Only after capturing and checking that evidence did the harness set cleanup_release. The worker resumed and re-raised the original CancelledError. The wrapper raised built-in TimeoutError; the owner collected that exception and then the worker’s cancellation. The worker ended done() == True and cancelled() == True.
That behavior is directly aligned with the public wait_for contract: total wait can exceed the timeout because wait_for waits for cancellation to settle. The observed delay beyond 0.03 is not itself the proof; the held barrier is the proof that the caller had not yet received a terminal outcome while cancellation cleanup was still outstanding.
Keep normal completion separate from cleanup completion
An operation entering cleanup is not the same thing as an operation being complete. In A, ordinary work was already releasable and the worker returned normally. In B, ordinary work was not released while the worker waited for it. The worker entered its cancellation branch, announced cleanup, then blocked on a different Event. The wrapper remained pending because that cancellation path had not reached its terminal state. The finally block releases both barriers defensively, but this later release does not turn B into an ordinary-completion case.
That distinction should appear in production ownership models even though this laboratory has no external resource. “Cleanup started” is progress information. “Cleanup finished and the task’s terminal result was retrieved” is ownership completion. If service telemetry collapses those two states into a single “timed out” marker, maintainers lose the evidence needed to decide whether the worker is still alive, cancelled, failed during cleanup, or returned after suppressing cancellation.
The negative control also prevents an overbroad conclusion. wait_for does not inherently make every call exceed its timeout, and the article is not a benchmark of overhead. A completed or completable awaitable can produce its value normally. The question is what happens when the timeout path has to negotiate cooperative cancellation.
Observe how cleanup extends wait_for
Case B is the clearest demonstration of a cooperative timeout budget. The following reduced ledger is an observed run of the printed fixture on CPython 3.13.5, Linux, 2026-10-16:
Sequence | Observed event | Offset (seconds) |
3 | Worker recorded cancel_seen | 0.030249 |
4 | Worker recorded and signaled cleanup_started | 0.030298 |
6 | Observation returned: wrapper pending; cleanup release unset | 0.050528 |
7 | Harness recorded its decision to release cleanup | 0.050586 |
8 | Worker passed cleanup_release.wait() | 0.050624 |
9 | Worker re-raised cancellation | 0.050644 |
10 | Wrapper recorded built-in TimeoutError | 0.050723 |
11 | Owner collected the wrapper’s TimeoutError | 0.050767 |
12 | Owner collected the worker’s CancelledError | 0.050805 |
This run supports a bounded claim: under CPython 3.13.5 with this fixture, wait_for(worker, 0.03) initiated cancellation, and the wrapper did not finish while the worker deliberately withheld completion of cancellation cleanup. Once cleanup was released and the worker re-raised CancelledError, the wrapper produced built-in TimeoutError, with the worker terminally cancelled and no owned task left unreconciled.
It does not support a latency distribution, a scheduler SLA, or an exact upper bound such as “timeout plus cleanup always equals 50 milliseconds.” The 0.02 observation window is another laboratory trigger. Scheduler delays can affect when timer callbacks run and when tasks resume. The asyncio event-loop reference describes monotonic scheduling, not a real-time completion guarantee.
The operational lesson is about lifecycle, not networking or pool capacity. Refonte’s separate HTTPX pool-timeout diagnosis deals with a different ownership problem, connection-pool capacity after an open stream, and is not reused here as evidence for Python timeout semantics. This article’s claim stops at one worker, one wrapper, and their cooperative cancellation boundary.
Under a declared cooperative contract, B is ACCEPT: the documented behavior and observed terminal states agree. Under a service requirement that promises a hard caller completion by exactly t, the same case is HOLD because wait_for does not provide that stronger guarantee.
Return a value after cancellation has been suppressed
Case C uses the same timeout trigger and the same barriers as B. Cancellation is observed, cleanup starts, and the wrapper remains pending while cleanup_release is unset. The only behavioral change occurs after the harness releases cleanup: instead of re-raising CancelledError, the worker returns "late-result".
The observed result is intentionally uncomfortable. The wrapper returned "late-result"; the worker was done and not cancelled; every owned task was reconciled. The event sequence recorded cancel_seen at 0.030228 seconds, the pending-wrapper observation at 0.050492, the worker passing cleanup_release at 0.050583, and the wrapper recording its value at 0.050621. These are descriptive offsets from the CPython 3.13.5 run, not an expected timing range. The causal evidence is that the value followed observed cancellation and release of the cleanup barrier.
This is where the living reference documentation and the tagged implementation deserve to be read together. The high-level Python 3.13 page summarizes timeout behavior as cancellation followed by TimeoutError, while warning that cancellation can take time and that cancellation-time exceptions propagate. The tagged CPython 3.13.5 wait_for docstring is more explicit about the suppression branch: when the task suppresses cancellation and returns a value, that value is returned. The observed C result matches the tagged implementation description.
A value is therefore not evidence that the original elapsed-time business contract was satisfied. A caller that only checks result == "late-result" can miss the fact that cancellation was already requested. If the system’s contract says “a cancellation request means this operation must not report success,” then C is a policy violation even though the task reached a clean Python terminal state.
Do not treat cancellation suppression as a repair
Python’s cancellation guidance says CancelledError should generally be propagated after cleanup, and the exception reference says it must be re-raised in almost all situations. Case C is a diagnostic counterexample precisely because it breaks that default discipline.
The repair is not “teach the caller that late values are fine.” The repair is to make cancellation handling intentional. If the worker owns cleanup, it should normally perform that cleanup and re-raise cancellation. If a rare design genuinely suppresses cancellation, the API owner must explicitly define what a post-cancellation value means and how callers distinguish it from ordinary success. That policy question cannot be inferred from done() or cancelled() alone. In particular, cancelled() is a terminal classification, not a history of cancellation requests. The Task.cancelling() documentation distinguishes that request count from cancelled(). C deliberately does not call uncancel(); it is a counterexample, not a complete design for suppressing cancellation.
Nor is asyncio.shield a hard-stop repair. Shielding prevents a particular cancellation from propagating to the protected awaitable; it moves the design away from cancellation, not toward a stronger forced-termination deadline. The laboratory therefore does not use it.
For the normal service policy assumed here, C is REPAIR: cancellation was swallowed. A hard-deadline claim remains HOLD regardless, because even a repaired cooperative worker can spend time in cancellation cleanup before the caller obtains a terminal result.
Preserve exceptions raised during cleanup
Case D keeps the same cancellation and barrier ordering, then raises RuntimeError("cleanup-failed") after cleanup_release is set. The observed wrapper outcome was RuntimeError, not TimeoutError; the worker was done and not cancelled; no owned task remained unreconciled. In the CPython 3.13.5 run, the key offsets were 0.030234 seconds for cancel_seen, 0.050465 for the pending-wrapper observation, 0.050540 for passing the cleanup barrier, and 0.050618 for the wrapper recording the exception. The owner subsequently retrieved both terminal exceptions.
That result is exactly the kind of diagnostic information an owner must not relabel. The public wait_for documentation says that if an exception happens during cancellation, it is propagated. Changing RuntimeError("cleanup-failed") into a generic timeout would destroy evidence about where the terminal failure occurred. It would also make cleanup defects look like expected timeout outcomes, which leads reviewers toward the wrong repair.
The worker’s cancelled() == False is also important. Cancellation was requested and observed, but the task’s terminal state is an exception other than cancellation because the cleanup path raised RuntimeError. A task state inventory therefore needs at least the final outcome class, not just a Boolean “was cancellation attempted?” flag.
The ownership decision is REPAIR the cleanup path, preserve the original exception class and event history, then rerun the fixture. Whether the service ultimately maps that internal exception to a public API error is a separate boundary decision; the test evidence itself should remain intact.
Separate operation failure from harness failure
D’s RuntimeError("cleanup-failed") is an intended diagnostic case. The fixture passes when it observes that exact exception, because the purpose of the case is to verify exception preservation. Treating D as a harness failure would be as misleading as relabeling the exception TimeoutError.
A parent watchdog expiration is different. If the child fails to produce the required ledger and the parent’s 10-second safeguard expires, the parent records HARNESS_FAILURE. That says the experiment did not complete under the harness. It does not mean B’s timeout behavior “passed,” D’s cleanup failed as intended, or the worker was safely reconciled. Missing evidence is missing evidence.
Likewise, a baseline mismatch, malformed output, or an assertion that exposes a broken fixture should lead to RERUN after repair, not optimistic interpretation. The distinction protects original evidence: operation outcomes remain operation outcomes; test-infrastructure failures remain test-infrastructure failures.
Compare an observation timeout without cancelling the worker
Case E is the narrow comparison with asyncio.wait. The worker starts with a fresh unset work_release Event. The owner calls asyncio.wait({worker}, timeout=0.03). Python documents this API as returning (done, pending) sets and explicitly says it does not cancel futures or tasks when the timeout occurs.
That is what the complete run observed. At an offset of 0.030195 seconds, the observation returned with done_count == 0 and pending_count == 1. The worker was still pending, and worker.cancelled() was False. The owner then set work_release, joined the same retained worker reference, and collected "after-observation". The worker ended done, not cancelled, and reconciled. E creates no wrapper task: its wrapper_outcome field records the observation result solely to keep the case ledger’s columns comparable.
The semantics differ from wait_for in exactly the way the control needs. wait_for uses timeout to initiate cancellation of the awaited work and then resolve that cancellation path. wait uses its timeout to end an observation; it reports pending work without cancelling it. Returning from the observation does not mean the worker is done, and it does not transfer ownership to the event loop.
This is useful when the policy is genuinely “look for up to some budget, then make another ownership decision.” It is dangerous if reviewers silently reinterpret pending as “stopped.” In E, the worker remains the caller’s responsibility until the owner releases it and retrieves the terminal result.
No exact wall-clock upper bound is proved by E. The documentation describes a timeout controlling how long the wait operation attempts to wait before returning, but scheduler and process timing still prevent a real-time guarantee. The safe conclusion is semantic: the observation ended without cancelling the worker, and the retained owner subsequently joined it.
Repair cancellation handling at the ownership boundary
A maintainable worker should make cancellation ownership obvious. The default repair is small: perform resource cleanup in finally, or catch CancelledError only when the code needs to record or await cleanup and then re-raise it. Python’s task-cancellation guidance recommends try/finally for robust cleanup and says caught CancelledError should generally be propagated when cleanup is complete.
The following complete function isolates the cancellation-only pattern used in B. It uses Events rather than an unspecified resource API:
import asyncio
async def cooperative_worker(
work_release: asyncio.Event,
cleanup_started: asyncio.Event,
cleanup_release: asyncio.Event,
) -> str:
try:
await work_release.wait()
return "on-time"
except asyncio.CancelledError:
cleanup_started.set()
await cleanup_release.wait()
raise
This function handles cleanup on cancellation only. A resource that also needs cleanup after ordinary success or another exception requires an appropriate finally path. The exact resource operation remains outside this laboratory. The ownership rule is unchanged: the cleanup owner does not silently turn cancellation into success, and the task owner collects the terminal state rather than interpreting cancel() as “finished.” Refonte’s material on secure and scalable API design offers broader backend context, not authority for this cancellation rule.
Repeated blind calls to cancel() are not a substitute for ownership. A second cancellation can arrive while cleanup is suspended at an await, changing the cleanup path rather than making it magically synchronous. This article therefore uses one timeout-triggered cancellation and a controlled cleanup release, then joins the known task.
Audit the cleanup path without promising a hard stop
A cleanup audit should identify synchronous steps, awaited steps, their owners, and the expected outcome if any step fails. Synchronous cleanup can occupy the event loop until it returns; awaited cleanup can suspend and can be interrupted by another cancellation. Neither form supplies an automatic hard-stop bound. A resource-specific deadline is useful only within that resource’s documented contract; nesting another cooperative timeout does not establish forced termination. These are design limits, not additional resource experiments performed by this fixture.
If a service has an actual hard-deadline requirement, HOLD the claim until the architecture supplies a mechanism whose contract matches that requirement. The standard wait_for experiment here is evidence about cooperative task cancellation, not a recipe for forced task termination. The process safeguard discussed later is deliberately outside the tested event loop and is limited to a disposable child harness; it is not presented as a general production task-kill pattern.
Keep the process watchdog outside the experiment
The parent runs outside the tested event loop. It launches the child with the same interpreter, captures stdout and stderr, and applies subprocess.run(..., timeout=10). It preserves raw output in a newly created evidence directory before interpreting it. A zero exit is necessary but insufficient: the parent checks the metadata, exact A–E manifest, summary, task outcomes, and agreement between streamed events and case ledgers. Missing records, unexpected stderr, or watchdog expiration produce HARNESS_FAILURE and a nonzero exit.
Save the following parent as run_wait_for_lab.py beside the child. Importing verify_case defines the shared literal oracle without starting an event loop; the experiment itself runs only in the disposable child. The parent’s record-integrity checks are separate from that oracle.
from future import annotations
import json
import pathlib
import subprocess
import sys
import tempfile
# Importing this file defines the oracle; it does not run an event loop.
from wait_for_lab_child import CASE_IDS, verify_case
CHILD = pathlib.Path(__file__).with_name("wait_for_lab_child.py")
def validate_report(raw: bytes) -> None:
records = [json.loads(line) for line in raw.decode("utf-8").splitlines()]
if not records or any(not isinstance(r, dict) for r in records):
raise ValueError("missing or malformed ledger")
allowed = {"metadata", "event", "case", "summary"}
if any(r.get("record_type") not in allowed for r in records):
raise ValueError("unexpected record or child failure")
metadata = [r for r in records if r["record_type"] == "metadata"]
cases = [r for r in records if r["record_type"] == "case"]
summaries = [r for r in records if r["record_type"] == "summary"]
if len(metadata) != 1 or metadata[0] != records[0]:
raise ValueError("missing or misplaced metadata")
meta = metadata[0]
if (meta["implementation"] != "CPython"
or meta["python_version"] != "3.13.5"
or meta["optimize"] != 0 or meta["debug"] is not False
or meta["task_factory"] != "None"
or meta["timeouts_s"] != {"wait_for": 0.03,
"observation": 0.02}):
raise ValueError("baseline mismatch")
if [c.get("case_id") for c in cases] != list(CASE_IDS):
raise ValueError("missing, duplicate, or reordered case")
expected_summary = {"record_type": "summary", "status": "PASS",
"cases": list(CASE_IDS)}
if summaries != [expected_summary] or records[-1] != expected_summary:
raise ValueError("missing or misplaced final summary")
stream = [r for r in records if r["record_type"] == "event"]
expected_stream = []
for case in cases:
verify_case(case)
expected_stream.extend(
{"record_type": "event", "case_id": case["case_id"], **event}
for event in case["events"]
)
if stream != expected_stream:
raise ValueError("stream and case ledger differ")
def main() -> int:
if sys.flags.optimize != 0:
print("PARENT status: HARNESS_FAILURE (optimization enabled)")
return 2
if sys.argv[1:] not in ([], ["--watchdog-control"]):
print("Usage: run_wait_for_lab.py [--watchdog-control]")
return 2
control = sys.argv[1:] == ["--watchdog-control"]
command = [sys.executable, "-I", "-u", str(CHILD)]
if control:
# Separate containment control, not an A-E worker experiment.
command = [sys.executable, "-I", "-u", "-c",
"import time; print('WATCHDOG_CONTROL_STARTED', "
"flush=True); time.sleep(60)"]
evidence = pathlib.Path(tempfile.mkdtemp(
prefix="wait-for-evidence-", dir=CHILD.parent,
))
print(f"PARENT evidence: {evidence}", flush=True)
print(f"PARENT Python: {sys.version.splitlines()[0]}", flush=True)
print(f"PARENT command: {command!r}", flush=True)
try:
completed = subprocess.run(
command, capture_output=True, timeout=10, check=False,
)
except subprocess.TimeoutExpired as exc:
(evidence / "child.stdout").write_bytes(exc.stdout or b"")
(evidence / "child.stderr").write_bytes(exc.stderr or b"")
print((exc.stdout or b"").decode("utf-8", errors="replace"))
print((exc.stderr or b"").decode("utf-8", errors="replace"),
file=sys.stderr)
print("PARENT status: HARNESS_FAILURE (process safeguard)")
return 124
except OSError as exc:
print(f"PARENT status: HARNESS_FAILURE ({exc})")
return 2
(evidence / "child.stdout").write_bytes(completed.stdout)
(evidence / "child.stderr").write_bytes(completed.stderr)
print(f"PARENT child_exit_code: {completed.returncode}")
print(completed.stdout.decode("utf-8", errors="replace"), end="")
print(completed.stderr.decode("utf-8", errors="replace"),
end="", file=sys.stderr)
try:
if control or completed.returncode != 0 or completed.stderr:
raise ValueError("control returned, child failed, or stderr present")
validate_report(completed.stdout)
except (AssertionError, KeyError, TypeError, ValueError) as exc:
print(f"PARENT status: HARNESS_FAILURE ({type(exc).__name__}: {exc})")
return 2
print("PARENT status: PASS")
return 0
if name == "__main__":
raise SystemExit(main())
From that writable directory, run the parent using an executable that reports CPython 3.13.5. The command below uses the conventional python3.13 name; select the installed 3.13.5 executable rather than assuming every 3.13 installation is the same patch release.
python3.13 -VV
python3.13 run_wait_for_lab.py
Observed output excerpt from the complete run, CPython 3.13.5 on Linux, 2026-10-16. Event and case records are omitted from this display, not from validation:
PARENT Python: 3.13.5 (main, Jul 15 2026, 20:25:40) [GCC 14.2.0]
PARENT child_exit_code: 0
{"cases": ["A", "B", "C", "D", "E"], "record_type": "summary", "status": "PASS"}
PARENT status: PASS
A separate containment control deliberately starts a disposable child that waits without running the A–E experiment. Run it with the following command. Its expected nonzero outcome must not be counted as a passing worker case:
python3.13 run_wait_for_lab.py --watchdog-control
Observed containment-control excerpt on the same CPython 3.13.5 environment, 2026-10-16; the parent process exited with status 124:
WATCHDOG_CONTROL_STARTED
PARENT status: HARNESS_FAILURE (process safeguard)
The retained partial stdout contains the control’s startup marker and no A–E summary. This exercises the expiration branch without pretending that the child completed cancellation cleanup. Every normal run and control receives a fresh evidence directory, so rerunning does not overwrite the previous stdout and stderr.
The parent’s 10-second timeout is deliberately not described as “the child always dies by exactly 10 seconds.” Python’s subprocess documentation says process creation may itself be uninterruptible on many platform APIs, and after timeout the implementation kills and waits for the child before surfacing TimeoutExpired. OS scheduling adds another reason not to turn this into a real-time guarantee.
The safeguard also has a strict rollback limit. Killing a disposable child can end that child process; it does not reverse effects the child may already have caused outside the process. External effects are excluded from this fixture, so the run does not test rollback, retry safety, or distributed compensation. That limit is one reason the watchdog belongs to harness containment rather than the service’s business semantics.
Reconcile every owned task before accepting the run
The fixture’s acceptance unit is not “the wrapper printed something.” It is the complete ownership manifest. Cases A–D each explicitly create one worker and one wrapper; E creates one worker. Those nine task references are retained and reconciled individually. Every terminal value or BaseException is retrieved from a completed task. The final record includes worker.done(), worker.cancelled(), named owned-task states, and an owned_task_unreconciled list that must be empty. The top-level coroutine is managed by asyncio.run; it is not an extra case worker.
The complete observed outcome ledger for the printed CPython 3.13.5 fixture was:
Case | Wrapper or observation outcome | Worker terminal outcome | Worker state | Ownership |
A | Value: on-time | Value: on-time | done=True | All retrieved; |
B | Built-in TimeoutError | CancelledError | done=True | All retrieved; |
C | Value: late-result | Value: late-result | done=True | All retrieved; |
D | RuntimeError | Same RuntimeError | done=True | All retrieved; |
E | Observation: 0 done, | Value: after-observation | done=True | All retrieved; |
Python’s task documentation separately warns callers to save references to created tasks and notes that uncollected task exceptions can otherwise surface as “Task exception was never retrieved” diagnostics. The laboratory takes the stronger ownership position appropriate to a test: every task it creates is in the manifest, and every manifest entry must reach a collected terminal state.
The wrapper outcome and the worker inventory answer related questions. In B, with the printed wrapper, one timeout-triggered cancellation, and no additional interruption, the wrapper does not deliver TimeoutError while the worker’s cleanup is still pending. That waiting is exactly what B demonstrates. A missing worker record would instead make the report incomplete: the reviewer could not reconcile the declared manifest against the actual task reference. The appropriate decision is HOLD ownership and repair the reporting path, not infer that wait_for abandoned this worker.
The inventory is intentionally limited to tasks created by the fixture. It does not sweep asyncio.all_tasks() and claim ownership over runtime internals or unrelated application work. This makes the evidence auditable: known task IDs, known expected outcomes, and one terminal retrieval for every task the fixture itself created. The collector first observes terminal completion, then calls result(); it does not catch a cancellation of its own wait and label that as the worker’s outcome. Normal collection and finally collection use the same retained references, while the outcomes dictionary prevents duplicate owner records.
Review evidence before replacing it. An empty output stream, a missing case, a missing summary, or a disagreement between streamed and nested event records fails the parent’s gate. A record saying that an owned task was not retrieved also fails. Preserve the raw streams, script version, and invocation, repair the identified harness problem, and generate a new run rather than filling missing rows with expected outcomes.
Choose accept, repair, rerun, or hold
The same low-level result can imply different operational decisions depending on the declared contract. That is why the decision matrix includes both evidence and requirement:
Evidence | Cooperative timeout contract | Hard caller deadline claim | Ownership decision |
A returns normally; worker terminal and reconciled | Behavior matches ordinary completion | No hard-deadline proof beyond this one completion | ACCEPT normal case; HOLD generalized hard claim |
B cancellation is observed; cleanup delays wrapper; worker re-raises; wrapper gets TimeoutError | Matches documented cooperative behavior | Wrapper can outlive t | ACCEPT cooperative semantics; HOLD hard claim |
C cancellation is swallowed and a value returns | Terminal state is known, but default cancellation discipline is violated | Value after timeout trigger defeats deadline intuition | REPAIR worker policy; HOLD hard claim |
D cleanup raises RuntimeError and wrapper preserves it | Failure evidence is correct and complete | Not a timeout success | REPAIR cleanup; preserve exception |
E observation returns pending; owner later joins worker | Matches asyncio.wait observation semantics | Observation is not termination | ACCEPT only with explicit later ownership |
Parent watchdog expires or required ledger is missing | Evidence incomplete | Evidence incomplete | RERUN after harness repair |
Any owned task lacks a terminal retrieval | Ownership incomplete | Ownership incomplete | HOLD |
This matrix deliberately separates “library behavior is as documented” from “service policy is acceptable.” B can be a perfectly correct asyncio result and still invalidate a business promise that says the caller is guaranteed an outcome by a fixed wall-clock instant. C can reach a clean terminal Python state while still violating a service cancellation policy. D can be a passing diagnostic test while identifying cleanup code that needs repair.
The evidence would change the decision if a rerun on the declared baseline produced a different terminal class, if the wrapper became terminal while the B–D cleanup barrier was demonstrably unset, if task ownership could not be reconciled, or if the service requirement changed from cooperative cancellation to an independently specified hard-deadline mechanism. Until then, the bounded conclusion stays narrow.
Define the evidence that blocks acceptance
Four conditions block ACCEPT outright. First, a required outcome record is missing. Second, an owned task remains pending or its exception/result has not been retrieved. Third, the observed lifecycle violates the declared service contract even if Python is behaving correctly. Fourth, the external watchdog terminates the child before the required ledger is complete.
A fifth condition applies to claims rather than runs: evidence from this cooperative fixture cannot be promoted into a universal hard real-time statement. The event barriers establish causal ordering within a cooperative event loop. They do not prove behavior under CPU starvation, a blocked event-loop thread, another operating system, another Python implementation, or effects outside the process.
That boundary is part of reproducible failure analysis. Preserve the original ledger, repair only the component whose contract is violated, and rerun under a recorded environment. Do not “fix” an uncomfortable result by relabeling exceptions, dropping late records, or treating process termination as evidence that the intended task cleanup succeeded.
Assign service and test-harness responsibilities
Responsibility should follow ownership. The worker/resource owner defines and implements cleanup, including whether cancellation is caught for logging and then re-raised. The API owner defines what the timeout argument means to callers: cooperative cancellation budget, observation window, or something stronger supplied by another mechanism. The test-harness owner preserves environment metadata, controls barriers, reconciles all created tasks, and keeps the process-level safeguard independent of the event loop under test.
That split is more useful than a blanket rule such as “all timeouts are hard” or “all cancellations are best effort.” It tells a reviewer where a failed case belongs. C is primarily a worker cancellation-policy problem. D is a cleanup failure that must remain visible. A missing worker record is a harness/ownership problem. A business requirement for exact caller completion by t is an API-contract problem, because wait_for documents a cooperative cancellation handshake that can extend total wait.
Refonte’s broader job-ready backend skills article discusses backend capability at a wider level; this playbook’s contribution is narrower: reviewers should be able to turn a timeout intuition into a testable ownership contract with reproducible evidence.
Rollback has limits. If a change to the wrapper or worker policy is wrong, the code change can be reverted and the lab rerun. That does not imply reversal of effects that a real production worker may already have caused. This article intentionally excludes remote effects, databases, sockets, retries, and distributed compensation, so it makes no safety claim about them.
For service review, the practical gate is simple: no ACCEPT while the timeout semantic is ambiguous, while a task is unjoined, or while cleanup evidence is missing. Repair the owner’s part, preserve the original failed ledger, and rerun the controlled case before promoting the policy.
Strengthen backend testing through structured practice
The qualified lesson is not “never use asyncio.wait_for.” It is to use it under the contract Python actually documents. A timeout can initiate cancellation without immediately producing the caller’s terminal outcome. Cooperative cleanup can extend the wrapper lifetime; cancellation suppression can produce a late value; cleanup can raise its own exception; and an observation timeout such as asyncio.wait can return while the worker continues.
The executed CPython 3.13.5 fixture makes those branches inspectable with one worker, explicit Events, retained task references, a terminal-state ledger, and an external process safeguard. That is enough to ACCEPT documented cooperative behavior under the declared contract, REPAIR swallowed cancellation or cleanup defects, HOLD hard-deadline claims and unreconciled ownership, and RERUN when the harness itself fails. It is not evidence for a production latency percentile, forced task termination, or rollback of external effects.
For readers considering broader learning paths, Refonte’s choosing a programming path material offers adjacent context rather than technical authority for this experiment. The program page checked on 2026-10-16 advertises three months at 10–12 hours per week, with basic programming concepts as a prerequisite. It lists Node.js and Express, database integration, RESTful APIs, testing/debugging, and deployment. It describes a Training Certificate and a Certificate of Internship on successful completion, alongside “Potential Internship” language, not a guaranteed external placement or job. These published topics do not establish that this specific asyncio laboratory is taught. For structured practice in backend implementation and testing, readers may review Refonte Learning’s Backend Developer Program and its published syllabus.
