Skip to content

← Orientation and the Gate step 12 of 13

Hard Primitives

Placement 5/6: async cancellation

Placement diagnostic, 5 of 6. About seven minutes. If this one is opaque, T9 (Concurrency Without Regret) is where you start.


Cancellation in asyncio is not a flag you poll. task.cancel() schedules a CancelledError to be thrown into the coroutine at its current suspension point — which means it can only ever arrive at an await, and it behaves like any other exception once it does: your except clauses see it, your finally blocks run, and if you swallow it the task completes normally.

The job coroutine is given, and it is instrumented so you can watch all of that happen. Write the driver:

async def drive(reraise: bool, ticks: int) -> list[str]:
  1. Start job(log, reraise) as a task — it must run concurrently with you, not be awaited inline.
  2. Yield control to the event loop exactly ticks times.
  3. Cancel the task.
  4. Await it, and append one final entry to the log: "propagated" if a CancelledError reached you, otherwise "returned:" + result.
  5. Return the log.

solve is given: asyncio.run(drive(reraise, ticks)).

The four regimes the tests walk through

ticks=0 — you cancel before the task has ever been scheduled. job never runs, the log has no "start", no "cleanup" runs, and you get ["propagated"]. A task cancelled before its first step does not execute its finally blocks, because there is no frame to unwind. This is why “acquire the resource inside the task” and “acquire it before creating the task” are different designs.

ticks=1..4 — cancellation lands mid-flight. job catches it, logs "caught", and its finally logs "cleanup". Then reraise decides everything:

  • reraise=True — the CancelledError propagates, the task ends up cancelled, and your await task raises. Log ends "propagated".
  • reraise=Falsejob returns "swallowed" instead. The task completes successfully. Your await task returns a value and never raises. Log ends "returned:swallowed".

ticks>=5job has already finished by the time you call cancel(). Cancelling a completed task is a no-op that returns False, and await task hands you "finished".

Why swallowing is the bug

In the reraise=False regime, job was told to stop and did not. Its caller cannot tell the difference between “completed the work” and “was cancelled and lied about it”. Scale that up: a TaskGroup shutting down after a sibling failure waits forever on the one task that catches CancelledError and carries on; a server draining connections never drains. The rule is absolute — catch CancelledError only to clean up, then re-raise. If you need a value out of a cancelled operation, put it somewhere the caller can reach, do not suppress the cancellation to return it.

Note also that except Exception does not catch this. Since Python 3.8 CancelledError inherits from BaseException precisely so that the ubiquitous except Exception: log.exception(...) does not eat it. except BaseException and a bare except: do — which is one concrete reason not to write either.

Loading visualization…