Prevent Goroutine Leaks in Go
Catches goroutines that never exit: sends nobody receives, loops with no cancellation path, channels never closed, and tickers that keep running.
What it flags
type result struct {
quote Quote
err error
}
func (c *Client) FetchQuote(ctx context.Context, symbol string) (Quote, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
ch := make(chan result)
go func() {
q, err := c.lookup(ctx, symbol)
ch <- result{q, err} // blocks forever once FetchQuote has returned
}()
select {
case r := <-ch:
return r.quote, r.err
case <-ctx.Done():
return Quote{}, fmt.Errorf("quote %s: %w", symbol, ctx.Err())
}
}type result struct {
quote Quote
err error
}
func (c *Client) FetchQuote(ctx context.Context, symbol string) (Quote, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
ch := make(chan result, 1) // the send completes even if nobody receives
go func() {
q, err := c.lookup(ctx, symbol)
ch <- result{q, err}
}()
select {
case r := <-ch:
return r.quote, r.err
case <-ctx.Done():
return Quote{}, fmt.Errorf("quote %s: %w", symbol, ctx.Err())
}
}Why it matters
Each leaked goroutine keeps its stack and everything it references alive, so a leak on a request path grows memory and goroutine count until the process is restarted or killed. The most common cause is a send on an unbuffered channel after the receiver has timed out or returned, which only happens when a dependency is slow and never in a happy-path test. Nothing fails loudly: the service keeps responding while the goroutine count climbs.
Agent instructions
cubic checks every pull request against these instructions. Paste them into AGENTS.md or Cursor rules and your AI coding tool follows them while it writes code.
AGENTS.md
Add this section to AGENTS.md at the repository root. Codex, Cursor, and other AI coding tools read it.
## Prevent Goroutine Leaks in Go Applies to `**/*.go`. Review goroutines, channels, and tickers for goroutines that can block or run forever after their work is no longer needed. Flag changes that: - Start a goroutine whose loop or blocking receive has no exit path: no `ctx.Done()` case, no closed channel to end a `range`, and no return condition. - Send on an unbuffered channel from a goroutine whose receiver can return first, for example after a `select` timeout, a `ctx.Done()` case, or an early error return, leaving the sender blocked forever. - `range` over a channel that the producer never closes, or closes only on some paths. - Create a `time.NewTicker` without `defer ticker.Stop()` in a module whose `go.mod` declares a Go version below 1.23, where an unstopped ticker is never garbage collected. - Start goroutines per request or per item with no `sync.WaitGroup`, `errgroup.Group`, or result channel the caller waits on, so work outlives the request and its errors are lost. - Spawn one goroutine per element of an unbounded input without a limit such as `errgroup.Group.SetLimit` or a fixed worker pool. - Call `wg.Done()` without `defer`, so an early return or panic skips it and `wg.Wait()` blocks forever. Do not flag: - Goroutines started once in `main` or server setup that are meant to run for the life of the process. - Sends on a channel buffered for every sender, such as `make(chan result, 1)` for a single worker. - Goroutines joined through `errgroup` or `sync.WaitGroup` where every path reaches `Wait` and the work observes the group's context.
Checks files matching **/*.go.
More Go agents
See all Go code review standards
Run this agent on every pull request
cubic reviews every pull request against the agents you turn on and comments on the lines that break them.