Skip to content

← Getting Started with Tests and Type Checks step 12 of 13

Hard Primitives

Placement 5/6: Cancel an asyncio Task at Each Stage

Solve this without the hints and you can skim the concurrency track. It is the hardest of the six tests.

Calling an async def function gives you a coroutine, which can pause at each await. The asyncio event loop runs coroutines in one thread and switches between them at each pause. A task is a coroutine that the event loop runs on its own, without anyone awaiting it yet. Cancelling a task asks it to stop. After task.cancel(), the task sees an asyncio.CancelledError at its next await. The task can clean up and let the error through, or catch it and return normally.

The starter's job runs four stages and writes each one to a shared log. The stages are fetch, parse, store and notify. Before each stage it pauses with await asyncio.sleep(0), which lets other tasks run.

What to write

Write only drive(reraise: bool, ticks: int) -> list[str]. Leave job, STAGES and solve as they are. ticks is zero or more.

  1. Make an empty log and start job(log, reraise) with asyncio.create_task. Keep the task it returns.
  2. Run await asyncio.sleep(0) exactly ticks times. Each one lets the job take one step.
  3. Call task.cancel() once.
  4. Await the task. If that raises asyncio.CancelledError, add "propagated" to the log. If it returns a string, add "returned:" followed by that string.
  5. Return the log.

Don't await job directly. That would run the job to the end before drive could call cancel(). Don't change how the event loop creates tasks either.

CancelledError is a subclass of BaseException, not Exception, so except Exception does not catch it. Catch asyncio.CancelledError by name.

What the log shows

  • ticks == 0. You cancel the job before it starts, so it never logs "start" or reaches its finally. The log is ["propagated"] for both values of reraise.
  • ticks from 1 to 4. The job logs "start" and then ticks - 1 stages. cancel() interrupts its next sleep, so it logs "caught", then "cleanup" from its finally. With reraise=True it raises the error again, and you add "propagated". With reraise=False it returns "swallowed", and you add "returned:swallowed".
  • ticks of 5 or more. The job has already finished. Calling cancel() on a finished task does nothing and returns False.
solve(True, 3)
# ["start", "fetch", "parse", "caught", "cleanup", "propagated"]
solve(False, 5)
# ["start", "fetch", "parse", "store", "notify", "cleanup", "returned:finished"]

In real code, clean up and raise the error again. asyncio.timeout() and TaskGroup use cancellation to stop work, and a task that swallows it can break them.