The problem with stopping a TCP server

Most TCP servers run until the process is killed, but orderly shutdown matters in specific situations — tests being the most common. A Go TCP server built on the standard library actually performs two jobs at once: it accepts new connections on a listener, and it runs blocking handlers for each established connection. Any shutdown mechanism has to deal with both.

TCP itself offers no guidance here. The protocol is too low-level to define what "done" means for an open connection. The conservative default is to stop accepting new connections and then wait for clients to close their own connections. That works well when the server's clients are under your control, as they are in tests.

Shutdown by waiting for clients

The first approach is intentionally conservative: close the listener, but let existing connections finish naturally. The server type carries a net.Listener, a quit channel for signaling, and a sync.WaitGroup to track goroutines:

type Server struct {
  listener net.Listener
  quit     chan interface{}
  wg       sync.WaitGroup
}

func NewServer(addr string) *Server {
  s := &Server{
    quit: make(chan interface{}),
  }
  l, err := net.Listen("tcp", addr)
  if err != nil {
    log.Fatal(err)
  }
  s.listener = l
  s.wg.Add(1)
  go s.serve()
  return s
}

The constructor starts a background goroutine running serve, which is a standard Accept loop with one addition. When Accept returns an error, a non-blocking select checks whether quit has been closed. If it has, the error came from closing the listener and serve returns quietly; otherwise the error is real and is reported.

func (s *Server) serve() {
  defer s.wg.Done()

  for {
    conn, err := s.listener.Accept()
    if err != nil {
      select {
      case <-s.quit:
        return
      default:
        log.Println("accept error", err)
      }
    } else {
      s.wg.Add(1)
      go func() {
        s.handleConection(conn)
        s.wg.Done()
      }()
    }
  }
}

Stop coordinates the shutdown. It closes quit first, then closes the listener, which forces the pending Accept to error out. Because quit is already closed, serve exits without treating that error as a failure. The final step — waiting on the wait group — is essential: each connection handler registered itself with the same wait group, so Stop blocks until every handler has returned and the accept loop has ended.

func (s *Server) Stop() {
  close(s.quit)
  s.listener.Close()
  s.wg.Wait()
}

A minimal handler for this design just reads client data and logs it:

func (s *Server) handleConection(conn net.Conn) {
  defer conn.Close()
  buf := make([]byte, 2048)
  for {
    n, err := conn.Read(buf)
    if err != nil && err != io.EOF {
      log.Println("read error", err)
      return
    }
    if n == 0 {
      return
    }
    log.Printf("received from %v: %s", conn.RemoteAddr(), string(buf[:n]))
  }
}

Usage follows a simple pattern. NewServer returns immediately; Stop blocks. A test that wants a clean shutdown first ensures all clients have closed their connections, then calls Stop and waits for it to return.

s := NewServer(addr)
// do whatever here...
s.Stop()

Actively closing idle connections

Waiting for clients is not always practical. A more aggressive variant shuts down idle connections itself. The implementation stays almost identical to the first version — the only difference is the connection handler. Each read carries a deadline, here 200 ms:

func (s *Server) handleConection(conn net.Conn) {
  defer conn.Close()
  buf := make([]byte, 2048)
ReadLoop:
  for {
    select {
    case <-s.quit:
      return
    default:
      conn.SetDeadline(time.Now().Add(200 * time.Millisecond))
      n, err := conn.Read(buf)
      if err != nil {
        if opErr, ok := err.(*net.OpError); ok && opErr.Timeout() {
          continue ReadLoop
        } else if err != io.EOF {
          log.Println("read error", err)
          return
        }
      }
      if n == 0 {
        return
      }
      log.Printf("received from %v: %s", conn.RemoteAddr(), string(buf[:n]))
    }
  }
}

When a read times out, the client has been idle for the full deadline period and the connection is safe to close. The handler checks quit on every loop iteration and returns when shutdown is signaled. This approach is robust because it will not cut off a client that is actively sending data. It is also simple — all extra logic lives inside handleConnection.

The costs are a periodic conn.Read call every 200 ms and a worst-case 200 ms delay in every Stop request. Both are acceptable in most shutdown scenarios, and the deadline is tunable per application.

An alternative is tracking all open connections outside the handler and force-closing them in Stop. That may be more efficient, but it risks closing connections mid-transmission and adds bookkeeping complexity.

The standard library's http.Server.Shutdown offers a useful reference point:

Shutdown gracefully shuts down the server without interrupting any active connections. Shutdown works by first closing all open listeners, then closing all idle connections, and then waiting indefinitely for connections to return to idle and then shut down.

HTTP has an advantage over raw TCP here: it is a higher-level protocol, so the server can define "idle" precisely. For generic TCP servers, the right strategy depends on the protocol. If the server initiates messages, it may be safe to close connections immediately on shutdown rather than waiting for client activity.

Choosing a strategy

Two principles cover most cases. First, make shutdown as safe as possible — do not interrupt work in progress. Second, let the higher-level protocol inform what "safe" means; a server that pushes events can be more aggressive than one that only responds to client requests.

For tests, the first approach is usually sufficient. Once test clients have closed their connections, Server.Stop returns with no delay and no special handling.