tailscale/tailscale · warning · ErrServerClosed
server closed
Error message
server closed
What it means
ErrServerClosed is the udprelay Server's shutdown sentinel, the analogue of http.ErrServerClosed. It is returned from server operations (relay loops, reads/writes on allocated endpoints) once the Server has been closed, signaling callers to stop rather than retry.
Source
Thrown at net/udprelay/server.go:965
// Write the packet batches via the socket associated with the
// destination's address family. If source and destination address
// families are matching we tx on the same socket the packet was
// received, otherwise we use the "other" socket. [Server] makes no
// use of dual-stack sockets.
if dest.Addr().Is4() == readFromSocketIsIPv4 {
readFromSocket.WriteBatchTo(buffs, dest, packet.GeneveHeader{}, 0)
} else {
otherSocket.WriteBatchTo(buffs, dest, packet.GeneveHeader{}, 0)
}
delete(writeBuffsByDest, dest)
}
s.metrics.countForwarded(readFromSocketIsIPv4, true, forwardedByOutAF.bytes4, forwardedByOutAF.packets4)
s.metrics.countForwarded(readFromSocketIsIPv4, false, forwardedByOutAF.bytes6, forwardedByOutAF.packets6)
}
}
var ErrServerClosed = errors.New("server closed")
// ErrServerNotReady indicates the server is not ready. Allocation should be
// requested after waiting for at least RetryAfter duration.
type ErrServerNotReady struct {
RetryAfter time.Duration
}
func (e ErrServerNotReady) Error() string {
return fmt.Sprintf("server not ready, retry after %v", e.RetryAfter)
}
// getNextVNILocked returns the next available VNI. It implements the
// "Traditional BSD Port Selection Algorithm" from RFC6056. This algorithm does
// not attempt to obfuscate the selection, i.e. the selection is predictable.
// For now, we favor simplicity and reducing VNI re-use over more complex
// ephemeral port (VNI) selection algorithms.
func (s *Server) getNextVNILocked() (uint32, error) {
for range totalPossibleVNI {View on GitHub (pinned to cfe32b8be6)
Solutions
- Treat ErrServerClosed as a normal stop signal: exit the goroutine instead of retrying or logging it as a failure
- Audit shutdown ordering so workers finish before Close, or select on a done channel alongside server operations
- Wrap the check with errors.Is(err, udprelay.ErrServerClosed) at the top of your error handling
Defensive patterns
Strategy: try-catch
Type guard
// isServerClosed reports whether err is the udprelay shutdown sentinel.
func isServerClosed(err error) bool { return errors.Is(err, udprelay.ErrServerClosed) } Try / catch
if err := srv.Serve(ctx); err != nil && !errors.Is(err, udprelay.ErrServerClosed) {
log.Fatalf("relay failed: %v", err)
}
// ErrServerClosed is the normal shutdown path: return nil Prevention
- Structure relay workers to select on a shutdown channel so they stop before Close
- Never log ErrServerClosed as an error; treat it like http.ErrServerClosed
When it happens
Trigger: Invoking Serve/relay operations after Close/Shutdown, or racing a Close against an in-flight operation so the operation observes the closed state.
Common situations: Goroutines that keep using the server after shutdown was initiated; missing synchronization between the shutdown path and workers; tests that close the server then drain results.
Related errors
AI-assisted analysis of tailscale/tailscale@cfe32b8be6 (2026-08-15).
Data as JSON: /api/errors/918eb702131af10b.
Report an issue: GitHub.