Skip to content

Worked review 3: a CLI with context, signals, and timeout

This CLI fetches three resources, one after another, with an overall timeout and Ctrl+C handling. It compiles, go vet says nothing, golangci-lint run says nothing, and it runs. It still has 5 real bugs — two of which no tool in this course catches, one of which the tool only half-catches.

package main
import (
"context"
"fmt"
"os"
"os/signal"
"time"
)
func fetch(ctx context.Context, name string, take time.Duration) (string, error) {
done := make(chan string, 1)
go func() {
time.Sleep(take) // pretend network work — ignores ctx entirely
done <- name + " ready"
fmt.Println(" (background: fetch for", name, "finished anyway, nobody was waiting)")
}()
select {
case msg := <-done:
return msg, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func run() error {
ctx, cancel := context.WithTimeout(context.Background(), 2)
_ = cancel // referenced but never called — still leaks, but go vet cannot tell
sigCtx, _ := signal.NotifyContext(ctx, os.Interrupt)
for _, name := range []string{"users", "orders", "invoices"} {
msg, err := fetch(sigCtx, name, 30*time.Millisecond)
if err != nil {
if err.Error() == "context deadline exceeded" {
return fmt.Errorf("timed out fetching %s: %v", name, err)
}
return fmt.Errorf("fetch %s: %v", name, err)
}
fmt.Println(msg)
}
return nil
}
func main() {
if err := run(); err != nil {
fmt.Println("error:", err)
}
time.Sleep(60 * time.Millisecond) // long enough to see the abandoned background print
}

The very first fetch times out instantly — before any real work could possibly have happened — and a line prints after the error that shouldn’t print at all if the timeout genuinely stopped the work. Try to name at least three of the five bugs before opening the answer.

Reveal the 5 bugs
  1. context.WithTimeout(context.Background(), 2) is missing * time.Second. 2 is an untyped constant that converts straight to time.Duration(2) — two nanoseconds. The context is already past its deadline before the first fetch call even starts, which is exactly what the real output shows: error: timed out fetching users: context deadline exceeded on the very first resource.
  2. cancel is referenced but never called. _ = cancel satisfies “the variable is used” but never actually invokes the function — the timer behind WithTimeout leaks for 2 nanoseconds’ worth of… nothing, since it already fired, but the same code shape with a longer real timeout would leak for the timeout’s full duration. The spelling matters to the tooling: rewriting this as the more obviously wrong ctx, _ := context.WithTimeout(...) makes go vet object immediately:
    main.go:11:7: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak
    The _ = cancel spelling in the actual program above leaks in exactly the same way and go vet says nothing about it — a real gap in what the lostcancel analyzer’s simple heuristic can see.
  3. stop from signal.NotifyContext is discarded and never called. This is not merely “a small leak” — the documentation for signal.NotifyContext is explicit: “Future interrupts received will not trigger the default (exit) behavior until the returned stop function is called.” Verified directly: a process that calls signal.NotifyContext(ctx, os.Interrupt) and discards stop, then receives a real SIGINT, does not exit:
    sending SIGINT to myself, 500ms after NotifyContext with no stop() call
    still alive: default SIGINT-exit behavior was never restored (stop() was never called)
    The same program with no signal handling installed at all exits immediately on the identical signal:
    sending SIGINT to myself with no signal handling installed at all
    signal: interrupt
    Forgetting stop() does not just leak memory — it silently disables Ctrl+C for the rest of the process’s life.
  4. The error check compares err.Error() == "context deadline exceeded" and wraps with %v. This happens to work here because nothing has wrapped the error yet, but it is one refactor away from silently breaking: the moment any caller wraps this error again (fmt.Errorf("worker failed: %w", err)), the string comparison no longer matches, and %v (rather than %w) would have broken errors.Is even sooner by discarding the chain entirely.
  5. fetch’s background goroutine never looks at ctx. It just sleeps for take and sends, full stop. The real output’s ordering proves it: error: timed out fetching users: ... prints immediately, and only after that — once the full 30ms sleep elapses — does (background: fetch for users finished anyway, nobody was waiting) print. The caller gave up, but the “work” kept running to completion anyway, wasting whatever resource it was holding (a connection, a file handle, a goroutine).
package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"time"
)
func fetch(ctx context.Context, name string, take time.Duration) (string, error) {
done := make(chan string, 1)
go func() {
select { // fixed: the background goroutine now watches ctx too
case <-time.After(take):
done <- name + " ready"
case <-ctx.Done():
return // stop pretending to work once the caller stopped waiting
}
}()
select {
case msg := <-done:
return msg, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func run() error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) // fixed: real unit
defer cancel() // fixed: always release the timer
sigCtx, stop := signal.NotifyContext(ctx, os.Interrupt)
defer stop() // fixed: always unregister — restores default Ctrl+C behavior when we're done
for _, name := range []string{"users", "orders", "invoices"} {
msg, err := fetch(sigCtx, name, 30*time.Millisecond)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) { // fixed: errors.Is, not string comparison
return fmt.Errorf("timed out fetching %s: %w", name, err) // fixed: %w keeps the chain
}
return fmt.Errorf("fetch %s: %w", name, err)
}
fmt.Println(msg)
}
return nil
}
func main() {
if err := run(); err != nil {
fmt.Println("error:", err)
}
time.Sleep(60 * time.Millisecond)
}

Run the corrected version: all three resources print ready (the timeout is now a real 2 seconds, long enough for 30ms fetches), there is no orphaned background print because the fetch goroutine now exits the instant ctx.Done() fires, and both cancel() and stop() are deferred immediately next to where they’re created. go vet and golangci-lint run both report zero issues on both versions of this file — which is itself the lesson: 3 of the 5 bugs here compile clean, vet clean, and lint clean.

The buggy program's very first fetch times out immediately, before any of the 30ms simulated work could finish. What causes that?
go vet flags ctx, _ := context.WithTimeout(...) with a lostcancel warning, but says nothing about ctx, cancel := context.WithTimeout(...); _ = cancel. Both leak identically. What does this gap tell you about the lostcancel analyzer?
What does discarding the stop function returned by signal.NotifyContext actually cost, according to its documentation and the verified demo in this lesson?
In the buggy version, the line (background: fetch for users finished anyway, nobody was waiting) prints *after* error: timed out fetching users: .... What does that ordering prove?