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

  1. Treat ErrServerClosed as a normal stop signal: exit the goroutine instead of retrying or logging it as a failure
  2. Audit shutdown ordering so workers finish before Close, or select on a done channel alongside server operations
  3. 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

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.