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.
The program
Section titled “The program”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} Find the bugs
Section titled “Find the bugs”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
context.WithTimeout(context.Background(), 2)is missing* time.Second.2is an untyped constant that converts straight totime.Duration(2)— two nanoseconds. The context is already past its deadline before the firstfetchcall even starts, which is exactly what the real output shows:error: timed out fetching users: context deadline exceededon the very first resource.cancelis referenced but never called._ = cancelsatisfies “the variable is used” but never actually invokes the function — the timer behindWithTimeoutleaks 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 wrongctx, _ := context.WithTimeout(...)makesgo vetobject immediately:Themain.go:11:7: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak_ = cancelspelling in the actual program above leaks in exactly the same way andgo vetsays nothing about it — a real gap in what thelostcancelanalyzer’s simple heuristic can see.stopfromsignal.NotifyContextis discarded and never called. This is not merely “a small leak” — the documentation forsignal.NotifyContextis explicit: “Future interrupts received will not trigger the default (exit) behavior until the returned stop function is called.” Verified directly: a process that callssignal.NotifyContext(ctx, os.Interrupt)and discardsstop, then receives a realSIGINT, does not exit:The same program with no signal handling installed at all exits immediately on the identical signal:sending SIGINT to myself, 500ms after NotifyContext with no stop() callstill alive: default SIGINT-exit behavior was never restored (stop() was never called)Forgettingsending SIGINT to myself with no signal handling installed at allsignal: interruptstop()does not just leak memory — it silently disables Ctrl+C for the rest of the process’s life.- 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 brokenerrors.Iseven sooner by discarding the chain entirely. fetch’s background goroutine never looks atctx. It just sleeps fortakeand 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).
The corrected program
Section titled “The corrected program”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.