Context Tree — Junior Level¶
Table of Contents¶
- Introduction
- Prerequisites
- Glossary
- Core Concepts
- Real-World Analogies
- Mental Models
- Pros & Cons
- Use Cases
- Code Examples
- Coding Patterns
- Clean Code
- Product Use / Feature
- Error Handling
- Security Considerations
- Performance Tips
- Best Practices
- Edge Cases & Pitfalls
- Common Mistakes
- Common Misconceptions
- Tricky Points
- Test
- Tricky Questions
- Cheat Sheet
- Self-Assessment Checklist
- Summary
- What You Can Build
- Further Reading
- Related Topics
- Diagrams & Visual Aids
Introduction¶
Focus: "Where does a context come from? What does deriving one do? Why does cancelling the parent magically cancel everything below it?"
A Context is never a flat value. It is a node in a tree. The tree is what makes context useful: a single cancel call at the top of an HTTP request reaches every database query, RPC, goroutine, and timer started underneath it. Without the tree, cancellation would only travel as far as you remembered to wire it.
This file teaches the tree by hand. We will:
- Look at
Background()andTODO()— the only two roots Go gives you. - Derive children with
WithCancel,WithTimeout,WithDeadline, andWithValueand see how each adds a node. - Watch a parent cancellation race down to its grandchildren.
- See why a child's cancellation never travels back up.
- Meet
WithCancelCause(Go 1.20+),AfterFunc, andWithoutCancel(Go 1.21+). - Draw the tree with graphviz and mermaid so the picture sticks.
By the end you should be able to read a piece of unfamiliar Go code, point at every context.With... call, sketch the tree it builds, and predict what happens when any node is cancelled.
You do not need to know the source of cancelCtx, the propagateCancel machinery, or how the runtime allocates timers. Those are the senior and professional files. Here we focus on building correct mental pictures.
Prerequisites¶
- Required: Comfort with
context.Context,context.Background(),<-ctx.Done(), andctx.Err(). Read 01-deadlines-and-cancellations first if any of those feel unfamiliar. - Required: Go 1.21 or newer recommended (1.20 is acceptable).
WithoutCancelandAfterFuncneed 1.21;WithCancelCauseneeds 1.20. - Helpful: Knowing that
<-someClosedChanreturns immediately. Cancellation propagation depends on this. - Helpful: Basic familiarity with goroutines and
select.
If you can write ctx, cancel := context.WithTimeout(context.Background(), time.Second); defer cancel() from memory, you are ready.
Glossary¶
| Term | Definition |
|---|---|
| Context tree | The directed tree formed by all live contexts in a process. Edges run from parent to child. The roots are Background() and TODO(). |
| Derived context | Any context returned by a With... function. Always has exactly one parent. |
| Parent | The first argument to a With... call. A node always has one parent (or is a root). |
| Child | A context derived from another context. A node may have zero, one, or many children. |
| Cascade | The act of cancellation flowing from a parent down to every descendant in one logical step. |
Background() | The root context. Never cancelled. Used at the top of main, tests, and long-running goroutines. |
TODO() | A second root, behaviourally identical to Background. Used as a placeholder while wiring code. |
WithCancel | Derives a child whose lifetime is controlled by an explicit cancel() function. |
WithTimeout | Derives a child that auto-cancels after a duration. |
WithDeadline | Derives a child that auto-cancels at a wall-clock time. |
WithValue | Derives a child that carries a key/value pair. Does not introduce cancellation. |
WithCancelCause | Go 1.20+. Like WithCancel but the cancel function takes an error reason (the "cause"). |
AfterFunc | Go 1.21+. Registers a callback to run when a context is cancelled. |
WithoutCancel | Go 1.21+. Derives a child that inherits values but not cancellation. |
context.Cause | Go 1.20+. Returns the original error passed to WithCancelCause (or Err() if none). |
| First-deadline-wins | A child's effective deadline is the earliest of its own deadline and any ancestor's deadline. |
| Leaf node | A context with no children. Often the one passed to the innermost function. |
| Lost cancel | A cancel function that escapes its scope without being called, leaking a tree node. Detected by go vet. |
Core Concepts¶
The tree is built one With... call at a time¶
Every call of the form ctxChild, cancel := context.WithX(ctxParent, ...) adds exactly one node to the tree. The new node points to ctxParent. Once the call returns, the parent has a new child registered internally.
root := context.Background()
a, cancelA := context.WithCancel(root)
b, cancelB := context.WithTimeout(a, 2*time.Second)
c := context.WithValue(b, "user", "yashin")
d, cancelD := context.WithCancel(c)
defer cancelA()
defer cancelB()
defer cancelD()
After these five lines the tree looks like:
Notice two facts:
c(avalueCtx) is just a node like any other. It does not cancel; it stores a key/value.dderives fromc, not frombdirectly. The tree records the immediate parent.
Cancellation flows from parent to every descendant¶
If you call cancelA() in the snippet above, every node beneath a — b, c, and d — has its Done() channel closed and its Err() set to context.Canceled. The cancellation is a single transitive sweep.
A receiver that does <-d.Done() wakes up because of cancelA(), even though it is two layers below.
Cancellation never flows from child to parent¶
If instead you call cancelD(), only d is cancelled. a, b, and c remain alive and active. This is the rule that makes context safe to share: a goroutine you spawn cannot tear down the caller by misusing its own context.
Background() is never cancelled¶
context.Background() returns an emptyCtx whose Done() is nil and whose Err() is nil. The root sits at the top of every tree and never moves. It never raises a signal — but signals flow down through it via its descendants.
WithValue does not branch cancellation¶
WithValue adds a node with a key/value pair but uses the parent's Done() and Err() directly. From a cancellation point of view, the valueCtx node is invisible. It only matters for Value(key) lookups, which walk up the tree until they find a match.
The first deadline in the chain wins¶
If a parent has deadline t1 and you call WithDeadline(parent, t2):
- If
t1 < t2, the parent's deadline is earlier. The new node is given the parent's deadline; its own timer is not even started. Go's source short-circuits this by returning a plainWithCancel. - If
t2 < t1, the child's deadline is earlier. A timer is started; when it fires, only this child and its descendants cancel.
You cannot extend a deadline by deriving. A child can only shorten.
Cancel functions are local to the node, not the tree¶
Each cancel function returned by With... cancels exactly its owning node. Calling it triggers the cascade downward but does not touch ancestors. It is safe to call multiple times — second and later calls are no-ops.
WithoutCancel cuts the cancellation wire¶
In Go 1.21+, context.WithoutCancel(parent) returns a child that keeps the parent's values but ignores its cancellation. It is a node whose Done() is nil and Err() is nil. Use it when a background task must outlive the request that triggered it.
AfterFunc attaches a callback to a node¶
context.AfterFunc(ctx, f) schedules f to run if and when ctx is cancelled. The returned stop function detaches f. Multiple AfterFunc calls stack — each callback runs independently. The callback runs in its own goroutine.
Real-World Analogies¶
A family tree of permissions¶
Imagine a household where the parent has a set of keys. Each child gets a copy. If the parent revokes their keys, every child loses access too. But a child losing their keys does not affect the parent. The Context tree is the key-revocation system.
A fuse box¶
Background() is the main breaker. Each With... adds a sub-breaker. Tripping a sub-breaker kills only the circuits below it. Tripping the main kills the whole house. There is no "child breaker that turns off the main" — that would be a safety hazard, and Go's API would not allow it either.
Russian dolls¶
Each WithTimeout is a nested doll. The smallest (innermost) doll is the leaf context. Opening any outer doll exposes everything inside. But closing the smallest doll does not affect any of the bigger ones.
A river delta¶
Background() is the spring. The river splits and rejoins (it doesn't rejoin in our case, but the analogy of branches holds). Damming the spring stops every tributary downstream. Damming a tributary leaves the rest of the river flowing.
Mental Models¶
Model 1: The tree is a graph that the runtime knows¶
Every cancelable context registers itself with its parent. The parent keeps a children map. When the parent cancels, it iterates this map and cancels each child synchronously. The runtime does not "search" — it walks the explicit edges. The cost of a cascade is proportional to the size of the subtree.
Model 2: cancel() is "trim this branch"¶
When you call cancel() on a node, two things happen: (1) the node closes its Done() channel, signalling downstream listeners; (2) every child in its children map gets cancel(false, ...) called recursively. The branch is pruned.
Model 3: Parents do not know which descendants exist¶
A parent knows only its direct children, not grandchildren. The cascade is depth-first: parent cancels direct children, each of which cancels its direct children, and so on. You don't need a flat "all descendants" list to make the cascade fire.
Model 4: WithValue is a linked list node, not a fork¶
For value lookup, ctx.Value(key) walks up the chain of parents. WithValue nodes form a kind of linked list along the spine. For cancellation, value nodes are invisible — they pass through unchanged.
Model 5: Cause is metadata layered on top of cancellation¶
Cause does not change the tree. It adds a side-channel for each cancellation: instead of just "this was cancelled," you can ask "why?" The tree shape is unchanged.
Pros & Cons¶
Pros¶
- Single signal, many listeners. One
cancel()reaches every descendant. - Composable. Library code never needs to know who its caller is; passing
ctxthrough is enough. - Type-safe.
Contextis an interface; everyone speaks the same language. - Cleanup-friendly. With
AfterFuncanddefer cancel(), leaked resources are obvious. - Cheap. A node is a small struct plus a map entry. Cancel cascade is O(size of subtree).
Cons¶
- Easy to leak. Forget
defer cancel()and the node lingers in the parent's children map. - Tree shape can hide bugs. A child you forgot to derive from
ctxis a child that never cancels. - No flat enumeration. The runtime does not expose "list all descendants." You cannot easily inspect.
Causerequires opting in. PlainWithCancelloses cause information.- Custom contexts spawn watcher goroutines. Don't implement your own unless you must.
Use Cases¶
HTTP request handler¶
req.Context() is the root of the per-request subtree. The handler derives a WithTimeout for the database call, a WithCancel for a background fan-out, and a WithValue for the request ID. When the client closes the TCP connection, req.Context() is cancelled and the entire subtree collapses.
Worker pool with shutdown¶
A pool keeps a single WithCancel(Background()) context. Each worker derives its own WithTimeout per task. Shutdown calls the top-level cancel(); every in-flight task aborts on its next <-ctx.Done() check.
Fan-out with shared cancellation¶
A scatter-gather query spawns N goroutines, each deriving a context for its work. The parent's cancel() aborts all N. If any one returns first, the parent cancels — and the others stop.
Background task that should outlive the request¶
A request triggers a write-back to a slow store. The request returns 200 to the user, but the write must finish. Use context.WithoutCancel(req.Context()) so the parent's cancellation does not abort the write.
Cleanup on cancellation¶
Use context.AfterFunc(ctx, func() { conn.Close() }) instead of a goroutine that does <-ctx.Done(); conn.Close(). Fewer goroutines, automatic teardown.
Code Examples¶
Example 1: Basic two-level tree¶
package main
import (
"context"
"fmt"
"time"
)
func main() {
root := context.Background()
parent, cancelParent := context.WithCancel(root)
defer cancelParent()
child, cancelChild := context.WithTimeout(parent, 5*time.Second)
defer cancelChild()
go func() {
<-child.Done()
fmt.Println("child done:", child.Err())
}()
time.Sleep(100 * time.Millisecond)
cancelParent() // also cancels child
time.Sleep(100 * time.Millisecond)
}
cancelParent() cancels both. The child prints child done: context canceled because the parent's cancellation cascaded.
Example 2: Sibling isolation¶
parent, cancel := context.WithCancel(context.Background())
defer cancel()
childA, cancelA := context.WithCancel(parent)
defer cancelA()
childB, cancelB := context.WithCancel(parent)
defer cancelB()
cancelA() // only A is cancelled
fmt.Println(childA.Err()) // context canceled
fmt.Println(childB.Err()) // <nil>
Example 3: First-deadline-wins¶
outer, c1 := context.WithTimeout(context.Background(), 2*time.Second)
defer c1()
inner, c2 := context.WithTimeout(outer, 10*time.Second) // bigger
defer c2()
start := time.Now()
<-inner.Done()
fmt.Printf("fired after %v, err=%v\n", time.Since(start), inner.Err())
// fired after ~2s, err=context deadline exceeded
The 2-second outer deadline beats the 10-second inner one.
Example 4: WithValue is invisible to cancellation¶
parent, cancel := context.WithCancel(context.Background())
defer cancel()
ctx := context.WithValue(parent, "user", "yashin")
cancel()
<-ctx.Done() // returns immediately
fmt.Println(ctx.Value("user")) // still "yashin"
fmt.Println(ctx.Err()) // context canceled
Example 5: WithCancelCause (Go 1.20+)¶
ctx, cancel := context.WithCancelCause(context.Background())
cancel(fmt.Errorf("upstream returned 503"))
<-ctx.Done()
fmt.Println(ctx.Err()) // context canceled
fmt.Println(context.Cause(ctx)) // upstream returned 503
Example 6: AfterFunc (Go 1.21+)¶
ctx, cancel := context.WithCancel(context.Background())
stop := context.AfterFunc(ctx, func() {
fmt.Println("running cleanup")
})
_ = stop // stop() detaches the callback if no longer needed
cancel()
time.Sleep(50 * time.Millisecond) // give the runtime time to run the callback
Example 7: WithoutCancel (Go 1.21+)¶
req, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
ctx := context.WithValue(req, "trace", "abc")
detached := context.WithoutCancel(ctx)
go func() {
time.Sleep(500 * time.Millisecond)
fmt.Println(detached.Value("trace")) // abc
fmt.Println(detached.Err()) // <nil> — never cancelled
}()
<-req.Done()
fmt.Println("request done:", req.Err())
time.Sleep(700 * time.Millisecond)
Example 8: Drawing the tree at runtime (manual)¶
The standard library does not expose the tree. You can record With... calls yourself for debugging:
type node struct {
ctx context.Context
label string
children []*node
}
root := &node{ctx: context.Background(), label: "root"}
// every time you derive, append to root.children
For production observability use OpenTelemetry spans, which mirror the context tree.
Coding Patterns¶
Pattern: derive at function entry¶
func handleRequest(ctx context.Context, req Request) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return do(ctx, req)
}
Every function that needs its own deadline derives at entry and defers cancel.
Pattern: per-step deadline¶
func process(ctx context.Context, steps []Step) error {
for _, s := range steps {
stepCtx, cancel := context.WithTimeout(ctx, s.Budget)
err := s.Run(stepCtx)
cancel()
if err != nil {
return err
}
}
return nil
}
cancel() runs inside the loop, immediately after the step, to avoid stacking lost cancels.
Pattern: shared parent for fan-out¶
ctx, cancel := context.WithCancel(parent)
defer cancel()
var eg errgroup.Group
for _, item := range items {
item := item
eg.Go(func() error {
return work(ctx, item)
})
}
return eg.Wait()
The first error from work returns, the deferred cancel() fires, every other worker sees <-ctx.Done() and stops.
Pattern: background task with WithoutCancel¶
detached := context.WithoutCancel(req.Context())
go func() {
if err := writeAuditLog(detached, event); err != nil {
log.Printf("audit: %v", err)
}
}()
Pattern: AfterFunc for resource cleanup¶
No extra goroutine sits on <-ctx.Done().
Clean Code¶
- One
cancelperWith..., always withdefer. - No package-level cancels. Cancels are scoped, not global.
- Pass
ctxas the first parameter, namedctx. Never store it in a struct. - Do not name variables
context— shadows the import. - Derive new contexts, do not mutate —
Contextis immutable. - Log
context.Cause(ctx)overctx.Err()when you need to know why. - Use
AfterFuncinstead of<-ctx.Done()goroutines when the work is cleanup.
Product Use / Feature¶
In a production HTTP service:
http.Serverbuildsreq.Context()and cancels it when the connection drops.- Middleware derives
WithTimeoutfor total request budget. - Auth middleware derives
WithValuewith the user ID. - The handler derives
WithTimeoutper downstream call. - A goroutine for streaming pushes derives a
WithCancel.
Closing the browser tab triggers a cascade that shuts down every database query, RPC, and goroutine spawned for that request. Without the tree this would require manual bookkeeping for each operation.
Error Handling¶
Two related but distinct errors live on every node:
ctx.Err()— the kind of cancellation. Eithercontext.Canceledorcontext.DeadlineExceeded. Stable: once set, never changes.context.Cause(ctx)(Go 1.20+) — the reason. Defaults toctx.Err()if not set viaWithCancelCause.
select {
case <-ctx.Done():
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("timed out: %w", context.Cause(ctx))
}
return fmt.Errorf("cancelled: %w", context.Cause(ctx))
case res := <-resultCh:
return res
}
Always check errors.Is(err, context.Canceled) rather than err == context.Canceled; the error may be wrapped.
Security Considerations¶
WithValueis not a secret vault. Anything passed in is reachable by every descendant. Do not store passwords or session tokens that should not leak.WithoutCanceldefeats deadlines. A bug or malicious code path could use it to keep work running past a security-imposed timeout. Audit usage.- Cause may carry sensitive errors. A
causecontaining internal error strings could leak via logs or responses. Sanitise. - Custom Context implementations are an attack surface. A custom context that lies about
Done()could deadlock or never cancel.
Performance Tips¶
- Defer
cancel()once perWith...— anything else risks a leak or an early cascade. - Hoist common parents. If 100 goroutines all derive from the same
WithTimeout, derive once and share. - Prefer
AfterFuncover<-ctx.Done()goroutines for cleanup-only logic. Saves a goroutine per cleanup. - Avoid deep value chains.
ctx.Valuewalks the spine. A chain of 50WithValuecalls makes every lookup O(50). - Avoid wide cancel children. A node with 100k cancelable children means 100k map entries; the cascade is O(N) under a lock.
Best Practices¶
- Always pass
ctxas the first parameter to functions that may block or call out. - Always
defer cancel()after everyWith.... - Use
WithValueonly for request-scoped data (request ID, user ID, trace ID), never for optional function arguments. - Use
WithCancelCausewhenever the cancellation reason might be needed downstream. - Use
WithoutCancelfor fire-and-forget tasks that should survive their trigger. - Use
AfterFuncfor synchronous cleanup hooks instead of one-off goroutines. - Lint with
go vet -lostcancelandstaticcheck.
Edge Cases & Pitfalls¶
Cancelling Background() is impossible¶
There is no cancel function for Background(). You can only cancel derived nodes.
Cancelling a node twice is fine¶
Closure capture of cancel inside a loop¶
for _, item := range items {
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel() // BAD — all cancels run only when the outer function returns
process(ctx, item)
}
Move cancel() inside the loop body (cancel() directly, not defer) when iterating.
Calling cancel after the context is already cancelled¶
Safe and idempotent. No effect.
Passing nil as parent¶
context.WithCancel(nil) panics. Use context.TODO() if you have no parent yet.
Mixing roots¶
Deriving from TODO() in production code is a smell. TODO() is a placeholder; replace it before merging.
Common Mistakes¶
Mistake 1: Forgetting defer cancel()¶
ctx, _ := context.WithTimeout(parent, time.Second)
work(ctx)
// timer fires later; node stays in parent's children map until then
Solution: never discard cancel. Use defer cancel().
Mistake 2: Wrong parent¶
ctx, cancel := context.WithCancel(context.Background()) // ignores request context
defer cancel()
do(ctx)
The handler had req.Context(), but you derived from Background(). The request's cancellation now cannot reach do. Always derive from the upstream context.
Mistake 3: Reusing cancel across goroutines¶
ctx, cancel := context.WithCancel(parent)
go work(ctx, cancel) // worker calls cancel — surprising side effect on caller
Avoid passing cancel into goroutines unless that goroutine owns the cancellation.
Mistake 4: Treating WithValue like a Cancel¶
WithValue cannot cancel. The value just rides along.
Mistake 5: Expecting child cancel to wake parent¶
parent, _ := context.WithCancel(context.Background())
child, cancelChild := context.WithCancel(parent)
cancelChild()
<-parent.Done() // never fires
Cancellation only flows downward.
Common Misconceptions¶
- "Background is the same as TODO." Behaviourally yes; semantically no.
TODOmeans "I will fix this later." - "WithValue introduces a new cancel." It does not. The new node shares the parent's cancellation.
- "Cancel propagates to siblings." It does not. Only descendants.
- "AfterFunc replaces defer cancel." It does not. AfterFunc runs on cancellation;
defer cancel()triggers cancellation when the function returns. - "WithoutCancel breaks the value chain." It does not. Values still propagate up.
- "Deeper trees are always worse." Not necessarily. Width (many children of one parent) costs more under the cascade lock than depth.
Tricky Points¶
Cancellation order is depth-first synchronous¶
A parent's cancel cascades into each child synchronously while holding the parent's mutex. Children's Done channels close in iteration order over the children map — which is non-deterministic. Do not depend on order.
closedchan sentinel¶
If a node's Done() was never called before cancel(), the runtime hands out a pre-closed singleton channel. This is a tiny optimisation but explains why "lazy" Done() is cheap.
Custom contexts spawn a watcher goroutine¶
If you implement your own Context and someone calls WithCancel(yours), the runtime spawns a goroutine to forward your Done() to the child. Avoid custom contexts.
Value lookup is not cached¶
ctx.Value(k) walks the parent chain every call. Cache the result if you need it many times.
Test¶
A short test you can run locally to verify the cascade.
func TestCascade(t *testing.T) {
parent, cancel := context.WithCancel(context.Background())
child, _ := context.WithCancel(parent)
grandchild, _ := context.WithCancel(child)
cancel()
for _, c := range []context.Context{parent, child, grandchild} {
if c.Err() == nil {
t.Fatalf("expected error after parent cancel")
}
}
}
Tricky Questions¶
- What happens if you
WithDeadline(ctx, time.Now().Add(-time.Hour))? Answer: the deadline is already passed; the context is cancelled immediately. No timer is started. - Does
WithoutCancelpropagate values? Yes. - Does
WithoutCancelpropagate deadlines? No. - Can you call
cancelfrom any goroutine? Yes. It is goroutine-safe. - Does
ctx.Done()allocate? Lazily, on first call. Subsequent calls return the same channel.
Cheat Sheet¶
Root: Background(), TODO()
Derive cancel: WithCancel, WithCancelCause
Derive timeout: WithTimeout, WithDeadline, WithDeadlineCause, WithTimeoutCause
Derive value: WithValue
Detach: WithoutCancel
Cleanup hook: AfterFunc
Inspect: Done(), Err(), Cause(), Value(k), Deadline()
Rules: - Cancel cascades downward only. - First deadline in the chain wins. - Always defer cancel(). - WithValue is transparent to cancellation.
Self-Assessment Checklist¶
- I can sketch the tree built by a snippet with five
With...calls. - I can predict the order in which
Done()channels close. - I know why
WithValuecannot cancel. - I know what
Causeadds beyondErr. - I know when to reach for
WithoutCancel. - I know what
AfterFuncsaves me over a goroutine. - I can explain "first deadline wins" with an example.
Summary¶
A Context is a node in a tree rooted at Background(). Each With... adds a child. Cancellation cascades from parent down to every descendant; it never travels upward. WithValue adds a node that is invisible to cancellation. WithCancelCause (Go 1.20+) adds a "why," AfterFunc (Go 1.21+) attaches cleanup, and WithoutCancel (Go 1.21+) cuts the cancellation wire while keeping values. Defer every cancel(), derive from the upstream context, and you have a clean tree.
What You Can Build¶
- A request-scoped logger that uses
WithValuefor request ID and trace ID. - A worker pool whose shutdown collapses the entire subtree of in-flight tasks.
- A fan-out search that cancels stragglers as soon as the first result returns.
- A background uploader detached from the triggering request with
WithoutCancel. - A connection-cleanup hook with
AfterFuncthat closes sockets when the context dies.
Further Reading¶
- The Go blog, "Go Concurrency Patterns: Context"
contextpackage docs,pkg.go.dev/context- Go 1.20 release notes —
WithCancelCause,Cause - Go 1.21 release notes —
AfterFunc,WithoutCancel,WithDeadlineCause,WithTimeoutCause /Users/mrb/Desktop/SeniorProject/Roadmap/Programming-Languages/golang/07-concurrency/04-context-package/01-deadlines-and-cancellations/
Related Topics¶
Diagrams & Visual Aids¶
Mermaid: typical HTTP request tree¶
Mermaid: WithoutCancel detaching¶
The dotted line shows the cancellation wire is cut. Values still flow.
Graphviz DOT: cascade order¶
digraph cascade {
rankdir=TB;
Background -> P;
P -> A;
P -> B;
A -> A1;
A -> A2;
B -> B1;
P [label="P: WithCancel"];
A [label="A: WithTimeout"];
B [label="B: WithCancel"];
A1 [label="A1: WithCancel"];
A2 [label="A2: WithCancel"];
B1 [label="B1: WithCancel"];
}
Save as tree.dot and run dot -Tpng tree.dot -o tree.png to render. Cancelling P closes Done() on five descendants.
ASCII: first-deadline-wins¶
The inner's 10s timer never fires; the outer's 2s deadline triggers cascade.