cloudflare/cloudflared · info · SessionCloseErr

flow was closed directly

Error message

flow was closed directly

What it means

SessionCloseErr is the sentinel error returned by a v3 QUIC session's Serve when the session's Close method was called directly (as opposed to timing out). It signals an intentional, successful shutdown, so the muxer treats it as a normal end-of-session condition and logs at debug level rather than reporting a failure.

Source

Thrown at quic/v3/session.go:36

	// A default is provided in the case that the client does not provide a close idle timeout.
	defaultCloseIdleAfter = 210 * time.Second

	// The maximum payload from the origin that we will be able to read. However, even though we will
	// read 1500 bytes from the origin, we limit the amount of bytes to be proxied to less than
	// this value (maxDatagramPayloadLen).
	maxOriginUDPPacketSize = 1500

	// The maximum amount of datagrams a session will queue up before it begins dropping datagrams.
	// This channel buffer is small because we assume that the dedicated writer to the origin is typically
	// fast enought to keep the channel empty.
	writeChanCapacity = 512

	logFlowID        = "flowID"
	logPacketSizeKey = "packetSize"
)

// SessionCloseErr indicates that the session's Close method was called.
var SessionCloseErr error = errors.New("flow was closed directly") //nolint:errname

// SessionIdleErr is returned when the session was closed because there was no communication
// in either direction over the session for the timeout period.
type SessionIdleErr struct { //nolint:errname
	timeout time.Duration
}

func (e SessionIdleErr) Error() string {
	return fmt.Sprintf("flow was idle for %v", e.timeout)
}

func (e SessionIdleErr) Is(target error) bool {
	_, ok := target.(SessionIdleErr)
	return ok
}

func newSessionIdleErr(timeout time.Duration) error {
	return SessionIdleErr{timeout}

View on GitHub (pinned to 2253eeeb25)

Solutions

  1. No fix needed — treat it as success: check errors.Is(err, v3.SessionCloseErr) and return without logging an error.
  2. If you see it unexpectedly, audit which component calls Close (shutdown hooks, conn index management) and confirm it was intentional.
  3. When wrapping Serve's error, propagate it with %w so errors.Is-based checks in the muxer still match.
  4. In tests, return SessionCloseErr to simulate a normally-closed session instead of a generic error.

Example fix

// before
if err := session.Serve(ctx); err != nil {
    log.Error().Err(err).Msg("session failed")
}
// after
if err := session.Serve(ctx); err != nil && !errors.Is(err, v3.SessionCloseErr) && !errors.Is(err, v3.SessionIdleErr{}) {
    log.Error().Err(err).Msg("session failed")
}
Defensive patterns

Strategy: try-catch

Type guard

func isCleanSessionClose(err error) bool {
    return errors.Is(err, v3.SessionCloseErr) || errors.Is(err, v3.SessionIdleErr{})
}

Try / catch

if err := session.Serve(ctx); err != nil {
    if errors.Is(err, v3.SessionCloseErr) || errors.Is(err, v3.SessionIdleErr{}) {
        log.Debug().Msgf("flow closed: %s", err.Error())
        return nil
    }
    return err
}

Prevention

When it happens

Trigger: Calling session.Close() while the session's Serve loop is running — e.g. on proxy shutdown, connection index reuse, or flow teardown from the muxer; also produced by mockSession in tests to simulate clean shutdown.

Common situations: Graceful cloudflared shutdown or config reload closing active tunnels; a session being closed by another goroutine while Serve is still waiting on streams; developers mistaking this benign sentinel for a transport failure in logs.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of cloudflare/cloudflared@2253eeeb25 (2026-09-06). Data as JSON: /api/errors/3e144a3b7c8de91b. Report an issue: GitHub.