Two Ways to Cancel
Cancellation that doesn't raise until you blink, a peek synthesized out of a blocking get, and a task group that quietly turns the peek into a lie. Three primitives from Part 5 composed into the load-bearing trick in BaseHTTPMiddleware — and the seam where it leaks.
Part 5 gave you cancel scopes, checkpoints, and task groups as separate primitives. This post composes them into one of the most consequential constructs in modern Python web frameworks: a non-blocking peek at a queue whose only interface is a blocking get. The construct is legal anyio, beautiful, and — sometimes — wrong.
The whole story rests on one small asymmetry in how cancellation lands. Get that asymmetry in your head and the rest follows.
§1The asymmetry, in one line
From Part 5, §4: scope.cancel() arms a call_soon callback. The callback finds tasks parked on Futures (i.e. _fut_waiter is set) and calls task.cancel() on them — which under the hood throws CancelledError at the paused yield self. The currently-running task is skipped on purpose.
No suspension, no delivery. Cancellation is scheduled; it lands the next time you blink.
That's it. Anything below builds on this one fact. If you blink (suspend on a Future), the rescheduled callback finds you parked and cancels you. If you don't blink — if you run straight through to the end of the scope without parking on anything unresolved — the callback never gets its turn before the scope exits, and the cancellation is silently dropped. Same scope, same cancel() call, two outcomes depending on whether the body suspended.
That asymmetry is not a bug. It's the affordance one clever piece of Starlette turns into a primitive.
§2The pre-armed scope
Suppose you have an ASGI receive callable. Its only interface is await receive() — a coroutine that returns the next message, blocking until one is available. You want to ask "is there a message ready right now, without waiting?" There is no such API. Build one out of what you have.
# starlette/requests.py (1.0.0) — Request.is_disconnected async def is_disconnected(self) -> bool: if not self._is_disconnected: message: Message = {} # If message isn't immediately available, move on with anyio.CancelScope() as cs: cs.cancel() # pre-armed: tripped before the await message = await self._receive() if message.get("type") == "http.disconnect": self._is_disconnected = True return self._is_disconnected
Three lines of anyio, doing something the API never offered. Trace it with §1 in hand. The scope is entered; cs.cancel() trips it — arming the call_soon callback, raising nothing yet. Then await self._receive(). Two outcomes:
- A message is already queued.
_receive()pulls it and returns without ever parking on an unresolved Future. Noyield self, no suspension. Thecall_soon'd cancellation never gets a turn. The scope exits with a real message in hand. The peek succeeded. - The queue is empty.
_receive()parks on a Future —yield self, control to the loop. The rescheduled_deliver_cancellationruns, finds the task parked, and cancels it.CancelledErroris raised into theawait; the scope catches its own cancellation and exits cleanly withmessagestill empty. The peek found nothing — "move on," as the comment says.
That is a genuinely beautiful use of the model: "give me the message if you can hand it over without making me wait; otherwise pretend I never asked." It is queue.peek() synthesized out of queue.get() plus a pre-armed cancel scope. It works precisely because of the §1 asymmetry, and the Part-4 fact that a synchronous return never suspends.
A pre-armed cancel scope is a bet on whether the next await parks. Win the bet (synchronous return) and you keep the value. Lose it (real suspension) and you exit empty-handed but unharmed. The whole construct is safe only because anyio defers cancellation delivery to the next checkpoint instead of raising it inline.
§3Why the join is the checkpoint
Now the seam. The peek works as long as _receive is the only thing the task awaits inside the scope. The moment something else slips a suspension into that block — anything at all that parks on a Future — the pre-armed cancellation lands on that suspension, not on _receive, and the peek returns nothing even when a message was sitting in the queue.
The most common way this happens is a task group. Part 5, §6 spelled it out: entering a task group is free; exiting awaits the join. If a wrapper handed you a _receive that spawns a sibling task — say, to race the real receive against a fallback — then control flow looks like this:
# conceptual — what BaseHTTPMiddleware wraps _receive with async def wrapped_receive(): async with anyio.create_task_group() as tg: tg.start_soon(some_sibling) msg = await real_receive() # maybe synchronous — message ready return msg # ↑ tg.__aexit__ awaits the sibling's join — THIS is a checkpoint
Even when real_receive() returns synchronously with a message in hand, control still has to pass through tg.__aexit__, which awaits the sibling's join. That await is a real suspension. The pre-armed cancellation, still patiently scheduled, lands there. CancelledError raises out of the join, the scope swallows it, the outer peek-caller sees an empty message, and concludes "no message was ready" — even though one was.
The peek didn't lie to itself. It told the truth about the suspension it found. The suspension just happened to be the wrong one.
Pre-armed scope wraps a wrapped-receive whose task group joins on exit. The synchronous return of the real receive is shadowed by the join's await — and that's where the cancellation lands.
A pre-armed scope is only as honest as its enclosed work. The moment you compose it with anything that introduces an incidental suspension — a task group join, a logging middleware, a context-manager that flushes a buffer — the bet shifts from "did the real call park?" to "did anything in this scope park?" The semantics quietly degrade from peek to "did we suspend at all," and now the peek answers a different question than the one its name promises.
This is the exact failure mode the standalone SSE field report tracks through five middleware layers. The point of this post is that you can predict it from first principles: cancellation is deferred, checkpoints land on any suspension, and task groups make checkpoints out of thin air at exit. The wider lesson is that composition in anyio is real — but the composition's truth is determined by which suspensions exist where, not by the names on the wrappers.
§4Take with you
- No suspension, no cancellation delivery. The whole peek depends on this asymmetry: arm a scope, then run code that doesn't park, and the cancellation is silently dropped.
- The pre-armed scope is
queue.peek()built fromqueue.get(). Legal anyio. Beautiful. Real Starlette code. - A task group's exit is a checkpoint. Even when the body finished synchronously, the join awaits — and any pre-armed cancellation lands there.
- The peek answers the question "did this body suspend," not "was the message ready." Those are the same question only when the body suspends on nothing else. Wrappers break that invariant.
The protocol layer, the discipline layer, and the trap that connects them are all behind us. Part 7 puts the loop to work behind a real request-serving stack — and the standalones (Middleware is an Onion, The Peek That Always Returns False) trace the failure mode from this post through Starlette's actual implementation.