Introduction

Go's net.Listener accept loop runs indefinitely to handle incoming connections. When the listener is closed (during shutdown or restart), Accept() returns an error. If this error is not handled correctly, the loop either spins rapidly consuming CPU (on temporary errors), leaks goroutines (if the loop does not exit), or crashes the server (if errors panic). The classic pattern of for { conn, err := ln.Accept(); ... } needs proper error classification to distinguish between recoverable errors and signals to stop accepting.

Symptoms

bash
accept tcp [::]:8080: use of closed network connection
# Loop continues, consuming CPU with repeated errors

Or goroutine leak:

bash
# goroutine profile shows:
goroutine 123 [select]:
  net.(*netFD).accept(...)
# Listener closed but goroutine still blocked on Accept

Common Causes

  • Accept loop does not exit on close: Listener closed but loop keeps trying
  • Temporary errors not handled: EAGAIN/EWOULDBACK cause rapid loop iteration
  • No backoff on accept errors: Errors processed without delay
  • Connection handler goroutine leak: Handler goroutines not properly managed
  • Listener not tracked: No way to signal shutdown to accept loop
  • TLS handshake errors treated as fatal: TLS errors should not stop accept loop

Step-by-Step Fix

Step 1: Proper accept loop with error handling

```go import ( "log" "net" "os" "time" )

func acceptLoop(ln net.Listener) error { var tempDelay time.Duration

for { conn, err := ln.Accept() if err != nil { // Check if listener was intentionally closed if opErr, ok := err.(*net.OpError); ok && opErr.Err.Error() == "use of closed network connection" { log.Println("Listener closed, exiting accept loop") return nil }

// Handle temporary errors with exponential backoff if ne, ok := err.(net.Error); ok && ne.Timeout() { if tempDelay == 0 { tempDelay = 5 * time.Millisecond } else { tempDelay *= 2 } if max := 1 * time.Second; tempDelay > max { tempDelay = max } log.Printf("Accept error: %v, retrying in %v", err, tempDelay) time.Sleep(tempDelay) continue }

// Permanent error return err }

// Reset delay on successful accept tempDelay = 0

go handleConnection(conn) } } ```

Step 2: Graceful listener shutdown

```go import "sync"

type Server struct { ln net.Listener mu sync.Mutex closing bool wg sync.WaitGroup }

func (s *Server) Start(addr string) error { ln, err := net.Listen("tcp", addr) if err != nil { return err }

s.mu.Lock() s.ln = ln s.mu.Unlock()

return s.acceptLoop(ln) }

func (s *Server) Stop() error { s.mu.Lock() s.closing = true ln := s.ln s.mu.Unlock()

if ln != nil { return ln.Close() // This causes Accept() to return an error } return nil }

func (s *Server) Wait() { s.wg.Wait() } ```

Prevention

  • Always check for "use of closed network connection" error to exit accept loop cleanly
  • Implement exponential backoff for temporary accept errors
  • Track active connections with a WaitGroup for graceful shutdown
  • Use net.Error.Timeout() to distinguish temporary from permanent errors
  • Close the listener to signal the accept loop to exit -- do not use channels for this
  • Add connection count metrics to detect connection leaks
  • Test shutdown behavior by starting and stopping the server in tests