Chapter 15: Graceful Shutdown

Every chapter so far has been about starting things. A goroutine, a pool, a pipeline, a context tree, a group that fails as one. Chapter 2 asked the question that has followed all of them — how does this goroutine exit? — and each chapter answered it locally: close the channel, cancel the context, return the error, let Wait observe it.

This chapter asks the question once, about everything at once, with a stopwatch running.

That last clause is the difficulty. Shutdown is the only part of a program someone else times. A container runtime sends SIGTERM and starts counting. A deploy script sends SIGTERM and starts counting. An operator presses Ctrl+C and starts counting in their head, and their patience is shorter than any config file. When the count runs out the kernel sends SIGKILL, which cannot be caught, blocked, or argued with.

So the question is not “how do I stop?” It is: given a budget you did not set, what do you finish, what do you abandon, and how does anyone find out which?

Here are three shutdowns. All three compile. All three pass go test -race. Two of them pass go vet.

snippet_15_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: main exits the moment Shutdown starts, killing every
// in-flight request
go func() {
    <-sigCh
    srv.Shutdown(context.Background())
}()
if err := srv.ListenAndServe(); err != nil {
    log.Fatal(err) // fires on a SUCCESSFUL shutdown
}
snippet_15_x_2.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: the second Ctrl-C does nothing, and the operator cannot
// escape a drain that has stalled
ctx, stop := signal.NotifyContext(
    context.Background(), syscall.SIGTERM)
defer stop()
<-ctx.Done()
drain() // if this hangs, only SIGKILL will save you
c_15_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: vet catches this one -- signals are dropped, not queued
c := make(chan os.Signal) // unbuffered
signal.Notify(c, syscall.SIGTERM)
<-c

The first is the most common shutdown bug in Go, and the standard library’s documentation warns about it by name: ListenAndServe returns ErrServerClosed at the start of the drain, so log.Fatal fires on the success path. The second is the idiom nearly everyone writes, and §15.2 measures what it costs. The third is the one that fails a tool before it fails in production — go vet rejects it outright, which makes it the only listing in this book that does not survive the toolchain.

None of these is exotic. They are what careful code looks like before you know the specific mechanics, and each fails silently — the shape §14.1 named, met again at the process boundary.

THE CLOCK YOU DO NOT OWN

A timeline of the shutdown budget. At T+0 SIGTERM arrives and the budget starts while the load balancer is still sending traffic; everything in the chapter happens inside the span that follows; at T+grace SIGKILL arrives, which cannot be caught, runs no deferred functions and flushes nothing. The point is that the deadline is set by the platform, not by the process.

What you’ll learn
  • What graceful actually promises, and the four things an abrupt exit silently loses
  • How to catch a signal, why defer stop() takes away the operator’s escape hatch, and what naming the signal buys you
  • Why failing readiness has to come before closing listeners, measured in refused requests
  • The four holes in Server.Shutdown, including the one that lets a handler outlive the process
  • How to drain a worker pool, and why abandoning the backlog is sometimes the correct answer
  • How to shut down components in dependency order, and the errgroup return value that quietly means “everyone else runs forever”
  • How to divide a budget you did not set, and what to do when it expires anyway
What we’re not covering
  • Testing concurrent code in general, goleak, race-detector integration and testing/synctest as a technique — Chapter 16. This chapter uses shutdown’s own tests as the motivating case and hands the machinery on
  • Backpressure, rate limiting and load shedding — Chapter 17
  • The bug catalogue — Chapter 18
  • Distributed shutdown: deregistering from service discovery, propagating shutdown through dependent services, in-flight distributed transactions. Single-process shutdown is hard enough, and the distributed version is a different problem
Building toward

Chapter 13 gave you a mechanism to stop a tree of goroutines. Chapter 14 put a reason on the stop and carried it back to code that could act. This chapter applies both to the whole process, on a deadline set by someone else — and it is where the three chapters' threads finally meet in a single line of handler code (§15.4.6).

Prerequisites

The done channel and select from Chapter 4, since every drain loop here is one. Buffered channels from §5.2, because a shutdown signal you cannot afford to drop is the sharpest case for a buffer. Chapter 7's pipelines, which §15.5 runs in reverse. Context cancellation, WithCancelCause and WithoutCancel from Chapter 13 — all three do real work here. And Chapter 14 throughout: errgroup lifecycles in §15.6, and §14.7's degradation lens in §15.7.

Which Go are we on?

Every listing and figure in this chapter was run on Go 1.25 or later, against golang.org/x/sync v0.22.0 (v0.23.0 at the time of this revision; the errgroup API used here is unchanged). Five additions matter. Go 1.16 added signal.NotifyContext, which replaces the signal-channel dance Chapters 2 and 4 used and is the entry point for everything here. Go 1.20 added context.WithCancelCause, which is how a shutdown carries the reason it started — §15.4.6 depends on it directly. Go 1.26 rewired signal.NotifyContext onto context.WithCancelCause, so context.Cause names the signal — §15.2.4 depends on that directly (its documentation now carries the sentence “calling context.Cause on it will return an error describing the signal”). Go 1.21 added context.WithoutCancel, the answer to “this cleanup must still run after the thing it belongs to was cancelled” (§15.5.6). And Go 1.25 stabilised testing/synctest, which makes deadline-driven tests deterministic — with a limitation that decides how this chapter’s exercise is written, measured in §15.7.7.

Measured go1.26.1, darwin/amd64, Intel i7-10700K, GOMAXPROCS=16, golang.org/x/sync v0.22.0. Figures come from running the program as printed. Shutdown timings are noisier than the benchmarks in Chapters 12–14 — several depend on a polling loop with jitter, on the scheduler, and on a loopback TCP stack — so each is reported as a range across repeated runs rather than a single number, and the text says which digits are load-bearing. Where a claim is about behaviour rather than duration — does this return, does that cancel, does the process survive — it reproduced identically on every run and is stated flatly. And where a timing and a mechanism disagree, trust the mechanism: those are quoted from the standard library source with a file and a line.

15.1 What “Graceful” Actually Promises

The word does a lot of unexamined work. Before designing a shutdown it is worth being precise about what you are promising, because “graceful” is not one guarantee — it is four, they are independent, and most implementations deliver two of them.

15.1.1 The Four Promises

No new work is accepted. After the signal, a request that has not yet been admitted is refused cleanly — refused in a way the caller can retry elsewhere, not dropped on the floor.

Accepted work finishes. A request already inside a handler, a job already pulled from a queue, a transaction already begun runs to completion or is deliberately abandoned in a recoverable state. This is the promise that “graceful” usually means and the one most often broken by accident.

Resources are released in an order that is safe. The database connection closes after the last query that needs it, not before. The lease is surrendered rather than left to expire. The buffer is flushed rather than dropped.

The outcome is reported. If something was abandoned, the process says so — in its exit code, its logs, and its metrics — instead of exiting zero and leaving the operator to infer it.

The four are independent. A server that calls Shutdown and waits keeps the first two and can still break the last two. A process that flushes its telemetry perfectly can still have refused a hundred requests it should have served. Naming them separately is what lets you say which one a given bug violates.

15.1.2 What an Abrupt Exit Loses

Chapter 2 said the runtime does not wait for your goroutines and does not run their deferred functions when the process ends. That is exactly true, and shutdown is where the bill arrives:

main_151.go
// Illustrative snippet — not a complete program
func main() {
    defer flushTelemetry() // never runs
    lease := acquireLease()
    defer lease.Release()  // never runs
    serve()
}
Measured with no handler installed, every one of the four signals a service normally sees terminates the process before either deferred call runs. Not “runs late”, not “runs partially” — the deferred print never appeared in the output at all:
signal
SIGTERM
SIGINT
SIGHUP
SIGQUIT

The first three are the shell’s 128 + signal number. SIGQUIT is the odd one: the Go runtime intercepts it, prints a full goroutine dump, and exits 2 rather than 131 — which §15.7.5 turns into the diagnostic for a shutdown that has hung.

The same is true of os.Exit, which skips deferred functions by specification, and of a panic in any goroutine that nothing recovers (§14.6). Three different ways to reach the same place.

What is lost is not abstract:

What was pending
Buffered telemetry and logs
A held distributed lock or lease
An in-flight write, mid-transaction
An accepted-but-unfinished request

That last row is the one that generates support tickets. The client knows the request failed; it does not know whether the work failed. If the operation was not idempotent, neither does anyone else.

defer is not a shutdown mechanism

it is a function-scope mechanism that happens to fire on the normal path. A signal is not the normal path. Anything that must happen at shutdown needs an explicit call on the shutdown path, in the order you chose. This chapter’s §15.6 is about choosing that order; §15.2 is about making sure you get to run it at all.

15.1.3 Shutdown Is Startup in Reverse

Every dependency you built up on the way in has to come down in the opposite order.

BUILT OUTWARD, TORN DOWN INWARD

Two facing columns, startup on the left numbered one to five and shutdown on the right numbered five down to one. Load config pairs with close config sources, dial the database with close the pool, start workers with stop workers and drain, start listeners with stop accepting, and announce ready with fail readiness. A component may not close while anything above it still needs it, so reversing the build order answers the ordering question.

That is the whole of the ordering rule, and it explains most shutdown bugs by inspection: closing the database pool while workers are still draining is step 4 before step 3.

The rule has one important exception, and it is the first step. Readiness comes down before listeners, not after — which is startup order reversed, but for a reason that has nothing to do with dependencies and everything to do with the fact that something outside your process is still sending you traffic. §15.3 measures what happens when you get that one backwards.

15.1.4 The Clock You Do Not Own

Shutdown has a deadline, it is not yours, and it is enforced with a signal you cannot catch.

Under Kubernetes the container runtime sends SIGTERM, waits terminationGracePeriodSeconds (30 seconds by default), then sends SIGKILL. Under systemd the unit’s TimeoutStopSec plays the same role. The numbers differ; the shape does not. Something started a timer when it signalled you, and it will not negotiate.

This is why “shutdown timeouts” is not one number but a division problem. The propagation wait, the drain and the resource release all come out of the same budget, and §15.7 is about carving it up and about what to do when the carving turns out to have been optimistic.

Find out your real budget before designing around it.

The default is often not what is deployed: a Helm chart may set terminationGracePeriodSeconds to 10, or a platform team may have raised it to 120. A shutdown designed for 30 seconds and deployed under 10 does not degrade gracefully — it is killed mid-drain every single time, and the symptom is a slow trickle of lost work that nobody attributes to shutdown.

15.1.5 Common Mistakes

Relying on defer for shutdown cleanup
Problem

Nothing runs; exit 143 and no trace

Fix

Explicit calls on the shutdown path

Calling os.Exit at the end of shutdown
Problem

Deferred cleanup skipped by specification

Fix

Return from main instead

Assuming the budget is 30 seconds
Problem

Killed mid-drain on a cluster that sets 10

Fix

Read the deployment; design for the real number

Treating “graceful” as one promise
Problem

Requests finish but the lease is never released

Fix

Name the four promises; check them separately

Exiting zero after abandoning work
Problem

The loss is invisible to every dashboard

Fix

Non-zero exit, a log line and a metric (§15.7)

Summary: What “Graceful” Actually Promises

Graceful is four independent promises: refuse new work cleanly, finish what was accepted, release resources in a safe order, and report what happened. Most implementations keep the first two and quietly break the second two, and naming them separately is what makes a given bug describable.

An abrupt exit keeps none of them. SIGTERM with no handler is exit 143 with no deferred function run at all — the same place os.Exit and an unrecovered panic reach by different routes. What is lost is buffered telemetry, held leases, torn writes and accepted requests whose callers cannot tell a failure from a duplicate.

The ordering rule is that shutdown is startup reversed: a component may not close while anything above it still needs it. The single exception is readiness, which comes down first, because something outside the process is still routing traffic to you.

And the deadline is not yours. The platform sends SIGTERM, starts a timer, and ends the argument with SIGKILL. Your design problem is not choosing a timeout but dividing a budget someone else set.

Self-Check Questions: What “Graceful” Actually Promises

Which of the four promises does this break?

main_151_2.go
// Illustrative snippet — not a complete program
func main() {
    defer db.Close()
    srv := &http.Server{Handler: h}
    go func() { <-sigCh; srv.Shutdown(context.Background()) }()
    srv.ListenAndServe()
}
Two of the four, and both have the same root.

It keeps the first two and breaks the last two.

New work is refused once Shutdown closes the listeners, and accepted requests do finish, because Shutdown waits for them. So far so good.

Resources are not released safely. main returns as soon as ListenAndServe returns — which happens immediately when Shutdown begins, not when it completes (§15.4.1). So defer db.Close() runs while handlers are still using the pool, if it runs at all before the process exits.

And nothing is reported. The process exits zero whether every request completed or the runtime tore it down mid-flight. Nobody downstream can tell the difference.

The two bugs have the same root: main does not wait for Shutdown to return.

Your service holds a 60-second distributed lease and is killed by SIGKILL mid-shutdown. What does the rest of the fleet experience?

Up to sixty seconds during which no other replica can take over the work that lease protects.

A lease is a promise with a timeout attached: hold it and you have exclusive access, and if you die the timeout eventually releases it for you. That fallback exists for crashes. Shutdown is not a crash — it is the one case where you knew you were going away and could have handed the lease back explicitly.

Releasing it turns a 60-second induced outage into a sub-second handover. This is exactly why the “release resources” promise is separate from “finish accepted work”: you can do the second perfectly and still cause an outage by skipping the first.

It is also why the release has to be an explicit call on the shutdown path. A defer will not run under SIGKILL, and by then the budget has already expired — which makes it a §15.7 problem: if the lease release is the one thing that must happen, it goes first in the release phase, not last.

Why is readiness the exception to “shutdown is startup in reverse”?

Because the ordering rule is about internal dependencies, and readiness is about an external one.

Everything else in the teardown sequence is ordered by what needs what: workers before the pool they query, listeners before the workers they feed. Reversing the build order answers those questions mechanically.

Readiness is different. Nothing inside your process depends on it. What depends on it is a load balancer or an endpoints controller in another process entirely, which learns you are going away by polling — and until it has, it keeps sending you traffic. Close your listeners first and that traffic is refused.

So readiness comes down first not because something above it needs it, but because taking it down starts a clock running somewhere else, and you have to wait out that clock before you can safely stop accepting. §15.3 measures the cost of getting this backwards.

Key Takeaways

  • Graceful is four independent promises — refuse cleanly, finish accepted work, release safely, report the outcome — and most implementations keep only the first two
  • SIGTERM with no handler exits 143 and runs no deferred function at all; os.Exit and an unrecovered panic reach the same place
  • defer is a function-scope mechanism that happens to work on the normal path; a signal is not the normal path
  • What is lost is concrete: the last thirty seconds of telemetry, a lease nobody else can take for its full TTL, a torn write, a client that cannot tell a failure from a duplicate
  • Teardown order is build order reversed — a component may not close while anything above it still needs it
  • Readiness is the exception, and it comes down first, because the thing that depends on it lives in another process
  • The budget is set by the platform and ends in a signal you cannot catch, so the design problem is division, not choosing a number
Section 15.1 — in one line

Graceful means four separate promises kept in a window someone else opened — and the mechanism you would reach for first, defer, is the one guaranteed not to run.

15.2 The Signal, and the Deadline It Starts

Shutdown begins with a signal, and the signal starts a clock. This section is about catching the first correctly and about where the second comes from — they are one topic, because a signal you handle badly costs you budget you did not have to spare.

15.2.1 The Channel Form, and the Bug vet Catches

The form Chapters 2 and 4 used, and the one still in most codebases:

c_152.go
// Illustrative snippet — not a complete program
c := make(chan os.Signal, 1) // buffered -- this matters
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
<-c

The buffer is not decoration. signal.Notify's documentation is unusually blunt about why:

From the signal.Notify documentation

“Package signal will not block sending to c: the caller must ensure that c has sufficient buffer space to keep up with the expected signal rate.”

A signal delivered while nobody is receiving is discarded, silently. With an unbuffered channel there is a window between Notify and the receive in which your one and only SIGTERM can vanish, and the process then sits there until SIGKILL arrives.

Measured this is the one listing in this book that a tool rejects before production does. go vet reports it verbatim:
Terminal
misuse of unbuffered os.Signal channel as argument to signal.Notify

Chapters 13 and 14 opened with code that compiles, vets clean and passes -race. This is the sharper variant: a bug the toolchain already knows about, in code that ships anyway because nobody ran vet in CI.

15.2.2 signal.NotifyContext

Since Go 1.16 the whole dance is one call:

snippet_152.go
// Illustrative snippet — not a complete program
ctx, stop := signal.NotifyContext(context.Background(),
    syscall.SIGINT, syscall.SIGTERM)
defer stop()

<-ctx.Done() // the signal arrived

No channel, no buffer decision, no vet finding. And the result is a context.Context, which means the shutdown signal composes with everything Chapters 13 and 14 built: pass it to Shutdown, derive a budget from it with WithTimeout, hand it to an errgroup.

That last point is the one to hold on to. The signal is not an event you handle at one place; it is the root of a cancellation tree that reaches every component. §15.6 is that tree.

This retires the Chapter 2 snippet.

Chapter 2 introduced signal handling with a raw channel because contexts had not been covered yet. NotifyContext is the form to use now. The channel form is still correct when you need to distinguish which signal arrived and act differently per signal — though §15.2.4 shows that NotifyContext can answer that question too.

15.2.3 What You Cannot Catch

SIGKILL and SIGSTOP cannot be caught, blocked or ignored. This is a kernel guarantee, not a Go limitation, and it is the reason the budget in §15.1.4 is a hard bound rather than a suggestion.

It is also why “handle SIGKILL for cleaner shutdown” is not a task you can accept. When SIGKILL arrives the process stops executing instructions. There is no handler, no defer, no final flush. Everything you wanted to happen had to have happened already.

15.2.4 Naming the Signal

ctx.Done() tells you shutdown started. ctx.Err() tells you almost nothing:

Measured after a SIGTERM delivered to a context from signal.NotifyContext:
Terminal
ctx.Err() = context canceled
context.Cause(ctx) = terminated signal received

NotifyContext is built on context.WithCancelCause (§13.3.4; since Go 1.26) and passes a cause naming the signal. SIGINT gives interrupt signal received, SIGHUP gives hangup signal received.

This matters more than it first appears. SIGINT is usually a developer at a terminal who wants their prompt back; SIGTERM is usually an orchestrator that has already started counting. They deserve different budgets, and context.Cause lets one handler tell them apart without a second channel:

budget_152.go
// Illustrative snippet — not a complete program
switch cause := context.Cause(ctx); {
case errors.Is(cause, context.Canceled):
    // nobody signalled us -- we initiated this ourselves
    budget = 30 * time.Second
default:
    slog.Info("shutdown signalled", "cause", cause)
    budget = platformBudget()
}
Measured that first branch is a real discriminator. Calling stop() when no signal ever arrived leaves context.Cause(ctx) equal to context.Canceled exactly, so errors.Is(context.Cause(ctx), context.Canceled) cleanly separates we shut ourselves down from the platform signalled us.

15.2.5 defer stop() Is the Bug

Here is the idiom nearly everyone writes:

snippet_152_x_2.go
// Illustrative snippet — not a complete program
ctx, stop := signal.NotifyContext(
    context.Background(), syscall.SIGTERM)
defer stop() // ✗ leaves the handler installed for the whole shutdown
<-ctx.Done()
drain()      // if this stalls, the operator is stuck

NotifyContext's documentation says what this costs, and it is easy to read past:

From the signal.NotifyContext documentation

“Future interrupts received will not trigger the default (exit) behavior until the returned stop function is called.”

While the handler is installed, every subsequent signal is absorbed. The second Ctrl-C does nothing. The second SIGTERM from an impatient operator does nothing. If drain() has stalled on a connection that will never close, the only way out is SIGKILL.

Measured the same program, one line apart:
idiom
defer stop()
stop() called early

So the rule is: call stop() as the first thing you do after ctx.Done() fires, not at the end of main.

snippet_152_3.go
// Illustrative snippet — not a complete program
ctx, stop := signal.NotifyContext(context.Background(),
    syscall.SIGINT, syscall.SIGTERM)
defer stop() // still needed for the early-return paths

<-ctx.Done()
stop() // hand the signal back: a second one now kills us

slog.Info("shutdown started", "cause", context.Cause(ctx))
drain()

Keeping defer stop() as well is correct and not redundant: it covers the paths where main returns without a signal ever arriving, and calling stop twice is safe.

Does calling stop() erase the cause you just read?

It looks like it should — stop cancels the context, and cancelling normally sets a cause. It does not: reading context.Cause(ctx) before and after stop() returns terminated signal received both times. The first cancellation wins and later ones are no-ops, which is the same sync.Once-shaped guarantee errgroup uses for its first error (§14.4.8). Log the cause before or after; it does not matter.

15.2.6 What the Escape Hatch Costs

Handing the signal back is not free, and this is the half that usually goes unsaid.

Measured in the stop()-called-early run above, the process was killed by the second signal and the deferred cleanup did not run — the same exit-143-no-defers behaviour as §15.1.2, now arriving by a route you chose.

That is the trade. You gave the operator a way to escape a stalled drain, and in exchange you accepted that they can cut you off mid-cleanup. It follows that anything that must happen cannot live in a defer placed after stop(). Put it on the shutdown path, early, in the order §15.6 establishes — release the lease before you drain the last connection, not after.

Both halves of §15.1.2's lesson are now in play. defer does not run when the platform kills you, and it does not run when the operator does either.

15.2.7 Where the Budget Comes From

The signal starts a timer in the process that sent it.

Platform
Kubernetes
systemd
Docker

When it expires, SIGKILL. The numbers vary by more than an order of magnitude across those three rows, which is the point: there is no default you can safely assume.

Derive your budget from the platform’s, with headroom, and put every phase inside it:

snippet_152_4.go
// Illustrative snippet — not a complete program
<-ctx.Done()
stop()

// One budget, slightly inside the platform's, for the whole sequence.
shutdownCtx, cancel := context.WithTimeout(
    context.WithoutCancel(ctx), 25*time.Second)
defer cancel()

Two details in that snippet earn their place. The headroom (25 against a 30-second grace period) leaves room to log the outcome before SIGKILL lands. And context.WithoutCancel is required, not stylistic: ctx is already cancelled — that is why we are here — so deriving a timeout from it directly produces a context that is dead on arrival. §15.5.6 returns to this.

A budget is a division problem, not a number.

Twenty-five seconds is not “the shutdown timeout”; it is the total that the propagation wait, the drain and the resource release must fit inside, and §15.7 shows how to divide it and what to abandon when the division turns out optimistic.

15.2.8 A Signal Is Not the Only Trigger

Everything so far has assumed shutdown begins with SIGTERM. In a real service it is one of at least four triggers, and two of them can arrive together:

The first three have to funnel into one function, and that function must be safe to call more than once from more than one goroutine. A shutdown that runs twice concurrently closes a channel twice and closes a pool twice — turning a controlled exit into the panic §14.6 says takes the whole process down.

sync.Once is the entire answer, and §12.1 already built it:

app_152.go
// Illustrative snippet — not a complete program
type App struct {
    once   sync.Once
    result error
}

// Stop runs the shutdown sequence exactly once. Concurrent and
// later callers block until it finishes and get the same error.
func (a *App) Stop() error {
    a.once.Do(func() { a.result = a.shutdown() })
    return a.result
}

The reflex here is to add a done channel so that late callers can wait for the first one. It is unnecessary, and the reason is a sentence in sync.Once's own documentation — written as a warning about re-entrancy, but containing the guarantee we want: “Because no call to Do returns until the one call to f returns, if f causes Do to be called, it will deadlock.”

Measured two goroutines calling Do 20 ms apart, with f sleeping 300 ms — both returned at 300 ms and both observed the completed result. So a.result is safe to read after Do returns, from any caller, with no further synchronisation: Once supplies the happens-before edge as well as the exactly-once.

15.2.9 Common Mistakes

Unbuffered signal.Notify channel
Problem

The signal is silently dropped; process waits for SIGKILL

Fix

Buffer of 1, or use NotifyContext

defer stop() only
Problem

Second Ctrl-C absorbed; operator cannot escape a stalled drain

Fix

Call stop() first thing after ctx.Done()

Cleanup in a defer after stop()
Problem

A second signal kills the process mid-cleanup

Fix

Explicit calls on the shutdown path, early

Deriving the budget from the signal context
Problem

Dead on arrival — that context is already cancelled

Fix

context.WithoutCancel(ctx) first

Branching on ctx.Err()
Problem

Always context canceled; tells you nothing

Fix

context.Cause(ctx) names the signal

Assuming a 30 s grace period
Problem

Killed mid-drain wherever it is set lower

Fix

Read the deployment; leave headroom

A shutdown path callable twice
Problem

Double close, double Close, panic on the way out

Fix

Funnel every trigger through one sync.Once

Summary: The Signal, and the Deadline It Starts

signal.NotifyContext is the entry point. It replaces the channel form, avoids the unbuffered-channel bug that go vet already catches, and — because it produces a context — makes the signal the root of a cancellation tree rather than an event handled in one place.

That context carries more than a bare cancellation. ctx.Err() is always context canceled, but context.Cause(ctx) names the signal, which lets one path distinguish a developer’s Ctrl-C from an orchestrator’s SIGTERM, and both from a shutdown the process started itself.

defer stop() is the bug. While the handler is installed every later signal is absorbed, so an operator watching a stalled drain has no escape but SIGKILL. Call stop() as the first thing after ctx.Done() — and accept the price, which is that a second signal then kills you mid-cleanup, so nothing that must happen may sit in a defer after that point.

The budget comes from the platform and varies from 10 seconds to 90 across common runtimes. Derive yours from a detached context with headroom, and treat it as a total to divide rather than a timeout to set.

Self-Check Questions: The Signal, and the Deadline It Starts

Why does go vet reject this, and what actually goes wrong?

c_152_2.go
// Illustrative snippet — not a complete program
c := make(chan os.Signal)
signal.Notify(c, syscall.SIGTERM)
<-c
The channel has no buffer, and Notify never blocks.

Because signal.Notify never blocks. Its documentation states it: the package will not block sending to the channel, so the caller must provide buffer space.

With an unbuffered channel the send only succeeds if a receiver is already parked on it. Between Notify returning and the goroutine reaching <-c there is a window — short, but real — where a delivered signal finds no receiver and is discarded. There is no retry and no queue.

The consequence is the worst-shaped bug in this chapter: nothing happens. No error, no log line, no crash. The process simply never begins shutting down, and the first anyone knows is that it was SIGKILLed at the end of the grace period with every one of its four promises broken.

vet catches it, which makes this the rare case where the fix is running a tool you already have. make(chan os.Signal, 1) satisfies it, and signal.NotifyContext sidesteps it.

Your service takes the second Ctrl-C as an instruction to exit now. What have you given up?

Your deferred cleanup, on that path.

stop() restores the default disposition, which for SIGTERM and SIGINT is immediate termination.

Measured the process is killed at once and no deferred function runs — the same exit-143 behaviour as having installed no handler at all.

That is the deal, and it is a good one: an operator watching a drain that will never finish gets a way out that is not SIGKILL. But it means the shutdown path can be cut at any point after stop(), so ordering becomes load-bearing. Anything that must happen — surrendering a lease, flushing the write-ahead log — has to run early and explicitly, not in a defer that assumes it will be reached.

If some final step genuinely cannot be interrupted, the honest options are to do it before calling stop() and accept a brief window where the operator cannot escape, or to make it idempotent and recoverable so that being cut off is survivable. There is no third option where you both hand back the signal and guarantee the cleanup.

What is wrong with this budget?

snippet_152_5.go
// Illustrative snippet — not a complete program
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)
The line above waited for ctx to be done.

shutdownCtx is already cancelled before Shutdown sees it.

ctx came from signal.NotifyContext and the line above waited for it to be done. Deriving from a cancelled parent produces a child that is cancelled immediately — a context cannot outlive its parent, and the 25-second timeout never gets a chance to matter (§13.4.2).

So Shutdown returns essentially at once with context.Canceled, having closed the listeners and waited for nothing. Every in-flight request is abandoned. The shape of the bug is nasty: it looks exactly like a correct graceful shutdown, runs fast, exits zero, and drops requests.

The fix is context.WithoutCancel(ctx) as the parent — detach from the cancellation, keep any values, then apply a fresh deadline:

snippet_152_6.go
// Illustrative snippet — not a complete program
shutdownCtx, cancel := context.WithTimeout(
    context.WithoutCancel(ctx), 25*time.Second)

This is §13.3.5's detached context doing exactly the job it was added for, and §15.5.6 uses the same tool for cleanup that must outlive a cancelled request.

You call stop() immediately after ctx.Done() fires, as §15.2.5 says. What have you just made unsafe?

Every defer that has not run yet.

stop() restores the default disposition, so the next SIGTERM or Ctrl-C terminates the process outright — no deferred functions, exit 143, exactly as §15.1.2 measured. Before the call, a second signal was absorbed and your cleanup was guaranteed to finish; after it, the operator can cut you off at any point.

That is the trade §15.2.6 makes explicit, and the consequence is structural rather than cosmetic: anything that must happen can no longer live in a defer placed after stop(). It has to be an explicit call on the shutdown path, early, in the order §15.6 sets — the lease released before the last connection drains, not after.

Which is also why the answer is not “then do not call stop()”. A drain that has stalled with the handler still installed is unkillable except by SIGKILL, which loses strictly more. You are choosing which failure you prefer, and this chapter’s answer is that an operator who can escape is worth more than a cleanup that always completes.

Key Takeaways

  • signal.Notify never blocks, so an unbuffered channel drops signals — and go vet reports it verbatim
  • signal.NotifyContext (Go 1.16) replaces the channel form and makes the signal the root of a cancellation tree
  • SIGKILL and SIGSTOP cannot be caught; the platform’s budget is therefore a hard bound
  • ctx.Err() is always context canceled; context.Cause(ctx) names the signal, and equals context.Canceled exactly when nobody signalled you
  • defer stop() absorbs every later signal — call stop() as the first thing after ctx.Done() so the operator keeps an escape hatch
  • The hatch costs your defers: after stop(), a second signal kills the process mid-cleanup
  • stop() does not overwrite the cause; the first cancellation wins
  • Budgets range from 10 s (Docker) to 90 s (systemd); derive yours from WithoutCancel with headroom and divide it
  • A signal is one of four triggers; funnel them all through a sync.Once, which blocks late callers and supplies the happens-before edge
Section 15.2 — in one line

Catch the signal with a context, hand the signal back the moment you have it, and remember that the clock started in someone else’s process before you knew there was one.

15.3 The Sequence

There are five steps and their order is the whole design. Four of them follow from §15.1.3's reversal rule. The first does not, and getting it wrong is the most common production shutdown bug there is.

15.3.1 The Five Steps

THE SHUTDOWN SEQUENCE

Five numbered steps down a vertical line: fail readiness to tell the load balancer to stop routing, then a marked propagation window during which it has not noticed yet, then stop accepting by closing listeners and ceasing to pull jobs, then drain to let accepted work finish, then release pools and leases in reverse dependency order, then report through the exit code, the log and a metric. Steps two to five are startup reversed; step one is not, and exists because something outside the process is still routing traffic.

Steps 2 through 5 need no justification beyond §15.1.3 — you stop taking new work before you finish old work, you release a resource after the last thing that needs it, and you say what happened. Step 1 is the one worth measuring.

The sequence is the same whichever of §15.2.8's four triggers started it. That is the point of funnelling them through one sync.Once: there is one shutdown, and it runs these five steps once.

15.3.2 Deregistration Is Concurrent With the Signal, Not Before It

The instinct is that shutdown starts when the signal arrives, and that by then traffic has already stopped. Neither half is true.

In an orchestrated environment two things happen at once. The control plane marks the pod terminating and begins updating endpoints, and the kubelet sends SIGTERM. These are concurrent, and the second usually wins the race: your process learns it is going away before the load balancer does.

WHAT ARRIVES WHEN

A three-column sequence between the control plane, your process and the load balancer. The control plane marks the pod terminating and sends SIGTERM to the process, and separately pushes an endpoints update to the load balancer. The two are concurrent and the signal usually lands first, so between SIGTERM and the endpoints update the load balancer is still routing and requests are still arriving. Closing listeners in that window refuses every one of them.

The propagation delay is real and is not small: an endpoints update has to reach every proxy, each of which is watching on its own schedule. Hundreds of milliseconds is normal, and under load it is worse.

15.3.3 Measured: What the Window Costs

The experiment: four clients dial the server in a loop, pausing 3 ms between requests, with keep-alives disabled so every request is a fresh connection — which is what a load balancer’s pool does. After 80 ms of steady state the shutdown begins, and the modelled balancer goes on routing for a further 150 ms before it notices. The only variable is whether the server keeps serving across that 150 ms window before closing its listeners.

Measured, ten runs:

when listeners close
Close listeners immediately
Wait out propagation first

The refusals are not a rounding error; they are roughly twice the number of requests successfully served. The absolute counts scale with the client rate and the length of the window — it is the ratio, and the zero, that survive a change of machine. And every one is a request the load balancer believed it had routed to a healthy backend — so the client sees a connection error, not a retryable 503, and whether it retries at all depends on a policy you do not control.

The fix is the one that looks like a hack:

snippet_153.go
// Illustrative snippet — not a complete program
<-ctx.Done()
stop()

health.SetNotReady()            // step 1
sleepCtx(hardCtx, propDelay)    // <-- required, not a hack
srv.Shutdown(shutdownCtx)       // step 2 and 3

The wait itself is a sleep the hard deadline can cut short:

sleep_ctx_153.go
// Illustrative snippet — not a complete program
func sleepCtx(ctx context.Context, d time.Duration) {
    t := time.NewTimer(d)
    defer t.Stop()
    select {
    case <-t.C:
    case <-ctx.Done():
    }
}

That wait is doing real work. It is not waiting for anything inside the process; it is waiting for a fact to propagate through a system you can only observe indirectly. There is no channel that tells you the load balancer noticed, which is why the shape is a sleep rather than a receive.

It is a bounded sleep, and that part is not decoration. §15.7.1 divides the grace period into phases and §15.7.8 makes it a rule that every phase carries its own deadline; a bare time.Sleep(propDelay) is the one construct in this chapter that cannot be cut short, so five seconds of it out of a thirty-second budget is five seconds the hard deadline can never reclaim. sleepCtx is the same wait with an escape, and every later listing uses it — §15.3.5 in the full sequence, and the select form in §15.4.2 that retires keep-alives at the same time.

Fail readiness, never liveness.

Most services answer both probes from one /health endpoint, and wiring SetNotReady() to it is the bug that undoes everything this chapter builds: the liveness probe starts failing too, and the platform kills the pod after failureThreshold × periodSeconds — often 3 to 10 seconds, far inside the grace period you were carefully dividing. Liveness answers “is this process still working?” and the answer is yes right up until it exits; readiness answers “should traffic come here?” and that is the one that flips. Two endpoints, and only one of them changes.

This is the sleep that reviewers delete

it has no comment explaining what it waits for, so it reads like a workaround for a race the author did not understand. Comment it with the number it is derived from — the readiness probe’s periodSeconds plus failureThreshold, plus the proxy’s own sync interval — and it becomes what it actually is: the propagation term of the budget from §15.2.7.

15.3.4 Choosing the Wait

The wait should cover the worst case of “how long until every proxy has stopped routing to me”, which is roughly:

Terminal
propagation ≈ readiness period × failure threshold
              + proxy sync interval
              + a margin

With a 2-second readiness period, a failure threshold of 1 and a proxy that reconciles every second, five seconds is a defensible number and ten is not paranoid. That is a large fraction of a 30-second budget, which is exactly why §15.7 treats the total as something to divide deliberately.

Two ways to spend less of it. If your platform supports a preStop hook, the wait can happen there, before SIGTERM is ever sent — the grace period clock starts after preStop completes, so the wait comes out of a different budget. And if you can observe your own readiness endpoint being polled, you can wait for the actual last poll rather than a worst-case constant. Both are optimisations on the same principle: the window exists, and something has to cover it.

15.3.5 The Rest of the Sequence

With step 1 established, the remainder is §15.1.3 applied literally:

shutdown_153.go
// Illustrative snippet — not a complete program
func shutdown(ctx context.Context, app *App) error {
    // 1. stop being routed to
    app.health.SetNotReady()
    sleepCtx(ctx, app.cfg.PropagationDelay)

    // 2 + 3. stop accepting, then let accepted work finish
    if err := app.server.Shutdown(ctx); err != nil {
        return fmt.Errorf("http drain: %w", err)
    }
    if err := app.workers.Drain(ctx); err != nil {
        return fmt.Errorf("worker drain: %w", err)
    }

    // 4. release, innermost last
    app.lease.Release(ctx)
    app.db.Close()
    app.telemetry.Flush(ctx)

    return nil
}

Steps 2 and 3 are one call for the HTTP server, because Shutdown does both — it closes listeners and then waits. §15.4 is about what that call does and does not include, starting with the half-step this listing leaves out: keep-alives should be retired during the propagation wait, not at Shutdown time (§15.4.2). The worker drain is separate because a worker pool’s intake is a channel, not a listener, and §15.5 covers the distinction. And the release order is the reverse of the order those things were created, with one deliberate exception: the lease goes first, because §15.2.6 established that everything after stop() can be cut off, and a held lease is the most expensive thing to lose.

15.3.6 Common Mistakes

Closing listeners as step 1
Problem

Requests refused that the LB believed were routed

Fix

Fail readiness and wait out propagation first

Failing liveness along with readiness
Problem

Killed seconds into a 30 s budget, on every deploy

Fix

Two endpoints; only readiness flips

Deleting the propagation sleep in review
Problem

The same refusals return, now unexplained

Fix

Comment it with the numbers it derives from

Waiting a fixed 1 s “to be safe”
Problem

Too short for a 2 s readiness period

Fix

Derive it: period × threshold + sync + margin

Releasing resources before the drain
Problem

Handlers query a closed pool mid-request

Fix

Release strictly after the drain returns

Surrendering the lease last
Problem

Cut off by a second signal, lease held for its TTL

Fix

Release it first among the resources

Treating drain and stop-accepting as one step
Problem

Worker pool keeps pulling new jobs while “draining”

Fix

Close the intake, then wait

Summary: The Sequence

Five steps: fail readiness, wait out propagation, stop accepting, drain, release and report. Four of them are §15.1.3's reversal rule applied mechanically. The first is not, and it is the one that gets skipped.

It gets skipped because the instinct is that traffic has already stopped by the time the signal arrives. It has not. Deregistration and SIGTERM are concurrent, and the signal usually arrives first, so there is a window in which the load balancer is still routing to a process that has decided to leave.

Measured that window is expensive — closing listeners immediately refused 168–180 requests against 80–88 served, while waiting the propagation delay first refused none and served 232–252. The refused requests are worse than failures — they are failures the load balancer believed it had routed successfully.

The fix is a sleep, it is required rather than a workaround, and it should be commented with the readiness period and proxy sync interval it is derived from so that the next reviewer does not delete it.

Self-Check Questions: The Sequence

Your service closes its listener the instant SIGTERM arrives, and clients see connection resets. The load balancer’s health check is configured correctly. What is wrong?

Nothing is wrong with the health check. The problem is that it has not been consulted yet.

Marking the pod terminating and sending SIGTERM happen concurrently, and the signal typically arrives first. The endpoints update then has to propagate to every proxy, each polling on its own schedule. Until it has, the load balancer is still sending you traffic — as far as it knows you are a healthy backend.

Closing the listener in that window is what produces the resets. Measured on a 150 ms propagation delay: 168–180 requests refused against 80–88 served.

The fix is to fail readiness first and then keep serving for the length of the propagation window before closing anything. Correct health-check configuration is necessary but not sufficient: it determines how long the window is, not whether it exists.

Why can’t you replace the propagation sleep with a channel or a callback?

Because nothing in the system will tell you.

Every other wait in this book has a signal to select on: a channel close, a context done, a WaitGroup reaching zero. This one does not. The fact you are waiting for — “every proxy has processed the endpoints update and stopped routing to me” — is distributed across processes you cannot observe, and none of them sends you an acknowledgement.

So you wait on a duration derived from configuration you can read: the readiness probe’s period times its failure threshold, plus the proxy’s sync interval, plus a margin. That is not a heuristic standing in for a signal; it is the only information available.

Two ways to do better without inventing a signal. A preStop hook moves the wait before SIGTERM, so it comes out of a different budget. And if you can see your readiness endpoint being polled, you can wait for the last poll to have observed the failure rather than assuming the worst case — which turns a fixed constant into an observation, though still not into a signal.

Your release phase closes the database pool and then surrenders a distributed lease, and the lease release needs a database write. What is the bug and why is the ordering rule not enough to catch it?

The lease can never be released: its write goes to a pool that is already closed.

The reversal rule from §15.1.3 does catch this, if you apply it to the real dependency graph. The lease depends on the database, so the lease was acquired after the pool was opened, so it must be released before the pool is closed. Reversed build order gives the right answer.

What makes it easy to miss is that the dependency is invisible at the call site. lease.Release(ctx) and db.Close() are two lines that look independent, and nothing in either signature says one needs the other. The rule works, but only if you have the real graph rather than the order the lines happen to appear in.

There is a second reason to move the lease earlier, from §15.2.6: after stop() a second signal can cut the shutdown path at any point, and a lease held to its TTL is the most expensive thing to be holding when that happens. So it goes first among the resources — which in this case also happens to satisfy the dependency.

Key Takeaways

  • Five steps: fail readiness, wait out propagation, stop accepting, drain, release and report
  • Deregistration and SIGTERM are concurrent, and the signal usually arrives first — traffic has not stopped when you learn you are leaving
  • Closing listeners immediately refused 168–180 requests against 80–88 served; waiting out the propagation window first refused none
  • Refused requests are worse than failed ones: the load balancer believed it routed them to a healthy backend
  • The propagation sleep is required, not a workaround, and needs a comment naming the numbers it derives from — and a bound, so the hard deadline can still reclaim it
  • Only readiness flips: a shared /health that fails liveness too gets the pod killed well inside the grace period
  • Derive it as readiness period × failure threshold + proxy sync interval + margin; a preStop hook moves it out of your budget
  • Release in reverse dependency order, but put the lease first — everything after stop() can be cut off
Section 15.3 — in one line

Stop being routed to before you stop listening, because the thing that routes to you finds out by polling, and until it has, every listener you close turns a served request into a refused one.

15.4 Stopping the HTTP Server

Server.Shutdown(ctx) is the most-used shutdown API in Go and the most misunderstood. It has four holes. Every one is documented, every one is measurable, and every one produces a shutdown that looks correct in code review and loses data in production.

15.4.1 ErrServerClosed Returns Immediately

The first hole is the classic bug, and the standard library’s documentation calls it out by name:

From the Server.Shutdown documentation

“When Shutdown is called, Serve, ServeTLS, ListenAndServe, and ListenAndServeTLS immediately return ErrServerClosed. Make sure the program doesn’t exit and waits instead for Shutdown to return.”

Measured with a 700 ms handler in flight, Serve returned http: Server closed at 0 ms — the instant Shutdown was called — while Shutdown itself did not return until the handler finished. Two return values, hundreds of milliseconds apart, and the wrong one is the one most code waits on:
snippet_154_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: main exits while Shutdown is still draining
go func() { <-ctx.Done(); srv.Shutdown(shutdownCtx) }()
if err := srv.ListenAndServe(); err != nil {
    log.Fatal(err) // fires on a *successful* shutdown
}

Two bugs in three lines. log.Fatal calls os.Exit(1), killing every in-flight request and skipping every deferred function (§15.1.2) — and it fires on the success path, because ErrServerClosed is exactly what a correct shutdown returns. The correct shape inverts which value you wait on:

err_ch_154.go
// Illustrative snippet — not a complete program
errCh := make(chan error, 1)
go func() {
    err := srv.ListenAndServe()
    if !errors.Is(err, http.ErrServerClosed) {
        errCh <- err // a real listener failure
    }
}()

select {
case err := <-errCh:
    return err
case <-ctx.Done():
}
stop()
return srv.Shutdown(shutdownCtx) // wait for THIS one

ErrServerClosed is filtered out because it is not a failure; anything else on that channel is. And the value the function returns is Shutdown's, which is the one that means draining finished.

Shutdown is one-shot.

Calling Serve again on a server that has been shut down returns http: Server closed immediately, and the documentation agrees — “Once Shutdown has been called on a server, it may not be reused.” If you restart on a config reload, build a new http.Server; reusing the value silently refuses to serve.

15.4.2 Retiring Keep-Alive Connections

There is a phase between “stop advertising” and “close listeners” that Shutdown cannot help with, because by the time you call it the decision has already been made.

HTTP keep-alive means a client that finished a request holds the TCP connection open and reuses it. That is what makes a connection pool work, and it is also why a client that has already connected to you keeps sending requests down that connection long after the load balancer stopped choosing you for new ones. Failing readiness does nothing about it: endpoint removal governs which address gets dialled next, not connections already established.

Server.SetKeepAlivesEnabled(false) is the lever. It makes the server mark responses so each client retires its pooled connection after the response it is currently receiving.

Measured, one client with a warm pooled connection, second request after the call:

Terminal
SetKeepAlivesEnabled(false)=false 2nd resp.Close=false
SetKeepAlivesEnabled(false)=true 2nd resp.Close=true

resp.Close is net/http's normalised view of the close signal, which is why the raw Connection header reads empty on the client side. The effect is that the second client will not reuse the connection, so its next request goes through a fresh dial — which the load balancer sends elsewhere, because you already failed readiness.

It belongs in phase 1, alongside the propagation wait, for exactly that reason:

snippet_154_2.go
// Illustrative snippet — not a complete program
app.ready.Store(false)             // stop advertising
app.srv.SetKeepAlivesEnabled(false) // retire pooled connections

select {                            // let both take effect
case <-time.After(propagationDelay):
case <-hardCtx.Done():
}

Two seconds of propagation wait with keep-alives still enabled is two seconds of a warm client pipelining more work into a process that is leaving. With them disabled, the same two seconds drains the pool.

Shutdown closes idle connections, so why bother?

Because “idle” is a state a busy connection never reaches. Shutdown closes connections that are idle at that moment and then waits for the rest to become idle — and a client sending a steady stream down a kept-alive connection keeps it non-idle indefinitely. SetKeepAlivesEnabled(false) is what makes the connection become idle after the current response, so Shutdown has something to close. Without it, one chatty client can extend your drain for the whole budget.

15.4.3 Shutdown Does Not Cancel In-Flight Requests

The second hole is the one that surprises people who have read the first.

The ctx you pass to Shutdown bounds how long you are willing to wait. It does not bound how long the handler runs, and it is not delivered to the handler at all.

Measured, a 600 ms handler against a 200 ms shutdown budget:

Terminal
Shutdown returned: context deadline exceeded (after 200ms)
handler's r.Context().Err() at the end: <nil>

Shutdown gave up. The handler was never told anything happened — its request context’s Err() was still nil when it finished, 400 ms later. If main returns on that context deadline exceeded, the process exits with a handler mid-write.

TWO CLOCKS, NOT CONNECTED

Two parallel timelines side by side. On the left, Shutdown’s clock runs for the two hundred millisecond budget you passed it and then returns context deadline exceeded. On the right, the handler’s own r.Context() is still running and its Err() is still nil. The two are never joined: the context given to Shutdown bounds how long you wait, not how long a handler runs, and it never reaches the request tree.

This is worth sitting with, because it inverts the usual mental model. Passing a context to a function in Go normally means “stop when this is done”. Here it means “stop waiting when this is done”, and the work continues unattended.

15.4.4 BaseContext, and the Rule That Makes It Safe

The fix is Server.BaseContext, a hook that supplies the context every request context descends from:

srv_154.go
// Illustrative snippet — not a complete program
base, cancelBase := context.WithCancelCause(context.Background())

srv := &http.Server{
    Handler:     h,
    BaseContext: func(net.Listener) context.Context { return base },
}

Now r.Context() in every handler is a child of base, so cancelling base reaches all of them. Measured, the same 600 ms handler:

Terminal
handler saw: context canceled

That is Chapter 13's context tree doing exactly what it was built for, one level higher than usual: the root is not a request, it is the process.

And here is the rule, which matters more than the mechanism. Cancel base alongside Shutdown and you have not built a graceful shutdown — you have built an abrupt one with extra steps. Every in-flight handler aborts mid-response.

Measured, base context cancelled at the same moment as Shutdown, with a handler that writes part of its response and then stops when cancelled:

how the handler stops
plain return
plain return
panic(http.ErrAbortHandler)
panic(http.ErrAbortHandler)

The first two rows are the ones to be frightened of. A handler that returns cleanly when its context is cancelled produces a well-formed HTTP responsenet/http computes a Content-Length for what was actually written, or terminates the chunk stream correctly — so the client reads a 200 OK to completion with no error of any kind and stores a truncated result. Nothing on the client side can tell.

WHY THE COMMON CASE IS THE SILENT ONE

Two branches compared. A handler that returns politely on ctx.Done() looks like an ordinary return to net/http, which frames what was already written with a Content-Length or a correctly terminated chunk stream and sends a complete, well-formed message, so the client sees a 200 OK with a short body and no error it can detect. A handler that panics with http.ErrAbortHandler breaks the connection mid-message, so the client sees an unexpected EOF, which is at least true. Handling cancellation correctly is what makes the truncation invisible.

That is the sting: the polite thing a handler can do on cancellation is exactly what erases the evidence. http.ErrAbortHandler is the sanctioned way to say “this response is not finished” — panicking with it aborts the connection without logging a stack trace, and the client gets an error it can act on rather than a lie it cannot detect.

Either way it is §14.1's thesis — the failure mode is silence, not a crash — arriving at the process boundary. And it establishes the ordering:

WHEN TO CANCEL THE BASE CONTEXT

An ordering diagram. Shutdown runs first with its budget; only when that budget expires does cancelBase fire with a cause. Handlers that finish in time are undisturbed, and handlers that did not are told to stop and can return a 503. Firing the second alongside the first cuts short every handler that was going to finish.

§15.7.2 puts this on a full escalation ladder. The point here is that BaseContext is a footgun without it: the natural reading of “cancel the context to shut down” is precisely the thing that truncates responses.

15.4.5 Hijacked Connections Are Never Waited For

The third hole is documented and absolute:

Also from the Server.Shutdown documentation

“Shutdown does not attempt to close nor wait for hijacked connections such as WebSockets.”

Measured with a WebSocket-style hijacked connection still live, Shutdown returned nil in approximately 0 ms. Not an error, not a timeout — a clean success while a connection was still open and being written to.

RegisterOnShutdown exists for this, and its own documentation limits what it does:

snippet_154_3.go
// Illustrative snippet — not a complete program
srv.RegisterOnShutdown(func() {
    close(wsQuit) // notify -- does not wait
})

The callbacks are launched in their own goroutines and nothing waits for them. So the pattern is: use the hook to notify, and track the connections yourself with a WaitGroup you can actually wait on.

ws_conns_154.go
// Illustrative snippet — not a complete program
var wsConns sync.WaitGroup

// in the handler, before hijacking
wsConns.Add(1)
defer wsConns.Done()

// in the shutdown path, after Shutdown returns
close(wsQuit)
waitCtx, cancel := context.WithTimeout(shutdownCtx, 5*time.Second)
defer cancel()
waitFor(waitCtx, &wsConns) // now you have actually waited

If your service has long-lived connections — WebSockets, SSE, gRPC streams — this is not an edge case. Shutdown returning nil means nothing about them.

15.4.6 The Chained Cause

Three chapters converge here, and the payoff is one line in a handler.

Wire the base context with WithCancelCause rather than plain WithCancel, and pass the shutdown cause through:

srv_154_2.go
// Illustrative snippet — not a complete program
// WithoutCancel: keep the values middleware put above the
// server, but do not inherit the signal's cancellation.
base, cancelBase := context.WithCancelCause(
    context.WithoutCancel(sigCtx))
srv := &http.Server{Handler: h,
    BaseContext: func(net.Listener) context.Context { return base }}

// later, when the budget expires:
cancelBase(fmt.Errorf("drain budget expired: %w",
    context.Cause(sigCtx)))

Now a handler can find out not just that it is being cut short, but why:

snippet_154_4.go
// Illustrative snippet — not a complete program
select {
case <-r.Context().Done():
    slog.Warn("request abandoned",
        "cause", context.Cause(r.Context()),
        "trace", r.Context().Value(traceKey))
    return
case result := <-work:
    respond(w, result)
}

Measured, the full chain from a real signal:

Terminal
Err = context canceled
Cause = drain budget expired: user defined signal 1 signal received
trace = trace-3

The signal named itself in §15.2.4, NotifyContext put that name in a cause, and the shutdown path copied that cause into the base context’s own — it does not travel there by itself. That distinction is the whole of the wiring: base is deliberately not cancellable from sigCtx, or every handler would die the instant the signal landed, so the only way the reason reaches a request is that you carried it. Chapter 13's WithCancelCause, Chapter 14's error attribution and this chapter’s shutdown, in one line of handler code.

The wiring is load-bearing, in two places.

Build the base context with plain context.WithCancel and the handler gets Cause=context canceled — the reason is gone. Root it at context.Background() instead of context.WithoutCancel(sigCtx) and something quieter goes missing: every value attached above the server. Measured, the same handler read trace-3 from r.Context() under WithoutCancel and nothing at all under Background(). Trace IDs and tenant identifiers live there, and a shutdown log line without them is the one you cannot correlate.

15.4.7 A Timed-Out Shutdown Closes Nothing

The fourth hole follows from §15.4.3 but is worth stating separately, because it changes what your code has to do next.

When the context expires, Shutdown returns ctx.Err() and stops waiting. It does not close the remaining connections and it does not stop the handlers. Everything is exactly as it was, minus your attention.

Server.Close() is the hard stop: it closes all listeners and all connections immediately, active ones included. So the complete pattern is two-phase:

snippet_154_5.go
// Illustrative snippet — not a complete program
if err := srv.Shutdown(shutdownCtx); err != nil {
    slog.Warn("graceful drain did not finish", "err", err)
    cancelBase(errShutdownExpired) // step 2 of the ladder
    if cerr := srv.Close(); cerr != nil {   // step 3
        slog.Error("force close failed", "err", cerr)
    }
}

Without the Close, a Shutdown that timed out leaves you with open connections and a process that will be SIGKILLed holding them.

15.4.8 Shutdown Polls

One mechanical detail, because it has a cost that shows up in §15.7's budget arithmetic. Shutdown does not wait on a condition variable; it polls for quiescence with a backoff, and the standard library is candid about it:

shutdown_poll_154.go
// Illustrative snippet — not a complete program
// Ideally we could find a solution that doesn't involve polling,
// but which also doesn't have a high runtime cost (and doesn't
// involve any contentious mutexes), but that is left as an
// exercise for the reader.
const shutdownPollIntervalMax = 500 * time.Millisecond

The interval starts at 1 ms, doubles each round, and clamps at 500 ms with jitter. The consequence — that a long drain adds dead time after the last handler has already finished — is measured in §15.7.3.

15.4.9 Common Mistakes

log.Fatal on ListenAndServe's error
Problem

Process exits mid-drain on a successful shutdown

Fix

Filter ErrServerClosed; wait on Shutdown's return

Returning from main when Serve returns
Problem

Same, one line later

Fix

Serve returns immediately; Shutdown is the one to wait for

Expecting Shutdown's ctx to reach handlers
Problem

Handler runs on past the deadline, unaware

Fix

BaseContext, cancelled as an escalation

Cancelling the base context alongside Shutdown
Problem

200 OK with a silently truncated body

Fix

Cancel it only when the budget expires

Plain WithCancel for the base context
Problem

Handlers learn that they were cut, never why

Fix

WithCancelCause, threaded from the signal

Leaving keep-alives on during the propagation wait
Problem

A warm client keeps pipelining into a leaving process

Fix

SetKeepAlivesEnabled(false) in phase 1

Trusting Shutdown for WebSockets
Problem

Returns nil while connections are still live

Fix

RegisterOnShutdown to notify, a WaitGroup to wait

Treating a timed-out Shutdown as done
Problem

Connections still open at SIGKILL

Fix

Escalate to Close()

Reusing a server after Shutdown
Problem

Serve returns ErrServerClosed immediately

Fix

Build a new http.Server

Summary: Stopping the HTTP Server

Server.Shutdown has four holes, all documented and all measurable.

Serve returns ErrServerClosed at the instant Shutdown begins, hundreds of milliseconds before draining finishes — so code that waits on ListenAndServe and calls log.Fatal exits mid-drain, on the success path.

Shutdown's context bounds how long you wait, not how long handlers run.

Measured a 600 ms handler against a 200 ms budget left the request context’s Err() nil at the end. BaseContext is the fix, and it is dangerous without an ordering rule: cancelled alongside Shutdown it produces a 200 OK with a silently truncated body and no error anywhere. It belongs on the escalation ladder — Shutdown, then cancel the base, then Close — never in parallel.

Hijacked connections are outside all of it; Shutdown returned nil in ~0 ms with one still live, and RegisterOnShutdown notifies without waiting.

And a timed-out Shutdown closes nothing, so Close() is the required next rung.

The reward for wiring the base context with WithCancelCause is that a handler can log which signal is cutting it short — Chapter 13's cause, Chapter 14's attribution and this chapter’s shutdown meeting in one line.

Self-Check Questions: Stopping the HTTP Server

What is wrong with this, and which of §15.1.1's four promises does it break?

snippet_154_6.go
// Illustrative snippet — not a complete program
go func() { <-sig; srv.Shutdown(context.Background()) }()
if err := srv.ListenAndServe(); err != nil {
    log.Fatal(err)
}
ErrServerClosed is what a successful shutdown returns.

ListenAndServe returns http.ErrServerClosed the moment Shutdown is called — measured at 0 ms, while Shutdown itself was still draining a 700 ms handler. So log.Fatal fires on the success path, and log.Fatal is os.Exit(1).

That kills the process mid-drain. It breaks the second promise (accepted work finishes) directly, and the third (release resources safely), since os.Exit skips every deferred function. It also technically breaks the fourth: the process exits 1, which reads as a crash rather than a shutdown that dropped work.

Two changes fix it. Filter the sentinel, because it is not a failure — if !errors.Is(err, http.ErrServerClosed). And wait on Shutdown's return value rather than ListenAndServe's, because that is the one that means draining finished.

The documentation says this outright: “Make sure the program doesn’t exit and waits instead for Shutdown to return.”

You set a 5-second Shutdown budget and a handler takes 30 seconds. Where is the handler 6 seconds in?

Still running, and completely unaware.

Measured on the same shape: Shutdown returned context deadline exceeded at the deadline, and the handler’s r.Context().Err() was still nil when it finished long afterwards. The context you give Shutdown bounds how long it waits; it is never delivered to the handler.

So at six seconds the handler is mid-work, holding whatever it holds, and your shutdown path has already moved on. If main returns now, the process exits with that work in flight.

Two things are needed. BaseContext gives you a handle that actually reaches request contexts — but only cancel it once the budget has expired, never alongside Shutdown, or you truncate responses that would have finished. And srv.Close() is what actually drops the connections, since a timed-out Shutdown closes nothing.

That is the escalation ladder: wait politely, then tell handlers to stop, then drop the connections, then report.

Your API streams JSON and sets no Content-Length. On shutdown you cancel the base context and call Shutdown together. What does a client see?

A 200 OK with a truncated body and no error at all.

Measured, chunked framing: status="200 OK", body="part1", readErr=<nil>. The handler returned when its context was cancelled, the server terminated the chunked stream correctly, and from the client’s side the response is well formed — just shorter than it should have been. A client that unmarshals what it got and checks only the status code records a success holding partial data.

With an explicit Content-Length the same scenario gives unexpected EOF, because the promised byte count did not arrive. That is a worse-looking result and a far better outcome: it is detectable.

The fix is ordering, not framing. Cancelling the base context is the escalation after Shutdown's budget expires, not something you do alongside it. Give handlers the chance to finish first, and only cut them off when waiting has already failed — then the truncation happens to requests that were going to be lost anyway, rather than to requests that had 50 ms left to run.

It is worth noticing that this is §14.1's silent failure at a different scale. The program does not crash, does not log, does not return an error to anyone. It just quietly returns less than it promised.

Shutdown returned nil. Is it safe to exit?

Only if you have no hijacked connections and nothing else to release.

Shutdown returning nil means the listeners are closed and every non-hijacked connection reached idle. It says nothing about WebSockets, SSE streams or anything else that took the connection over with Hijackermeasured, it returned nil in ~0 ms with a hijacked connection still open and writable.

It also says nothing about the rest of your process: worker pools still draining (§15.5), the database pool, a held lease, unflushed telemetry.

So nil means one component finished one phase. The shutdown sequence continues: notify hijacked connections through RegisterOnShutdown, wait for them on a WaitGroup you maintain yourself since the hook does not wait, then release resources in reverse dependency order, then report.

Key Takeaways

  • Serve returns ErrServerClosed immediately when Shutdown begins — measured at 0 ms against a 700 ms drain; waiting on it and calling log.Fatal exits mid-drain on the success path
  • Shutdown's context bounds how long you wait, not how long handlers run; measured, the request context’s Err() was still nil after the budget expired
  • BaseContext gives you a handle that reaches every request context — and is a footgun without an ordering rule
  • Cancelling the base alongside Shutdown yields 200 OK with a silently truncated body under chunked framing, unexpected EOF with Content-Length
  • The ladder: Shutdown → cancel base → Close → report. Never fire the second alongside the first
  • Hijacked connections are outside Shutdown entirely; RegisterOnShutdown notifies and does not wait
  • A timed-out Shutdown closes nothing — Close() is the required escalation
  • Build the base with WithCancelCause, and a handler can log which signal cut it short
  • SetKeepAlivesEnabled(false) is what makes a busy connection become idle, so Shutdown has something to close
  • A handler that returns politely on cancellation produces a well-formed short response — http.ErrAbortHandler is how you make the truncation visible
  • Shutdown is one-shot; reusing the server returns ErrServerClosed immediately
Section 15.4 — in one line

Shutdown closes listeners and waits, and that is all it does — it will not tell your handlers, will not touch your WebSockets, will not close anything when it gives up, and the value it returns is not the one most programs are waiting on.

15.5 Draining Workers and Queues

An HTTP server has one intake and Shutdown closes it for you. A worker pool has an intake you built, a backlog you chose to hold, and work already in progress — three different things, and “drain the pool” means a different thing for each.

15.5.1 Three Things Called Draining

WHAT "DRAIN" MEANS, PRECISELY

A flow from producers through a jobs channel to workers to done, with the channel labelled the backlog and the workers labelled in flight. Three numbered steps follow: close the intake to stop accepting new jobs, drain the backlog to process what is queued, and finish in flight to let running jobs complete. Steps one and three are always required; whether you do step two is a policy decision that depends on whether the work has another copy.

Step 1 and step 3 are non-negotiable: you must stop taking new work, and you must let work already started finish or be abandoned deliberately. Step 2 is where designs differ, and conflating it with step 3 is the mistake this section exists to prevent.

15.5.2 Closing the Intake

For a channel-fed pool the intake is the channel, and closing it is what tells the workers to stop:

pool_155.go
// Illustrative snippet — not a complete program
type Pool struct {
    jobs chan Job
    wg   sync.WaitGroup
}

func (p *Pool) Start(ctx context.Context, n int) {
    for range n {
        p.wg.Go(func() {
            for job := range p.jobs { // ends when jobs is closed
                p.process(ctx, job)
            }
        })
    }
}

func (p *Pool) Drain(ctx context.Context) error {
    close(p.jobs) // step 1: no more work in
    return waitFor(ctx, &p.wg) // steps 2 and 3: finish what is there
}

for range p.jobs is doing double duty, and it is worth being explicit about why. A closed channel with buffered values still yields those values before the range ends — so this loop drains the backlog and then exits. One construct, both steps, which is convenient when draining the backlog is what you want and misleading when it is not.

The waitFor helper is the one piece the standard library does not give you: WaitGroup.Wait has no context-aware form.

wait_for_155.go
// Illustrative snippet — not a complete program
func waitFor(ctx context.Context, wg *sync.WaitGroup) error {
    done := make(chan struct{})
    // close never blocks, so this goroutine cannot leak on a timeout
    go func() { wg.Wait(); close(done) }()
    select {
    case <-done:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

This is §14.2.4's closer goroutine with a select on the end. The goroutine outlives a timeout — it is parked in wg.Wait() until the workers actually finish — but it holds only a stack and a channel, and it cannot block anything because close never blocks.

Closing the intake is the sender’s job, and shutdown makes it ambiguous.

§3.3's rule is that the sender closes, and a pool’s producers are the senders. If several goroutines feed the channel, none of them may close it — the shutdown coordinator must stop the producers first, then close. Closing the channel while a producer is still selecting on a send is a send on closed channel panic, which is a crash during shutdown: the worst possible timing. §15.5.4 is what “stop the producers first” has to mean in code.

15.5.3 In-Flight Is Not Queued

The distinction that matters most, and the one the phrase “drain in-flight operations” hides.

A job in flight has been pulled from the queue and is executing. If you drop it, its work is half-done — a partial write, a charged card with no receipt, a lease taken and not released. Nobody else will pick it up, because as far as the queue is concerned it was delivered.

A job in the backlog has not started. If you drop it, nothing has happened yet. Whether it is lost depends entirely on where the backlog lives.

Backlog lives in
An in-process channel
A durable queue, already acked
A durable queue, not yet acked

That third row is the important one, and it is why the answer to “should I drain the backlog?” is genuinely it depends.

15.5.4 The Add-After-Close Race

Closing the intake is what stops the workers, and it is also the most dangerous line in a shutdown, because close cannot be guarded by a select:

pool_155_x_2.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: Submit can be mid-send when Close fires
func (p *Pool) Submit(j Job) { p.jobs <- j }
func (p *Pool) Close()       { close(p.jobs) }

An atomic flag does not fix it. if closed.Load() { return } followed by p.jobs <- j leaves a window between the check and the send, and the window is exactly where the panic lives — it makes the crash rarer, which mostly means it now happens in production rather than in tests.

Measured 200 rounds, each with eight concurrent senders making 200 sends apiece against a Close — 1,600 sends per round, 320,000 in all. Across twenty repetitions of that experiment, 0 to 5 rounds panicked, and one repetition in twenty came back completely clean. Often enough to reach production, rare enough that a test run can easily come back green. Meanwhile go test -race flagged it on 10 runs out of 10, naming runtime.closechan against runtime.chansend. The detector does not need the crash, which is §14.1's thesis in miniature: the absence of a panic is not evidence of correctness.

The fix is mutual exclusion that spans the send:

pool_155_3.go
// Illustrative snippet — not a complete program
type Pool struct {
    jobs   chan Job
    wg     sync.WaitGroup // as in §15.5.2
    mu     sync.RWMutex
    closed bool
}

func (p *Pool) Submit(j Job) error {
    p.mu.RLock()
    defer p.mu.RUnlock()
    if p.closed {
        return ErrShuttingDown
    }
    p.jobs <- j // safe: Close cannot proceed while we hold RLock
    return nil
}

func (p *Pool) Close() {
    p.mu.Lock()
    defer p.mu.Unlock()
    p.closed = true
    close(p.jobs)
}

The RWMutex is doing real work. Submit holds the read lock across the send, so Close cannot take the write lock — and therefore cannot call close — while any send is in progress. Many Submit calls still proceed concurrently, which is why it is an RWMutex and not a Mutex; the close is simply ordered strictly after all of them.

One consequence: Submit can block holding a read lock, stalling Close behind a full channel. Give it an escape if that matters:

snippet_155.go
// Illustrative snippet — not a complete program
select {
case p.jobs <- j:
    return nil
case <-ctx.Done():
    return ctx.Err()
}
An error beats a panic, and beats a block.

ErrShuttingDown is information the caller can use — retry elsewhere, return 503, leave the message unacked so it is redelivered (§15.5.5). A panic is not, and a block that outlives the budget becomes a SIGKILL. “Stop accepting” from §15.3 is what this looks like from inside the API.

15.5.5 When Abandoning the Backlog Is Correct

With at-least-once delivery and an ack-after-processing discipline, an unstarted job that you never ack goes back to the queue and is redelivered — to a replica that is not shutting down and has a full budget rather than the seconds you have left.

Draining that backlog is therefore not just unnecessary, it is worse: you spend your scarce shutdown budget doing work another instance would have done with time to spare, and you risk being SIGKILLed halfway through a job that would otherwise have been redelivered cleanly.

pool_155_4.go
// Illustrative snippet — not a complete program
func (p *Pool) Drain(ctx context.Context) error {
    close(p.jobs)         // 1. stop pulling new work

    // 2. abandon the backlog -- unacked jobs will be redelivered
    drained := 0
    for range p.jobs {
        drained++
    }
    if drained > 0 {
        slog.Warn("abandoned queued jobs for redelivery",
            "count", drained)
        abandonedJobs.Add(int64(drained))
    }

    // 3. always wait for what is already running
    return waitFor(ctx, &p.wg)
}

Note what is not skipped: step 3. In-flight work is finished regardless, because that is the work with no other copy.

Note also that abandoning is reported. §15.1.1's fourth promise applies here specifically — a shutdown that silently drops forty queued jobs and one that drops none look identical from the outside unless you count them.

The ack discipline decides this, not the pool.

If your consumer acks on receipt rather than after processing, every row of the table above collapses into the first: nothing is redelivered, so the backlog is only in memory and abandoning it loses the jobs. The shape of your shutdown is determined by a decision made in the consumer, often by someone else, often years earlier. Find out which it is before choosing.

15.5.6 Cleanup That Must Outlive the Cancellation

Shutdown produces a situation Chapter 13 anticipated: the context is cancelled, and you still have work to do that needs a context.

A worker that has been told to stop still has to record what it did — flush a metric, write a checkpoint, release a lease. Every one of those is an outbound call that wants a context, and the only context in scope is cancelled.

worker_155_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: the checkpoint write is cancelled before it starts
func (w *Worker) run(ctx context.Context) {
    <-ctx.Done()
    w.store.Checkpoint(ctx, w.progress) // fails immediately
}

context.WithoutCancel (Go 1.21) is the answer, and §13.3.5 promised this chapter would use it:

worker_155_2.go
// Illustrative snippet — not a complete program
// ✓ detached from the cancellation, with a bound of its own
func (w *Worker) run(ctx context.Context) {
    <-ctx.Done()

    cleanupCtx, cancel := context.WithTimeout(
        context.WithoutCancel(ctx), 3*time.Second)
    defer cancel()

    if err := w.store.Checkpoint(cleanupCtx, w.progress); err != nil {
        slog.Error("checkpoint failed", "err", err)
    }
}

Measured, the three states that matter:

Terminal
parent cancelled, used directly a 120ms flush returns context.Canceled
                                  immediately, having sent nothing
WithoutCancel(parent) Err() is nil; the flush completes
WithoutCancel + WithTimeout(50ms) returns context deadline exceeded --
                                  the new bound applies, the old
                                  cancellation does not

The middle row is the fix and the bottom row is why it is only half a fix. Detaching without re-bounding trades cleanup that never runs for cleanup that never ends.

Two things carry over from the parent that you want: its values, so a trace ID or request ID still propagates into the cleanup call. And nothing else — which is the point.

A detached context is not an unbounded one.

WithoutCancel removes the deadline along with the cancellation, so a detached context with no timeout of its own can block until SIGKILL. Always pair it with WithTimeout, and make that timeout part of the budget division in §15.7 rather than a number chosen at the call site.

15.5.7 Pipelines Run in Reverse

A Chapter 7 pipeline shuts down the way §14.5.3 unwinds one, with the trigger moved from an error to a signal.

PIPELINE SHUTDOWN

A pipeline from a generator through stage A and stage B to a consumer. The generator closes its output, each stage’s range loop ends and its deferred close fires in turn, and closure cascades downstream to the consumer. Stopping the generator is the graceful form and everything after it drains in order and stops on its own; cancelling the context instead is the forceful form and abandons whatever was in flight.

Stopping the generator is the graceful form: it closes its output, each stage’s for range ends, each stage’s defer close(out) fires, and the values already in the pipeline flow through to the consumer. Nothing is dropped.

Cancelling the context is the forceful form, and it is the escalation — the same ladder as §15.4.4. It frees stages blocked on a send, which is what you need when a downstream stage has stopped reading, but everything in flight is abandoned.

The rule from §14.5 still holds and matters more here: every send needs a select on ctx.Done(), or a stage blocked on a send during shutdown never returns and your waitFor burns the whole budget waiting for it.

15.5.8 Common Mistakes

Cancelling the context instead of closing the intake
Problem

In-flight jobs abandoned mid-write

Fix

Close the intake; cancel only as escalation

Closing the intake while producers still send
Problem

panic: send on closed channel, during shutdown

Fix

An RWMutex spanning the send, not an atomic flag

Treating the backlog and in-flight work as one thing
Problem

Either budget burned on redeliverable jobs, or in-flight work dropped

Fix

Decide them separately

Abandoning the backlog silently
Problem

Forty lost jobs look exactly like zero

Fix

Count them, log them, emit a metric

Using the cancelled context for cleanup
Problem

Checkpoint and lease-release fail immediately

Fix

context.WithoutCancel plus a fresh timeout

A detached context with no deadline
Problem

Cleanup blocks until SIGKILL

Fix

Always pair WithoutCancel with WithTimeout

Unguarded sends in a pipeline stage
Problem

A stage never returns; the drain times out

Fix

select on ctx.Done() around every send

Summary: Draining Workers and Queues

“Drain” names three separate things: closing the intake, processing the backlog, and finishing work already in flight. The first and third are obligatory. The second is a policy decision, and conflating it with the third is the mistake.

In-flight work has no other copy — abandon it and its effects are half-applied. A backlog entry may or may not have another copy, and with at-least-once delivery and ack-after-processing it does: an unacked job goes back to the queue and is redelivered to an instance with a full budget instead of your remaining seconds. In that case draining the backlog is actively worse than abandoning it, and abandoning it must be counted and reported.

Cleanup at shutdown needs a context, and the one in scope is cancelled. context.WithoutCancel detaches from the cancellation while keeping values, and it must be paired with a fresh WithTimeout — detaching removes the deadline too, so an unbounded detached context blocks until SIGKILL.

Pipelines shut down by stopping the generator and letting closure cascade. Cancelling the context is the escalation, not the mechanism.

Self-Check Questions: Draining Workers and Queues

Your shutdown cancels the context that workers select on. What happens to a job halfway through a two-step database write?

It stops between the steps, and what that means depends entirely on whether those two writes were one transaction.

If they were, the transaction is never committed and the store rolls it back — the job’s effects vanish cleanly and, if it was unacked, it is redelivered. That is the good case, and it exists because someone made the two writes atomic, not because shutdown was graceful.

If they were not — two separate writes, or a write plus a message publish — the first has landed and the second never will. The system is now in a state no code path produces deliberately: a charge with no receipt, an order with no fulfilment record. Nothing rolls it back, because nothing knows it is incomplete.

The mechanism is wrong for the intent. Cancelling the context is the forceful stop; the graceful one is to close the intake so no new jobs start, then wait for running jobs to finish. Cancellation is the escalation for when that wait exceeds its budget — and then it is a deliberate loss you count and report, not an accident.

Why might draining the backlog be the wrong thing to do?

Because with at-least-once delivery, someone else will do it better than you can.

An unstarted job that you never ack goes back to the queue and is redelivered — to an instance that is not shutting down and has a full budget rather than the eight seconds you have left. Processing it yourself spends scarce time on work that was never at risk, and risks being SIGKILLed midway through a job that would otherwise have been redelivered cleanly.

It is the opposite decision when the backlog is only in memory, or when the consumer acks on receipt. Then there is no other copy and dropping it loses the job outright, so draining is the only way to keep the second promise.

The deciding factor is the ack discipline, which lives in the consumer and is usually not a decision the shutdown code gets to make. Find out which one you have before choosing.

Either way it must be reported. Abandoning forty queued jobs is a legitimate choice; abandoning them silently is not, because it is indistinguishable from abandoning none.

Why does this fail, and what does the fix have to include beyond WithoutCancel?

snippet_155_2.go
// Illustrative snippet — not a complete program
<-ctx.Done()
w.store.Checkpoint(ctx, w.progress)
The checkpoint uses the context that just told us to stop.

It fails because ctx is cancelled — the line above waited for exactly that. Any context-aware call using it returns immediately with context.Canceled, so the checkpoint never happens and the worker’s progress is lost.

context.WithoutCancel(ctx) detaches from the cancellation while keeping values, so a trace ID still propagates.

Measured with the parent cancelled, the detached child’s Err() is nil.

But detaching is not enough on its own, and this is the part that gets missed: WithoutCancel removes the deadline along with the cancellation. A detached context with no bound of its own can block until SIGKILL — the store is unreachable, the checkpoint hangs, and the whole shutdown budget goes with it.

So the fix is both:

snippet_155_3.go
// Illustrative snippet — not a complete program
cleanupCtx, cancel := context.WithTimeout(
    context.WithoutCancel(ctx), 3*time.Second)
defer cancel()

And that 3 seconds is not a free parameter. It comes out of the same total as the propagation wait and the drain, which is what §15.7 is about.

Key Takeaways

  • “Drain” is three things: close the intake, process the backlog, finish in-flight work — the first and third are obligatory, the second is policy
  • In-flight work has no other copy; abandoning it leaves effects half-applied
  • With at-least-once delivery and ack-after-processing, an unacked backlog entry is redelivered — so abandoning it is often better than spending your budget on it
  • Whichever you choose, count and report it: forty silently dropped jobs look exactly like zero
  • The ack discipline decides this, and it lives in the consumer, not in your shutdown code
  • Closing the intake is the sender’s job — stop producers first, or shutdown ends in a send on closed channel panic
  • close cannot be guarded by a select; an RWMutex spanning the send can, and an atomic flag cannot
  • context.WithoutCancel gives cleanup a context that outlives the cancellation while keeping values
  • It must be paired with WithTimeout: detaching removes the deadline too, and an unbounded cleanup blocks until SIGKILL
  • Pipelines drain by stopping the generator and letting closure cascade; cancelling is the escalation
Section 15.5 — in one line

Closing the intake is graceful and cancelling the context is forceful, and the question that decides everything else is whether the work you are about to abandon has another copy somewhere.

15.6 Coordinating Components

A real service is a handful of long-lived things — an HTTP server, a worker pool, a metrics exporter, a queue consumer — that start together, run together, and have to stop in an order determined by what depends on what. Chapter 14's errgroup is the natural tool, and it behaves differently here than it did there in one specific way that costs an entire shutdown budget.

15.6.1 Start Order Determines Stop Order

The dependency graph is not a matter of taste; it is fixed by construction, and §15.1.3's rule reads it directly.

ONE SERVICE, TORN DOWN INWARD

Four nested components shown by indentation with their start order on the left and their close order on the right. Config starts first and closes fourth, the database pool starts second and closes third, the worker pool starts third and closes second, and the HTTP server starts fourth and closes first. Each is nested inside the one above, so you close from the inside out: the HTTP server stops first because everything else outlives a request, and config closes last because everything read from it.

The one deliberate departure, from §15.2.6: a distributed lease is released early rather than in its dependency position, because everything after stop() can be cut off by a second signal and a lease held to its TTL is the most expensive thing to be holding when that happens. Order by dependency, then promote whatever is most costly to lose.

That promotion is a trade, not a free win, and it is worth naming: releasing a partition lease while workers still hold in-flight work for that partition lets a second owner start before the first has finished. Where the work is not idempotent, promote the release only as far as after the worker drain and before the pool close — still early enough to matter, late enough to keep the exclusion the lease was for.

15.6.2 The errgroup Return Value Means Something Else Here

Chapter 14 established the return-value decision: return err for fail-fast, return nil for collect-all (§14.4.5). For a batch of finite tasks that is the whole story.

For a set of long-running components it is not, and the difference is easy to miss because the code looks identical.

g_156.go
// Illustrative snippet — not a complete program
g, ctx := errgroup.WithContext(ctx)

g.Go(func() error { return runServer(ctx) })   // runs for hours
g.Go(func() error { return runWorkers(ctx) })  // runs for hours
g.Go(func() error { return runExporter(ctx) }) // runs for hours

return g.Wait()

The intent is: if any component stops, stop them all. The derived context should cancel and everyone should unwind.

That is not what return nil does. Measured, two components where the first exits at 80 ms and the second watches ctx.Done():

first component returns
nil
a sentinel error

errgroup only cancels the derived context when a closure returns a non-nil error (§14.4.8 — if err := f(); err != nil). A clean nil return means “I finished successfully”, and for a batch task that is exactly right. For a component that was supposed to run until told otherwise, it means “I am done, and everyone else keeps running forever.”

The exporter exits cleanly because its endpoint was closed. Nothing cancels. The server and the workers run on, g.Wait() blocks, and your shutdown budget expires waiting for components that were never told to stop.

15.6.3 Sentinel Errors for Component Exit

The fix is that in a lifecycle group, stopping is an event worth reporting, so every component returns a non-nil value when it stops:

err_component_156.go
// Illustrative snippet — not a complete program
var errComponentStopped = errors.New("component stopped")

g.Go(func() error {
    if err := runExporter(ctx); err != nil {
        return fmt.Errorf("exporter: %w", err)
    }
    return fmt.Errorf("exporter: %w", errComponentStopped)
})

Now an exporter that exits for any reason cancels the derived context, every sibling sees ctx.Done(), and g.Wait() returns promptly with something that names what happened.

The caller then distinguishes the expected stop from a real failure:

err_156.go
// Illustrative snippet — not a complete program
err := g.Wait()
switch {
case err == nil:
    // unreachable in a lifecycle group -- see below
case errors.Is(err, errComponentStopped):
    slog.Info("component stopped, shutting down", "err", err)
case errors.Is(err, context.Canceled):
    slog.Info("shutdown signalled")
default:
    slog.Error("component failed", "err", err)
    exitCode = 1
}

That first case is worth a comment in real code, because it documents an invariant: in a lifecycle group Wait returning nil means every component returned nil, which means every component stopped without telling anyone, which is the bug this section is about.

This inverts Chapter 14's advice, and deliberately.

§14.4.5 says return nil when you want every goroutine to run to completion — correct for a batch, where completion is the goal. A component’s completion is the anomaly. Same API, opposite default, because the two are answering different questions: “did this task succeed?” versus “is this component still running?”

15.6.4 The ErrServerClosed Trap

The component most likely to break this rule is the one from §15.4, because the natural way to write it is exactly wrong:

err_156_x_2.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: maps the stop signal to "nothing happened"
g.Go(func() error {
    err := srv.ListenAndServe()
    if errors.Is(err, http.ErrServerClosed) {
        return nil // <-- siblings now run forever
    }
    return err
})

§15.4.1 established that ErrServerClosed is not a failure, so filtering it out looks right — and in the select-based shape of §15.4.1 it is right, because the signal context is what drives shutdown there.

Inside a lifecycle group it is wrong. The server has stopped; that is the event the group exists to propagate. Map it to a sentinel instead:

err_156_3.go
// Illustrative snippet — not a complete program
// ✓ the server stopping is news, and the group should hear it
g.Go(func() error {
    err := srv.ListenAndServe()
    if errors.Is(err, http.ErrServerClosed) {
        return fmt.Errorf("http server: %w", errComponentStopped)
    }
    return fmt.Errorf("http server: %w", err)
})

15.6.5 The Full Shape

Components run in a group; the group’s context is the shutdown signal; teardown is ordered and happens after Wait returns.

run_156.go
// Illustrative snippet — not a complete program
func Run(ctx context.Context, app *App) error {
    ctx, stop := signal.NotifyContext(ctx,
        syscall.SIGINT, syscall.SIGTERM)
    defer stop() // §15.2.5 -- for the paths with no signal at all

    // §15.2.5 again, and this is the load-bearing half: hand the
    // signal back the moment it lands, not when the drain ends.
    // The drain is exactly when an operator reaches for Ctrl-C.
    go func() { <-ctx.Done(); stop() }()

    g, gctx := errgroup.WithContext(ctx)

    g.Go(func() error { return app.runServer(gctx) })
    g.Go(func() error { return app.runWorkers(gctx) })
    g.Go(func() error { return app.runExporter(gctx) })

    // Each component watches gctx and returns when it is done;
    // whichever stops first cancels the rest.
    runErr := g.Wait()

    // Teardown gets a budget of its own, detached from the
    // cancellation that got us here (§15.2.7).
    tctx, cancel := context.WithTimeout(
        context.WithoutCancel(ctx), app.cfg.ShutdownBudget)
    defer cancel()

    return errors.Join(runErr, app.teardown(tctx))
}

errors.Join (§14.2.3) is doing real work in that last line: a component may have failed and teardown may have timed out, and those are two different facts an operator needs. Returning only one of them is the same silent-loss problem §15.1.1's fourth promise names.

The teardown itself is a plain ordered sequence, not a group — because ordering is the entire requirement:

app_156.go
// Illustrative snippet — not a complete program
func (a *App) teardown(ctx context.Context) error {
    var errs []error
    // Innermost first, plus the lease promoted to the front.
    if err := a.lease.Release(ctx); err != nil {
        errs = append(errs, fmt.Errorf("lease: %w", err))
    }
    if err := a.workers.Drain(ctx); err != nil {
        errs = append(errs, fmt.Errorf("workers: %w", err))
    }
    if err := a.db.Close(); err != nil {
        errs = append(errs, fmt.Errorf("db: %w", err))
    }
    if err := a.telemetry.Flush(ctx); err != nil {
        errs = append(errs, fmt.Errorf("telemetry: %w", err))
    }
    return errors.Join(errs...)
}

Each step runs even if an earlier one failed, and every failure is collected. A database that will not close is not a reason to skip flushing telemetry — especially since that telemetry is how anyone will find out about the database.

15.6.6 When a WaitGroup Beats a Group

errgroup is the right tool when components can fail and one failure should stop the rest. It is the wrong tool when they cannot fail and you only need to know they are finished.

Waiting for hijacked WebSocket connections (§15.4.5) is that case: there is no error to collect, no cancellation to propagate, just a count reaching zero. A WaitGroup and the waitFor helper from §15.5.2 say that directly, and reaching for a group would add an error channel nobody reads.

The distinction is the same one §14.5.5 draws when it runs both at once: errgroup owns errors and cancellation across the stages; the WaitGroup tracks exactly the goroutines that send on one channel. errgroup manages lifecycles with failure; a WaitGroup counts.

15.6.7 Common Mistakes

return nil from a long-running component
Problem

Siblings run forever; budget expires in Wait

Fix

Return a sentinel — stopping is news

Mapping ErrServerClosed to nil inside a group
Problem

Same, and it looks like a correct filter

Fix

Map it to the component-stopped sentinel

Tearing down inside g.Go closures
Problem

Ordering is whatever the scheduler picks

Fix

Teardown after Wait, in an explicit sequence

Aborting teardown on the first error
Problem

Telemetry never flushed, so nobody learns why

Fix

Collect with errors.Join, run every step

Returning only the component error
Problem

A teardown timeout disappears

Fix

errors.Join(runErr, teardownErr)

Deriving the teardown budget from gctx
Problem

Already cancelled; teardown gets no time

Fix

context.WithoutCancel first (§15.2.7)

Using errgroup to count WebSocket closes
Problem

An error channel nobody reads

Fix

WaitGroup plus waitFor

Summary: Coordinating Components

Components stop in the reverse of the order they started, because that is what the dependency graph says — with one deliberate exception, the lease, promoted to the front because everything after stop() can be cut off.

errgroup is the right coordinator, and it means something different here than in Chapter 14.

Measured a component returning nil at 80 ms left its sibling running the full 700 ms and Wait returned nil; returning a sentinel stopped the sibling at 80 ms. errgroup cancels only on a non-nil return, so in a lifecycle group a clean exit means “I am done, and everyone else runs forever.”

Every component therefore returns a sentinel when it stops — including the HTTP server, whose ErrServerClosed is exactly the value you would naturally filter to nil.

Teardown happens after Wait, as an explicit ordered sequence rather than inside the group, on a budget detached from the cancellation that triggered it. Every step runs even if an earlier one failed, and the errors are joined, because the telemetry flush is how anyone learns about the database close that failed.

Self-Check Questions: Coordinating Components

Three components in an errgroup.WithContext. The metrics exporter’s endpoint closes and it returns nil. What happens?

Nothing, which is the problem.

errgroup cancels its derived context only when a closure returns a non-nil error (§14.4.8). A nil return means “this task succeeded”, so the group records nothing and cancels nothing. The server and the worker pool never learn the exporter is gone; they keep running, and g.Wait() blocks until they stop on their own.

Measured a sibling watching ctx.Done() ran the full 700 ms when the first component returned nil, against 80 ms when it returned a sentinel.

In production this is a service that is half-dead and does not know it — no metrics, full traffic, and a shutdown that will never begin because nothing has told it to. When a real SIGTERM finally arrives it lands on a group that is already in this state.

The fix is that in a lifecycle group, a component stopping is an event: return a non-nil sentinel so the cancellation propagates, and let the caller distinguish errComponentStopped from a genuine failure.

Why is this wrong inside a lifecycle group when §15.4.1 said ErrServerClosed is not a failure?

err_156_4.go
// Illustrative snippet — not a complete program
g.Go(func() error {
    err := srv.ListenAndServe()
    if errors.Is(err, http.ErrServerClosed) {
        return nil
    }
    return err
})
The filter is correct in one shape and wrong in this one.

Both statements are true; they are answering different questions.

§15.4.1's point is that ErrServerClosed must not be treated as a failure — it is what a successful shutdown returns, so log.Fatal on it kills the process on the success path. Filtering it there is right.

Inside a lifecycle group the question is not “did this fail?” but “is this component still running?”, and the answer is no. Mapping it to nil tells the group nothing happened, so nothing cancels and every sibling runs on — the §15.6.2 failure exactly.

Map it to the sentinel instead:

snippet_156.go
// Illustrative snippet — not a complete program
return fmt.Errorf("http server: %w", errComponentStopped)

Now the group cancels, siblings unwind, and errors.Is(err, errComponentStopped) at the top lets you log it as an expected stop rather than an error. Same value, two correct treatments, decided by which question the surrounding code is asking.

Why does teardown happen after g.Wait() rather than in deferred functions inside each g.Go closure?

Because ordering is the entire requirement, and closures give you none.

Goroutines in a group unwind concurrently in whatever order the scheduler and their own blocking produce. Put defer db.Close() in one closure and defer workers.Drain() in another and you have expressed no relationship between them — the pool may close while workers are mid-query, which is §15.1.3's rule violated by omission rather than by mistake.

Running teardown after Wait returns gives you a single-threaded sequence you can read top to bottom, which is what an ordering constraint needs.

It also fixes the budget. A defer inside a closure runs on a context that has already been cancelled — that is why the closure is returning — so any context-aware cleanup there fails immediately (§15.5.6). After Wait, you can build a fresh budget with context.WithoutCancel and give teardown real time.

Key Takeaways

  • Stop order is start order reversed; promote the lease to the front because everything after stop() can be cut off
  • A component returning nil left its sibling running 700 ms instead of 80 — errgroup cancels only on a non-nil error
  • In a lifecycle group, a component stopping is news: return a sentinel, always
  • ErrServerClosed is the trap, because filtering it to nil is correct in §15.4's shape and wrong inside a group
  • Wait returning nil from a lifecycle group is itself the bug — nobody told anyone they stopped
  • Teardown belongs after Wait, as an explicit sequence, not in deferred functions inside closures
  • Run every teardown step even if one fails, and join the errors — the telemetry flush is how anyone learns about the rest
  • Derive the teardown budget from context.WithoutCancel; the group’s context is already cancelled
  • errgroup manages lifecycles with failure; a WaitGroup counts
Section 15.6 — in one line

In a batch, return nil means success; in a lifecycle group it means “I have stopped and told nobody” — and the component most likely to say it is the HTTP server you were about to filter.

15.7 When the Budget Expires

Everything so far assumed the budget was enough. Sometimes it is not: a connection will not close, a queue will not drain, a downstream call hangs. This section is about dividing the budget deliberately, escalating when a phase overruns, and making sure that what you abandoned is visible afterwards — §14.7's degradation lens applied to exit.

15.7.1 Dividing the Budget

The platform gives you one number (§15.2.7). Four phases have to fit inside it, and if you do not divide it explicitly the first phase takes all of it.

DIVIDING A 30-SECOND GRACE PERIOD

A thirty-second bar divided into four phases with tick marks at zero, five, twenty-five, twenty-eight and thirty seconds: propagation covered by section 15.3, drain covered by sections 15.4 and 15.5, release covered by section 15.6, and headroom before SIGKILL. Every phase gets its own deadline carved from the total, and the headroom at the end is for reporting, because a shutdown killed before it can log why it failed has broken the fourth promise.

In code that is one budget and a series of sub-deadlines:

phase_157.go
// Illustrative snippet — not a complete program
const grace = 30 * time.Second // must match the platform's setting

total, cancel := context.WithTimeout(
    context.WithoutCancel(sigCtx), grace-2*time.Second) // headroom
defer cancel()

phase := func(d time.Duration) (context.Context, context.CancelFunc) {
    return context.WithTimeout(total, d)
}

Each phase then takes phase(5*time.Second) or whatever its share is, and each one is bounded twice — by its own share and by the total. A phase that finishes early gives its unused time back to the phases after it, because they are bounded by total as well.

Derived the drain’s real allowance is smaller than the diagram suggests. From 30 seconds, subtract 2 s of reporting headroom, 5 s of propagation (§15.3.4) and 3 s for release, and 20 s is left — but Shutdown can spend up to its 500 ms poll cap doing nothing after the last handler has returned (§15.7.3), so the honest figure is nearer 19.5 s. The half-second is not worth chasing; knowing it is there is what stops you setting the drain share to exactly what remains.
A sub-deadline is not the same as a share.

context.WithTimeout(total, 20*time.Second) for the drain does not reserve 20 seconds; it caps the drain at 20 or at whatever remains of total, whichever is smaller. That is the behaviour you want, and it means the shares should sum to slightly less than the total rather than exactly to it — otherwise the last phase routinely gets nothing.

15.7.2 The Escalation Ladder

When a phase overruns, you escalate rather than give up. Each rung is more forceful and loses more.

THE LADDER

Four rungs of escalation, each more forceful and each losing more. Rung one calls Shutdown and waits politely, losing nothing; when the budget expires, rung two calls cancelBase with a cause to tell handlers to stop, losing in-flight responses; if they are still not gone, rung three calls Close and drops the connections; rung four reports what was lost and exits non-zero, losing nothing more. Never skip a rung, and never fire rung two alongside rung one.

In code:

err_157.go
// Illustrative snippet — not a complete program
err := srv.Shutdown(drainCtx)
if err == nil {
    return nil // rung 1 was enough
}

slog.Warn("drain did not finish in budget", "err", err)
drainTimeouts.Inc()

// Rung 2: now, and only now, tell handlers to stop.
cancelBase(fmt.Errorf("shutdown budget expired: %w", err))

// Give them a moment to notice and unwind cleanly.
reap, cancel := context.WithTimeout(total, 2*time.Second)
defer cancel()
if err := srv.Shutdown(reap); err == nil {
    return nil
}

// Rung 3: drop what is left.
return srv.Close()

The second Shutdown after cancelling the base context is the rung most implementations skip, and it is nearly free. Handlers that check their context return within milliseconds of being told to, so this usually succeeds — and the difference between a handler returning early and a connection being dropped underneath it is the difference between a client seeing a 503 it can retry and a client seeing a reset it cannot interpret.

15.7.3 The Cost of the Poll

§15.4.8 noted that Shutdown polls with a backoff. The consequence belongs here, in the budget, because it is time you pay for nothing.

The interval starts at 1 ms and doubles to a 500 ms cap, and the backoff restarts on each Shutdown call. So the longer a drain runs, the coarser the polling gets — and the longer the gap between the last handler actually finishing and Shutdown noticing.

Measured, the lag between the handler returning and Shutdown returning, over eight runs each. Shutdown is called the moment the handler starts, which is what fixes how far the backoff has advanced by the time the handler ends — call it later and every row shrinks:

handler ran for
10 ms
20 ms
100 ms
500 ms
2 s
3 s

The short rows are tight and reproducible; the long ones swing by a factor of two because more doublings mean more accumulated jitter, and a run outside these bounds on a busier machine would not be surprising. Treat the trend as the finding and the individual numbers as approximate — the shape is what matters: a slow request makes your shutdown slower even after that request has finished.

At the top of the table this is a rounding error. For a service whose p99 request is several seconds, up to a fifth of a second of pure dead time per Shutdown call is worth knowing about when you are dividing a budget into phases — and it is an argument for not calling Shutdown more times than the ladder requires.

15.7.4 What to Abandon

When the budget will not stretch, the question is not whether to lose something but what. §15.5.3's table decides most of it:

Work
Unacked queue backlog
Idle connections
In-flight requests
A held lease
Unflushed telemetry

The last two rows are why §15.6.5 puts the lease first and the telemetry flush last but unconditional. Everything else can be sacrificed to them.

15.7.5 When Shutdown Hangs

Every rung of the ladder assumes something is making progress. Sometimes nothing is, and the process sits there until the grace period expires and SIGKILL decides. A hung shutdown is a §10.2 deadlock with worse timing: it happens on deploys, under load, and rarely on a machine you can attach a debugger to.

Three causes account for most of them, and this chapter has already named all three:

A stage blocked on a send with nobody receiving. §14.5's rule at shutdown: a pipeline stage whose consumer has gone parks on out <- v forever unless the send is guarded by select on ctx.Done(). Shutdown returns; wg.Wait() never does.

A wg.Wait() for a goroutine that took an early exit. A worker that returned before its Done was registered, or a path that skips it, leaves the counter permanently above zero — §2.3's rule with a shutdown-shaped symptom.

A component waiting on one already released. §15.6's ordering, reversed: the consumer is draining and the pool it needs is closed, so the drain cannot finish.

The diagnostic is §10.4's, and it works on a running process with no instrumentation:

Terminal
$ kill -QUIT <pid>
Measured SIGQUIT dumps every goroutine to stderr and exits with status 2. The raw dump is verbose — it opens with runtime frames carrying gp=, m=, fp= and pc= addresses — so the useful move is to skip to the frames naming your own package:
Terminal
SIGQUIT: quit
PC=0x7ff80acf77ce m=0 sigcode=0
...
goroutine 1 [sync.WaitGroup.Wait]:
sync.(*WaitGroup).Wait(...)
main.main()
    /app/main.go:17 +0x6f
...
goroutine 6 [sleep]:
time.Sleep(...)
main.stuckDrain(...)
    /app/main.go:10 +0x49
created by main.main in goroutine 1

Two goroutines and the whole answer between them: main is in wg.Wait, and the thing it waits for is asleep in stuckDrain. In a real dump there will be hundreds, most of them runtime and GC workers you can skip. The ones that matter carry a blocking state in the brackets — sync.WaitGroup.Wait, sync.Mutex.Lock, chan send, chan receive, select, semacquire — and, for a module declaring go 1.27 or later, any runtime/pprof labels the goroutine carries, printed after the bracket as {request: "…"}, which is what makes Chapter 19’s per-request labels useful in a dump — which is the same reading §10.4 teaches, applied to a process that is trying to leave.

Make the dump reachable before you need it.

SIGQUIT works everywhere, but in a container the process is PID 1 and you may have no shell to send it from. Two cheap alternatives: set GOTRACEBACK=all so a crash prints every goroutine rather than only the failing one, and expose net/http/pprof on an internal port, which serves /debug/pprof/goroutine?debug=2 — the same information, over HTTP, without killing the process. Wire the second on the day you deploy, not the day it hangs.

The structural fix is §15.7.1's: every phase bounded by a context derived from one deadline. A phase that cannot exceed its slice cannot hang the shutdown — it fails, gets logged, and the next phase starts. A hang means some phase has no bound, and finding which one is the same question as reading the dump.

15.7.6 Making the Loss Visible

A shutdown that abandoned work and exited zero is indistinguishable from one that abandoned nothing. §14.7.6's three signals apply unchanged, one for each audience:

shutdown_report_157.go
// Illustrative snippet — not a complete program
type ShutdownReport struct {
    Graceful       bool
    Duration       time.Duration
    RequestsInFlight int // abandoned mid-request
    JobsAbandoned    int // backlog left for redelivery
    Errors         []error
}

func report(r ShutdownReport) int {
    slog.Info("shutdown complete",
        "graceful", r.Graceful,
        "duration", r.Duration,
        "requests_abandoned", r.RequestsInFlight,
        "jobs_abandoned", r.JobsAbandoned,
        "errors", errors.Join(r.Errors...))

    shutdownDuration.Observe(r.Duration.Seconds())
    requestsAbandoned.Add(float64(r.RequestsInFlight))

    if !r.Graceful {
        ungracefulShutdowns.Inc()
        return 1 // the exit code is a signal too
    }
    return 0
}

Three audiences, three signals: the exit code for the orchestrator and anything scripting a rollout, the structured log for whoever investigates, the metric for the dashboard that notices a trend nobody was investigating.

The exit code is the one most often left at zero. An orchestrator that sees a clean exit has no reason to slow a rollout down — so a deploy that abandons a few requests per pod looks perfectly healthy right up until it is doing it a thousand times a minute.

Two counters that pay for themselves.

shutdown_duration_seconds tells you how much of the grace period you actually use, which is the only honest way to know whether the budget is right — if it is consistently at 95% you are one slow request away from SIGKILL. And requests_abandoned_total should be zero in steady state, so any non-zero value is a real signal rather than noise to be thresholded.

15.7.7 Proving It Works

Shutdown code runs rarely, is timing-dependent, and fails silently — which is the exact profile of code that is never tested. Two techniques make it testable, and Chapter 16 covers the general versions.

Count goroutines around the operation. The leak that shutdown produces is a goroutine that outlives the process’s intent, and counting before and after is enough to catch it deterministically. That is what this chapter’s exercise does in its fourth gate, and what Chapter 14's exercise did.

Use virtual time for deadline behaviour. testing/synctest went stable in Go 1.25 and makes a test with a 30-second budget run instantly and deterministically.

Measured 30 seconds of virtual time elapsed in 0.00 s of wall clock inside a bubble.

There is a limitation worth knowing before you build a test suite on it.

Measured a real net/http server (httptest.NewServer, a real loopback listener) inside a synctest.Test bubble never advances — the test was still blocked after 5 seconds of real time. Virtual time only moves when every goroutine in the bubble is durably blocked, and network I/O parks on the runtime poller, which does not qualify.

So synctest is the right tool for a shutdown sequencer expressed in channels and contexts, and the wrong tool for anything holding a socket. Since Go 1.27 that boundary has moved for HTTP specifically: httptest.NewTestServer(t, h) serves the handler over an in-memory network rather than a real socket, so a client–server round trip can run inside a bubble on virtual time; Chapter 16 (§16.4.8) covers it. This chapter’s exercise keeps a real net.Listener, so it still uses real short timeouts and goroutine counting. Chapter 16 takes the technique further.

15.7.8 Common Mistakes

One budget for the whole sequence
Problem

The first phase consumes all of it

Fix

A sub-deadline per phase, all bounded by the total

Shares summing to exactly the total
Problem

The last phase reliably gets nothing

Fix

Sum to less; leave headroom for reporting

Skipping rung 2 of the ladder
Problem

Connections dropped that would have returned in ms

Fix

Cancel the base context, then Shutdown again briefly

Calling Shutdown repeatedly in a loop
Problem

Poll backoff restarts each time; pure dead time

Fix

Escalate through the ladder instead

Exiting zero after abandoning work
Problem

A bad rollout looks perfectly healthy

Fix

Non-zero exit, a log line and a metric

No shutdown_duration metric
Problem

No way to know the budget is nearly exhausted

Fix

Observe it; alert above ~80% of the grace period

A phase with no deadline of its own
Problem

One stall hangs the whole shutdown until SIGKILL

Fix

Bound every phase; kill -QUIT to find which one

synctest around a real HTTP server
Problem

The bubble never advances; the test hangs

Fix

Virtual time for sequencers, real timeouts for real sockets — or httptest.NewTestServer (Go 1.27) for HTTP

Summary: When the Budget Expires

The platform gives one number and four phases must fit inside it. Divide it explicitly with a sub-deadline per phase, all bounded by the total so unused time flows forward, and leave headroom at the end — a shutdown killed before it can log why it failed has broken the fourth promise.

When a phase overruns, escalate rather than give up: wait politely, then tell handlers to stop, then drop the connections, then report. The rung most often skipped is the second Shutdown after cancelling the base context, and it is nearly free — handlers that check their context return in milliseconds, which is the difference between a client seeing a retryable 503 and an uninterpretable reset.

Shutdown polls, and the poll costs real time.

Measured the lag between the last handler finishing and Shutdown noticing grows from 6–7 ms after a 10 ms request to 111–217 ms after a 3 s one. The trend is the finding: a slow request makes shutdown slower even after it ends.

What to abandon is decided by whether the work has another copy. Unacked backlog goes first because it will be redelivered; a held lease and unflushed telemetry go last because losing them is more expensive than anything they were competing with.

And all of it has to be reported — exit code, log, metric — because a shutdown that quietly abandoned work is indistinguishable from one that did not.

Self-Check Questions: When the Budget Expires

Your 30-second budget is a single context.WithTimeout passed to every phase. What goes wrong?

The first phase that misbehaves takes the whole thing, and every phase after it gets nothing.

With one shared deadline there is no back-pressure between phases. A drain that hangs on one connection runs for the full 30 seconds, Shutdown returns context deadline exceeded with a second to spare, and then the lease release, the pool close and the telemetry flush all fail instantly against an expired context. You lose the cheap, important things because an expensive, less important thing overran.

Sub-deadlines fix it: give the drain a 20-second cap of its own, and when it overruns you still have ten seconds for everything after it. Because each phase is bounded by both its own share and the total, a phase that finishes early hands its unused time forward automatically.

And the shares should sum to slightly less than the total, so the reporting step at the end is not the one that gets squeezed — that step is how anyone learns any of this happened.

Why cancel the base context and call Shutdown again, rather than going straight to Close()?

Because the two produce very different results for the client, and the extra rung costs almost nothing.

Close() drops connections immediately. A client mid-response gets a reset, which it cannot distinguish from a network failure — so it does not know whether the work happened, and if the operation was not idempotent it cannot safely retry.

Cancelling the base context tells handlers to stop. A handler that checks its context returns within milliseconds, and can return a 503 with a Retry-After — a response the client can interpret and act on. Then the second Shutdown reaps those now-finished connections cleanly.

The cost is one short extra wait, typically a few milliseconds, since you only reach this rung for handlers that were already ignoring their deadline. The gain is that requests which had 50 ms left finish properly instead of being cut.

Close() is still the rung after that, for handlers that never check anything. The ladder exists so that each client gets the best outcome its handler’s behaviour allows.

Your service’s p99 request is 3 seconds. What does §15.7.3 tell you about the drain phase?

That roughly a fifth of a second of the drain is pure dead time, and it is worth budgeting for.

Shutdown's poll interval starts at 1 ms and doubles to a 500 ms cap, so a long drain ends up polling coarsely. Measured after a 3-second handler, Shutdown returned 111–217 ms after the handler had already finished — waiting on nothing.

At the scale of a 30-second budget that is not alarming. It matters in two ways. It is a floor on how quickly the drain phase can report success, so a drain sub-deadline of 200 ms for a service like this is not meaningful. And it argues against calling Shutdown in a loop or a retry, since the backoff restarts each time and you pay the ramp again.

The escalation ladder already respects this: it calls Shutdown, escalates, then calls it once more briefly — three calls at most, not a poll of your own on top of the standard library’s.

Worth noting the numbers swing — 78–148 ms at 2 seconds across runs — because of jitter in the backoff. The trend is solid; the individual figures are not something to compute against.

Shutdown abandoned 12 requests and the process exited 0. Who finds out?

Nobody, which is the failure.

A zero exit code tells the orchestrator the shutdown succeeded, so a rolling deploy proceeds at full speed. The requests are gone; their clients saw resets and cannot tell whether the work happened. No alert fires, because nothing was reported. And the deploy that abandons twelve requests per pod looks exactly like the deploy that abandons none, right up to the scale where users notice.

Three signals fix it, one per audience. A non-zero exit code, so the orchestrator can slow or halt the rollout. A structured log line naming the count and the phase that overran, so whoever investigates has somewhere to start. And a requests_abandoned_total counter, which should be zero in steady state and is therefore worth alerting on directly rather than thresholding.

This is §14.7.6's visibility rule with the exit code added, and the exit code is the one usually forgotten — it is the only one of the three that anything automated is already watching.

Key Takeaways

  • The platform gives one number; divide it into per-phase sub-deadlines all bounded by the total, so unused time flows forward
  • Shares should sum to less than the total, leaving headroom to report — a shutdown killed before it can log has broken the fourth promise
  • Escalate rung by rung: Shutdown, cancel the base, Shutdown briefly again, Close, report
  • The second Shutdown after cancelling is nearly free and turns an uninterpretable reset into a retryable 503
  • Poll lag grows from 6–7 ms after a 10 ms request to 111–217 ms after a 3 s one — a slow request slows shutdown after it ends
  • A hung shutdown is a deadlock with worse timing: kill -QUIT dumps every goroutine and exits 2, and pprof gives the same view without killing anything
  • Abandon by whether the work has another copy: unacked backlog first, leases and telemetry never
  • Report through all three channels, and do not forget the exit code — it is the one automation already watches
  • synctest gives deterministic virtual time for sequencers, but measured, a real HTTP server inside a bubble never advances (a real socket; Go 1.27’s in-memory httptest.NewTestServer is the exception — §16.4.8)
Section 15.7 — in one line

You cannot always finish, so decide in advance what you will drop, escalate in steps so each client gets the best outcome its handler allows, and make sure the exit code says what happened.

Chapter Summary

Shutdown is every pattern in this book run backwards, inside a window someone else opened. That framing carries the whole chapter: the reversal decides the order, and the borrowed clock decides that every phase needs a share of a budget rather than a timeout of its own.

Graceful is four separate promises — refuse new work cleanly, finish what was accepted, release resources safely, report the outcome — and most implementations keep the first two. The mechanism you would reach for first is guaranteed not to help: SIGTERM with no handler exits 143 and runs no deferred function at all.

The signal is a context. signal.NotifyContext retires the channel dance and the unbuffered-channel bug that go vet already catches, and it names itself through context.Cause — which is Chapter 13's WithCancelCause arriving for the third time. The idiom everyone writes, defer stop(), absorbs every later signal and takes away the operator’s escape hatch; calling stop() first thing gives it back, at the price of your remaining defers. And a signal is only one of four ways the same shutdown gets started, which is why the path behind it is a sync.Once and not a bare function.

Then the sequence, and its one counter-intuitive step. Deregistration and the signal are concurrent, so traffic is still arriving when you learn you are leaving.

Measured closing listeners immediately refused 168–180 requests against 80–88 served, where waiting out the propagation window refused none.

Server.Shutdown has four holes, all documented and all measurable. Serve returns ErrServerClosed at 0 ms while draining continues. Shutdown's context bounds how long you wait, not how long a handler runs — it never reaches r.Context(). Hijacked connections are outside it entirely. And when it times out it closes nothing. Before any of that, SetKeepAlivesEnabled(false) is what turns a warm connection into an idle one Shutdown can close. BaseContext fixes the second hole, and is a footgun without an ordering rule: fired alongside Shutdown it produces a 200 OK with a silently truncated body and no error anywhere.

Draining is three things, not one, and whether to drain the backlog depends on whether the work has another copy. errgroup means something different for components than for tasks: measured, a nil return left a sibling running 700 ms instead of 80, because a clean exit says “I finished” when it should say “I stopped”. And when the budget expires you escalate rung by rung, then report — through the log, the metric, and the exit code that automation is already watching. When it does not expire because nothing is moving at all, kill -QUIT turns the hang back into a Chapter 10 deadlock you can read.

Chapter Connections

How Chapter 15 connects
Chapter 2
§2.4's “how does this goroutine exit?” asked once per goroutine; §15.6 asks it of every component at once, in order
Chapter 3
Sender-closes decides who may close a worker pool’s intake — and §15.5.2 is where getting it wrong panics during shutdown
Chapter 4
Every drain loop here is §4.2's select, and §4.3's “run until killed” is what this chapter finally kills properly
Chapter 5
§15.2.1's buffered signal channel is §5.2's rule at its sharpest: the value you cannot afford to drop arrives exactly once
Chapter 7
§15.5.7 runs the pipeline backwards — stop the generator and let closure cascade
Chapter 8
waitFor's closer goroutine is safe for §8.4's reason, and close on a channel never blocks
Chapter 10
A drain that never finishes is a §10.2 deadlock with a deadline attached, and the goroutine dump still tells you which
Chapter 12
errgroup's first-error store is a sync.Once, which is why §15.2.5's stop() cannot overwrite the cause
Chapter 13
NotifyContext is WithCancelCause; BaseContext is the context tree rooted at the process; WithoutCancel is §13.3.5 finally cashed
Chapter 14
errgroup lifecycles (§15.6), the escalation ladder as §14.7's degradation applied to exit, and errors.Join for teardown
Chapter 16
Shutdown is the motivating case for deterministic testing — and synctest's limit with sockets is measured in §15.7.7

Final Checklist

Before moving to Chapter 16, ensure you can:

Exercise 15.1 — Wait for the Drain, and Tell the Handlers

Your move

Wait for the Drain, and Tell the Handlers

This service is correct in every way the earlier chapters taught you to check. It compiles, go vet is clean, and go test -race finds nothing — the report crosses goroutines on a buffered channel, so there is no data race here to find.

It still returns while requests are in flight, and it never tells a handler that its time is up.

The first bug is the one the standard library documents by name. srv.Serve returns http.ErrServerClosed the moment Shutdown begins — measured at 0 ms against a 700 ms drain (§15.4.1). This code treats that as “shutdown finished” and returns a report saying Graceful: true. In a real program main returns on that line and the process exits with requests still running. The drain goroutine is doing exactly the right thing; nobody waits for it.

The second is subtler and is the chapter’s centrepiece. When the drain budget expires, Shutdown gives up and returns context deadline exceeded — and the handler is never told. Its r.Context().Err() is still nil (§15.4.3). The context you hand Shutdown bounds how long you wait, not how long the handler runs, and it never reaches the request tree at all.

ch15/shutdown.go
// Package ch15 is the exercise for Chapter 15: Graceful Shutdown.
//
// Serve runs an HTTP service until ctx is cancelled, then shuts it
// down. It is meant to hold three promises:
//
//   - Serve does not return until in-flight requests have finished
//   - when the drain budget expires, in-flight handlers are told, so
//     they can stop cleanly instead of being cut off mid-response
//   - a request that fits inside the budget is never disturbed
//
// TODO(reader): this compiles, vets clean, and has no data race. It
// keeps the third promise and breaks the first two. Two tests prove
// it. Each needs a different fix, and neither fix repairs the other.
package ch15

import (
	"context"
	"errors"
	"net"
	"net/http"
	"time"
)

// Report describes how a shutdown went.
type Report struct {
	// Graceful is true when every in-flight request finished
	// inside the drain budget.
	Graceful bool
	// Drain is how long the drain actually took.
	Drain time.Duration
}

// Service is an HTTP service and the budget its shutdown may use.
type Service struct {
	Handler     http.Handler
	DrainBudget time.Duration
}

// Serve serves on ln until ctx is cancelled, then shuts down and
// returns a Report describing how that went.
func (s *Service) Serve(
	ctx context.Context, ln net.Listener,
) (Report, error) {
	srv := &http.Server{Handler: s.Handler}

	drained := make(chan Report, 1)
	go func() {
		<-ctx.Done()
		start := time.Now()
		drainCtx, cancel := context.WithTimeout(
			context.WithoutCancel(ctx), s.DrainBudget)
		defer cancel()
		err := srv.Shutdown(drainCtx)
		drained <- Report{
			Graceful: err == nil, Drain: time.Since(start)}
	}()

	err := srv.Serve(ln)
	if errors.Is(err, http.ErrServerClosed) {
		return Report{Graceful: true}, nil
	}
	return Report{}, err
}

The four gates it has to satisfy:

ch15/shutdown_test.go
package ch15

import (
	"context"
	"io"
	"net"
	"net/http"
	"runtime"
	"sync/atomic"
	"testing"
	"time"
)

func listen(t *testing.T) net.Listener {
	t.Helper()
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	return ln
}

// Gate 1: Serve must not return until in-flight requests have finished.
func TestServeWaitsForInFlightRequests(t *testing.T) {
	const work = 400 * time.Millisecond
	var handlerEnd atomic.Int64

	svc := &Service{
		DrainBudget: 3 * time.Second,
		Handler: http.HandlerFunc(func(
			w http.ResponseWriter, r *http.Request,
		) {
			time.Sleep(work)
			handlerEnd.Store(time.Now().UnixNano())
			io.WriteString(w, "done")
		}),
	}
	ln := listen(t)
	ctx, cancel := context.WithCancel(context.Background())

	type out struct {
		rep Report
		at  time.Time
	}
	res := make(chan out, 1)
	go func() {
		rep, err := svc.Serve(ctx, ln)
		if err != nil {
			t.Error(err)
		}
		res <- out{rep, time.Now()}
	}()

	go http.Get("http://" + ln.Addr().String())
	time.Sleep(100 * time.Millisecond) // request is now in flight
	cancel()

	got := <-res
	end := time.Unix(0, handlerEnd.Load())
	handlerLabel := "(never finished)"
	if handlerEnd.Load() != 0 {
		handlerLabel = end.Format("15:04:05.000")
	}
	if handlerEnd.Load() == 0 || got.at.Before(end) {
		t.Fatalf("Serve returned with a request still running\n"+
			"  Serve returned at   %v\n"+
			"  handler finished at %v\n\n"+
			"  Serve returns ErrServerClosed the moment Shutdown\n"+
			"  starts, not when it finishes. In a real program main\n"+
			"  returns here and the process exits with the request\n"+
			"  still in flight. Wait for the drain, not for Serve.",
			got.at.Format("15:04:05.000"), handlerLabel)
	}
}

// Gate 2: when the budget expires, handlers must be told.
func TestHandlersAreToldWhenBudgetExpires(t *testing.T) {
	var told atomic.Bool
	started := make(chan struct{})
	returned := make(chan struct{})

	svc := &Service{
		DrainBudget: 200 * time.Millisecond,
		Handler: http.HandlerFunc(func(
			w http.ResponseWriter, r *http.Request,
		) {
			defer close(returned)
			close(started)
			select {
			case <-r.Context().Done():
				told.Store(true)
			case <-time.After(2 * time.Second):
			}
		}),
	}
	ln := listen(t)
	ctx, cancel := context.WithCancel(context.Background())

	done := make(chan Report, 1)
	go func() { rep, _ := svc.Serve(ctx, ln); done <- rep }()
	go http.Get("http://" + ln.Addr().String())
	<-started
	cancel()

	rep := <-done
	// The handler writes told from its own goroutine and the report
	// can arrive first, so wait for the handler to return before
	// reading it. Bounded: the handler's own select gives up at 2s.
	<-returned
	if !told.Load() {
		t.Fatalf("the handler was never told the budget expired\n"+
			"  report: graceful=%v drain=%v\n\n"+
			"  Shutdown's context bounds how long you WAIT, not\n"+
			"  how long the handler RUNS -- it never reaches\n"+
			"  r.Context() at all. Give the server a BaseContext\n"+
			"  you hold, and cancel it when the budget expires.",
			rep.Graceful, rep.Drain.Round(time.Millisecond))
	}
	if rep.Graceful {
		t.Errorf("report says graceful, but the budget expired")
	}
}

// Gate 3: a request that fits inside the budget must NOT be disturbed.
// This fails if you cancel the base context alongside Shutdown.
func TestRequestsInsideBudgetAreNotCutShort(t *testing.T) {
	var cutShort atomic.Bool
	started := make(chan struct{})

	svc := &Service{
		DrainBudget: 2 * time.Second,
		Handler: http.HandlerFunc(func(
			w http.ResponseWriter, r *http.Request,
		) {
			close(started)
			select {
			case <-r.Context().Done():
				cutShort.Store(true)
				return
			case <-time.After(300 * time.Millisecond):
			}
			io.WriteString(w, "complete")
		}),
	}
	ln := listen(t)
	ctx, cancel := context.WithCancel(context.Background())

	body := make(chan string, 1)
	go func() {
		resp, err := http.Get("http://" + ln.Addr().String())
		if err != nil {
			body <- "ERR:" + err.Error()
			return
		}
		defer resp.Body.Close()
		b, _ := io.ReadAll(resp.Body)
		body <- string(b)
	}()

	done := make(chan Report, 1)
	go func() { rep, _ := svc.Serve(ctx, ln); done <- rep }()
	<-started
	cancel()
	<-done

	if got := <-body; got != "complete" || cutShort.Load() {
		t.Fatalf("a request that fitted the budget was cut short\n"+
			"  client received %q\n\n"+
			"  Cancelling the base context ALONGSIDE Shutdown\n"+
			"  aborts handlers that would have finished. It is\n"+
			"  the escalation AFTER the budget expires, not\n"+
			"  something you fire at the same time.",
			got)
	}
}

// Gate 4: the whole cycle must leave no goroutine behind. The drain
// runs in one, and a report nobody receives parks it forever.
func TestServeLeavesNoGoroutineBehind(t *testing.T) {
	before := runtime.NumGoroutine()
	started := make(chan struct{})

	svc := &Service{
		DrainBudget: 2 * time.Second,
		Handler: http.HandlerFunc(func(
			w http.ResponseWriter, r *http.Request,
		) {
			close(started)
			time.Sleep(300 * time.Millisecond)
		}),
	}
	ln := listen(t)
	ctx, cancel := context.WithCancel(context.Background())

	done := make(chan Report, 1)
	go func() { rep, _ := svc.Serve(ctx, ln); done <- rep }()
	go http.Get("http://" + ln.Addr().String())
	<-started
	cancel()
	<-done

	// The client's own goroutines settle on their own schedule, so
	// poll rather than reading the count once.
	http.DefaultClient.CloseIdleConnections()
	var after int
	for i := 0; i < 100; i++ {
		if after = runtime.NumGoroutine(); after <= before {
			return
		}
		time.Sleep(10 * time.Millisecond)
	}
	t.Fatalf("a goroutine outlived the shutdown\n"+
		"  before=%d after=%d\n\n"+
		"  The drain runs in a goroutine that ends by sending\n"+
		"  its report. If nothing receives that send, it parks\n"+
		"  there for the life of the process.",
		before, after)
}

Run it:

Terminal
$ cd labs/go-concurrency/code/ch15
$ go test -race ./...

Two of the four fail, the same way every time. Gate 3 passes because the starter never cancels anything, and gate 4 passes because the abandoned drain does eventually finish on its own — both of them are guarding against fixes, not against the starter:

Terminal
--- FAIL: TestServeWaitsForInFlightRequests (0.10s)
    shutdown_test.go:65: Serve returned with a request still running
          Serve returned at 19:01:00.907
          handler finished at (never finished)
          Serve returns ErrServerClosed the moment Shutdown
          starts, not when it finishes. In a real program main
          returns here and the process exits with the request
          still in flight. Wait for the drain, not for Serve.
--- FAIL: TestHandlersAreToldWhenBudgetExpires (2.00s)
    shutdown_test.go:111: the handler was never told the budget expired
          report: graceful=true drain=0s
          Shutdown's context bounds how long you WAIT, not
          how long the handler RUNS -- it never reaches
          r.Context() at all. Give the server a BaseContext
          you hold, and cancel it when the budget expires.
FAIL
FAIL corebackend.dev/go-concurrency/ch15 3.258s
FAIL
Done when: go test -race ./... in code/ch15/ reports ok for all four tests, and keeps reporting it under -count=10.
Two traps, and they are not symmetric. Waiting for the drain makes the first gate pass, and the handler is still never told — gate 2 alone still fails. But wiring BaseContext without waiting for the drain fails all three of the first gates, which is the more interesting result: Serve still returns at 0 ms, so nothing is left running to perform the escalation, and the handler is not told after all. The second fix does nothing until the first one exists. Order matters here in the same way it does in the sequence itself.

The third gate is the interesting one, and it exists to catch the fix that looks right. Having discovered BaseContext, the natural move is to cancel it when shutdown starts — which aborts every handler that was about to finish. TestRequestsInsideBudgetAreNotCutShort fails with client received "": the §15.4.4 truncation, turned into a test. The base context is the escalation after the budget expires, never something you fire alongside Shutdown.

Where the files are: labs/go-concurrency/code/ch15/. A worked answer sits in solution/shutdown.go.txt, including why the second brief Shutdown after cancelling is nearly free, and why the gates use real short timeouts rather than testing/synctestmeasured in §15.7.7, a real net/http server inside a synctest bubble never advances.

Further Reading

Next

You can now name what “graceful” actually promises and say which promise a given bug breaks: catch a signal as a context, hand the operator their escape hatch back, wait out a propagation window you cannot observe, and drive Server.Shutdown around all four of its documented holes. You also know the shape of the endgame — every phase bounded by a slice of a budget someone else set, an escalation ladder climbed one rung at a time, and a loss that is reported rather than exited-zero over. Chapter 16 removes the guesswork: testing concurrent code deterministically, where the shutdown you just built stops being the thing you hope works on deploy day and becomes the thing a test proves before it ships.