Prevent Data Races on Shared State in Go

Catches maps, counters, and slices shared across goroutines without a mutex or atomic, plus mutex-guarded state that escapes its lock.

What it flags

Flaggedmetrics/routes.go
type RouteStats struct {
	mu     sync.Mutex
	counts map[string]int
	total  int
}

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	h.stats.Record(h.route)
	h.next.ServeHTTP(w, r)
}

func (s *RouteStats) Record(route string) {
	s.mu.Lock()
	s.counts[route]++
	s.mu.Unlock()
	s.total++
}

func (s *RouteStats) Snapshot() (map[string]int, int) {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.counts, s.total // callers range over the live map while Record writes to it
}
Passesmetrics/routes.go
type RouteStats struct {
	mu     sync.Mutex
	counts map[string]int
	total  int
}

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	h.stats.Record(h.route)
	h.next.ServeHTTP(w, r)
}

func (s *RouteStats) Record(route string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.counts[route]++
	s.total++
}

func (s *RouteStats) Snapshot() (map[string]int, int) {
	s.mu.Lock()
	defer s.mu.Unlock()
	return maps.Clone(s.counts), s.total
}

Why it matters

Racing reads can observe stale or torn values, and concurrent map access can crash the whole process with a fatal error that recover cannot catch. The race detector only reports races on code paths that run concurrently during the test, so a race behind a rarely hit handler or a background refresh ships and fails under production load. Review is often the only point where someone asks which goroutines can reach a value.

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 Data Races on Shared State in Go

Applies to `**/*.go`.

Review Go code that shares state across goroutines for reads and writes without synchronization.

Flag changes that:
- Read or write a map from more than one goroutine (HTTP handlers, `go` statements, callbacks, package-level caches) without a `sync.Mutex`, `sync.RWMutex`, or `sync.Map`.
- Update counters, flags, or package-level variables shared across goroutines with plain `++`, `+=`, or assignment instead of a mutex or a `sync/atomic` type such as `atomic.Int64`.
- `append` to a shared slice from multiple goroutines, such as `results = append(results, r)` inside `go func()`.
- Guard a field with a mutex in some methods but read or write it without the lock in others, including getters and `len` calls.
- Return or keep a reference to a mutex-guarded map, slice, or pointer after unlocking, so callers read it while another goroutine writes it.
- Check and act in separate critical sections, such as reading under `RLock`, unlocking, and then writing based on the stale read.

Do not flag:
- Closures that capture loop variables in modules whose `go.mod` declares `go 1.22` or later, where each iteration has its own variable.
- Goroutines writing distinct indexes of a pre-sized slice (`results[i] = r`) when the slice is read only after `Wait`.
- State written before the goroutines start and only read afterward.

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.

Catch this in your code