Cancellation: an event, not a clock — gRPC Context cancellation and RST_STREAM

A deadline fires from a clock ('time's up') while cancellation fires from an event ('never mind'); gRPC bundles both in one Context object so aborting a parent call propagates 'stop now' down the very same chain a deadline would.

Previously

The shrinking budget bar stopped zombie work when the clock ran out — but when the user closes the tab at 200ms no clock has expired, so we need a second trigger that says 'stop now' and rides the exact same propagation path.

Scene 06a

Cancellation: an event, not a clock

  1. Watch
  2. Try it
  3. Predict
  4. Capture
CANCELLATION — an event: “never mind”cancel propagation: ONBUDGET · 9.8s left of 10sRST_STREAM @ 200msgrpc-timeout: 9.9sCONTEXT⏱ 9.9s✕ cancelgreet("Ada")grpc-timeout: 9.8sCONTEXT⏱ 9.8s✕ cancelgreet("Ada")A · frontendcancelledgets 10s · work 4sCPUearly-return (aborted)B · gatewaycancelledgets 9.9s · work 4sCPUearly-return (aborted)C · greetercancelledgets 9.8s · work 4sCPUearly-return (aborted)Cancel at 200ms rides the Context down to C — every hop aborts.RST_STREAM (a scene-4 frame) is the cancel event; the Context carries it.No clock fired — plenty of deadline remained; this stop came from an event.
cancellation: a 'never mind' EVENT, not a clock
Context chip carries {deadline, cancel} as one unit →
What to watch for

The chain is running greet("Ada") with a 10-second deadline — nearly all of it still on the clock. Earlier we learned a deadline propagates the remaining time so a slow downstream stops wasting effort once time runs out. But time hasn't run out here. Instead, the client decides to quit — imagine the user closing the tab. That decision is not a clock reading; it's a one-off event. gRPC reuses the RST_STREAM frame from the transport layer to signal it: the first thing the chain learns is that the caller said 'never mind'. We call this stop-now signal cancellation — a trigger that fires from an event rather than from elapsed time. Watch the cancel at 200ms ripple down A→B→C, flipping every hop from active to cancelled so all three servers abort their work mid-computation.

Implementation

Highlighted lines are the ones running in the diagram right now.

Client.cancel
the caller quits — an event, no clock involved
def cancel(call):
# fired by an EVENT (tab closed), not by elapsed time
call.stream.send(RST_STREAM) # HTTP/2 cancel frame
call.ctx.cancel(reason = CANCELLED)
Server.handle
each hop watches its Context and early-returns on cancel
def handle(req, ctx):
for step in work(req):
if ctx.cancelled: # observed, not polled-on-a-clock
return # abort mid-flight; nothing to reply
partial = compute(step)
# the downstream call inherits THIS ctx
child = stub.call(req, ctx = ctx)
return assemble(partial)
Context.cancel
one object carries {deadline, cancel}; it fans out to children
def cancel(self, reason):
self.cancelled = True
# same path a deadline-expiry would walk
for child in self.children:
child.cancel(reason) # propagate down A->B->C

Where this sits in Build a gRPC-style RPC framework

Scene 06a of 14, in the Reliability act — Deadlines, cancellation, retries, and the interceptor onion.. A deadline fires from a clock; cancellation fires from an event. Both ride one Context object down the chain, so aborting a parent stops all the doomed downstream work.

Up next. Stopping doomed work is half the reliability story; the other half is re-trying work that failed — but blind retries against a struggling service are how one slow backend becomes a self-sustaining outage.

All 14 scenes in Build a gRPC-style RPC framework · Every curriculum

Built with Arqly
Every scene in Build a gRPC-style RPC framework builds on the one before it.All 14 Build a gRPC-style RPC framework scenes