quic: idle timeouts, handshake timeouts, and keepalive
Negotiate the connection idle timeout based on the sent and received
max_idle_timeout transport parameter values.
Set a configurable limit on how long a handshake can take to complete.
Add a configuration option to send keep-alive PING frames to avoid
connection closure due to the idle timeout.
RFC 9000, Section 10.1.
For golang/go#58547
Change-Id: If6a611090ab836cd6937fcfbb1360a0f07425102
Reviewed-on: https://go-review.googlesource.com/c/net/+/540895
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
diff --git a/internal/quic/config.go b/internal/quic/config.go
index b10ecc7..b045b7b 100644
--- a/internal/quic/config.go
+++ b/internal/quic/config.go
@@ -9,6 +9,8 @@
import (
"crypto/tls"
"log/slog"
+ "math"
+ "time"
)
// A Config structure configures a QUIC endpoint.
@@ -74,6 +76,26 @@
// If this field is left as zero, stateless reset is disabled.
StatelessResetKey [32]byte
+ // HandshakeTimeout is the maximum time in which a connection handshake must complete.
+ // If zero, the default of 10 seconds is used.
+ // If negative, there is no handshake timeout.
+ HandshakeTimeout time.Duration
+
+ // MaxIdleTimeout is the maximum time after which an idle connection will be closed.
+ // If zero, the default of 30 seconds is used.
+ // If negative, idle connections are never closed.
+ //
+ // The idle timeout for a connection is the minimum of the maximum idle timeouts
+ // of the endpoints.
+ MaxIdleTimeout time.Duration
+
+ // KeepAlivePeriod is the time after which a packet will be sent to keep
+ // an idle connection alive.
+ // If zero, keep alive packets are not sent.
+ // If greater than zero, the keep alive period is the smaller of KeepAlivePeriod and
+ // half the connection idle timeout.
+ KeepAlivePeriod time.Duration
+
// QLogLogger receives qlog events.
//
// Events currently correspond to the definitions in draft-ietf-qlog-quic-events-03.
@@ -85,7 +107,7 @@
QLogLogger *slog.Logger
}
-func configDefault(v, def, limit int64) int64 {
+func configDefault[T ~int64](v, def, limit T) T {
switch {
case v == 0:
return def
@@ -115,3 +137,15 @@
func (c *Config) maxConnReadBufferSize() int64 {
return configDefault(c.MaxConnReadBufferSize, 1<<20, maxVarint)
}
+
+func (c *Config) handshakeTimeout() time.Duration {
+ return configDefault(c.HandshakeTimeout, defaultHandshakeTimeout, math.MaxInt64)
+}
+
+func (c *Config) maxIdleTimeout() time.Duration {
+ return configDefault(c.MaxIdleTimeout, defaultMaxIdleTimeout, math.MaxInt64)
+}
+
+func (c *Config) keepAlivePeriod() time.Duration {
+ return configDefault(c.KeepAlivePeriod, defaultKeepAlivePeriod, math.MaxInt64)
+}
diff --git a/internal/quic/conn.go b/internal/quic/conn.go
index cca1116..b2b6a08 100644
--- a/internal/quic/conn.go
+++ b/internal/quic/conn.go
@@ -26,22 +26,17 @@
testHooks connTestHooks
peerAddr netip.AddrPort
- msgc chan any
- donec chan struct{} // closed when conn loop exits
- exited bool // set to make the conn loop exit immediately
+ msgc chan any
+ donec chan struct{} // closed when conn loop exits
w packetWriter
acks [numberSpaceCount]ackState // indexed by number space
lifetime lifetimeState
+ idle idleState
connIDState connIDState
loss lossState
streams streamsState
- // idleTimeout is the time at which the connection will be closed due to inactivity.
- // https://www.rfc-editor.org/rfc/rfc9000#section-10.1
- maxIdleTimeout time.Duration
- idleTimeout time.Time
-
// Packet protection keys, CRYPTO streams, and TLS state.
keysInitial fixedKeyPair
keysHandshake fixedKeyPair
@@ -105,8 +100,6 @@
peerAddr: peerAddr,
msgc: make(chan any, 1),
donec: make(chan struct{}),
- maxIdleTimeout: defaultMaxIdleTimeout,
- idleTimeout: now.Add(defaultMaxIdleTimeout),
peerAckDelayExponent: -1,
}
defer func() {
@@ -151,6 +144,7 @@
c.loss.init(c.side, maxDatagramSize, now)
c.streamsInit()
c.lifetimeInit()
+ c.restartIdleTimer(now)
if err := c.startTLS(now, initialConnID, transportParameters{
initialSrcConnID: c.connIDState.srcConnID(),
@@ -202,6 +196,7 @@
// don't need to send anything.
c.handshakeConfirmed.setReceived()
}
+ c.restartIdleTimer(now)
c.loss.confirmHandshake()
// "An endpoint MUST discard its Handshake keys when the TLS handshake is confirmed"
// https://www.rfc-editor.org/rfc/rfc9001#section-4.9.2-1
@@ -232,6 +227,7 @@
c.streams.peerInitialMaxStreamDataBidiLocal = p.initialMaxStreamDataBidiLocal
c.streams.peerInitialMaxStreamDataRemote[bidiStream] = p.initialMaxStreamDataBidiRemote
c.streams.peerInitialMaxStreamDataRemote[uniStream] = p.initialMaxStreamDataUni
+ c.receivePeerMaxIdleTimeout(p.maxIdleTimeout)
c.peerAckDelayExponent = p.ackDelayExponent
c.loss.setMaxAckDelay(p.maxAckDelay)
if err := c.connIDState.setPeerActiveConnIDLimit(c, p.activeConnIDLimit); err != nil {
@@ -248,7 +244,6 @@
return err
}
}
- // TODO: max_idle_timeout
// TODO: stateless_reset_token
// TODO: max_udp_payload_size
// TODO: disable_active_migration
@@ -261,6 +256,8 @@
wakeEvent struct{}
)
+var errIdleTimeout = errors.New("idle timeout")
+
// loop is the connection main loop.
//
// Except where otherwise noted, all connection state is owned by the loop goroutine.
@@ -288,14 +285,14 @@
defer timer.Stop()
}
- for !c.exited {
+ for c.lifetime.state != connStateDone {
sendTimeout := c.maybeSend(now) // try sending
// Note that we only need to consider the ack timer for the App Data space,
// since the Initial and Handshake spaces always ack immediately.
nextTimeout := sendTimeout
- nextTimeout = firstTime(nextTimeout, c.idleTimeout)
- if !c.isClosingOrDraining() {
+ nextTimeout = firstTime(nextTimeout, c.idle.nextTimeout)
+ if c.isAlive() {
nextTimeout = firstTime(nextTimeout, c.loss.timer)
nextTimeout = firstTime(nextTimeout, c.acks[appDataSpace].nextAck)
} else {
@@ -329,11 +326,9 @@
m.recycle()
case timerEvent:
// A connection timer has expired.
- if !now.Before(c.idleTimeout) {
- // "[...] the connection is silently closed and
- // its state is discarded [...]"
- // https://www.rfc-editor.org/rfc/rfc9000#section-10.1-1
- c.exited = true
+ if c.idleAdvance(now) {
+ // The connection idle timer has expired.
+ c.abortImmediately(now, errIdleTimeout)
return
}
c.loss.advance(now, c.handleAckOrLoss)
diff --git a/internal/quic/conn_close.go b/internal/quic/conn_close.go
index a9ef0db..246a126 100644
--- a/internal/quic/conn_close.go
+++ b/internal/quic/conn_close.go
@@ -12,33 +12,54 @@
"time"
)
+// connState is the state of a connection.
+type connState int
+
+const (
+ // A connection is alive when it is first created.
+ connStateAlive = connState(iota)
+
+ // The connection has received a CONNECTION_CLOSE frame from the peer,
+ // and has not yet sent a CONNECTION_CLOSE in response.
+ //
+ // We will send a CONNECTION_CLOSE, and then enter the draining state.
+ connStatePeerClosed
+
+ // The connection is in the closing state.
+ //
+ // We will send CONNECTION_CLOSE frames to the peer
+ // (once upon entering the closing state, and possibly again in response to peer packets).
+ //
+ // If we receive a CONNECTION_CLOSE from the peer, we will enter the draining state.
+ // Otherwise, we will eventually time out and move to the done state.
+ //
+ // https://www.rfc-editor.org/rfc/rfc9000#section-10.2.1
+ connStateClosing
+
+ // The connection is in the draining state.
+ //
+ // We will neither send packets nor process received packets.
+ // When the drain timer expires, we move to the done state.
+ //
+ // https://www.rfc-editor.org/rfc/rfc9000#section-10.2.2
+ connStateDraining
+
+ // The connection is done, and the conn loop will exit.
+ connStateDone
+)
+
// lifetimeState tracks the state of a connection.
//
// This is fairly coupled to the rest of a Conn, but putting it in a struct of its own helps
// reason about operations that cause state transitions.
type lifetimeState struct {
- readyc chan struct{} // closed when TLS handshake completes
- drainingc chan struct{} // closed when entering the draining state
+ state connState
- // Possible states for the connection:
- //
- // Alive: localErr and finalErr are both nil.
- //
- // Closing: localErr is non-nil and finalErr is nil.
- // We have sent a CONNECTION_CLOSE to the peer or are about to
- // (if connCloseSentTime is zero) and are waiting for the peer to respond.
- // drainEndTime is set to the time the closing state ends.
- // https://www.rfc-editor.org/rfc/rfc9000.html#section-10.2.1
- //
- // Draining: finalErr is non-nil.
- // If localErr is nil, we're waiting for the user to provide us with a final status
- // to send to the peer.
- // Otherwise, we've either sent a CONNECTION_CLOSE to the peer or are about to
- // (if connCloseSentTime is zero).
- // drainEndTime is set to the time the draining state ends.
- // https://www.rfc-editor.org/rfc/rfc9000.html#section-10.2.2
+ readyc chan struct{} // closed when TLS handshake completes
+ donec chan struct{} // closed when finalErr is set
+
localErr error // error sent to the peer
- finalErr error // error sent by the peer, or transport error; always set before draining
+ finalErr error // error sent by the peer, or transport error; set before closing donec
connCloseSentTime time.Time // send time of last CONNECTION_CLOSE frame
connCloseDelay time.Duration // delay until next CONNECTION_CLOSE frame sent
@@ -47,7 +68,7 @@
func (c *Conn) lifetimeInit() {
c.lifetime.readyc = make(chan struct{})
- c.lifetime.drainingc = make(chan struct{})
+ c.lifetime.donec = make(chan struct{})
}
var errNoPeerResponse = errors.New("peer did not respond to CONNECTION_CLOSE")
@@ -60,13 +81,25 @@
// The connection drain period has ended, and we can shut down.
// https://www.rfc-editor.org/rfc/rfc9000.html#section-10.2-7
c.lifetime.drainEndTime = time.Time{}
- if c.lifetime.finalErr == nil {
- // The peer never responded to our CONNECTION_CLOSE.
- c.enterDraining(now, errNoPeerResponse)
+ if c.lifetime.state != connStateDraining {
+ // We were in the closing state, waiting for a CONNECTION_CLOSE from the peer.
+ c.setFinalError(errNoPeerResponse)
}
+ c.setState(now, connStateDone)
return true
}
+// setState sets the conn state.
+func (c *Conn) setState(now time.Time, state connState) {
+ switch state {
+ case connStateClosing, connStateDraining:
+ if c.lifetime.drainEndTime.IsZero() {
+ c.lifetime.drainEndTime = now.Add(3 * c.loss.ptoBasePeriod())
+ }
+ }
+ c.lifetime.state = state
+}
+
// confirmHandshake is called when the TLS handshake completes.
func (c *Conn) handshakeDone() {
close(c.lifetime.readyc)
@@ -81,44 +114,66 @@
//
// https://www.rfc-editor.org/rfc/rfc9000.html#section-10.2.2
func (c *Conn) isDraining() bool {
- return c.lifetime.finalErr != nil
+ switch c.lifetime.state {
+ case connStateDraining, connStateDone:
+ return true
+ }
+ return false
}
-// isClosingOrDraining reports whether the conn is in the closing or draining states.
-func (c *Conn) isClosingOrDraining() bool {
- return c.lifetime.localErr != nil || c.lifetime.finalErr != nil
+// isAlive reports whether the conn is handling packets.
+func (c *Conn) isAlive() bool {
+ return c.lifetime.state == connStateAlive
}
// sendOK reports whether the conn can send frames at this time.
func (c *Conn) sendOK(now time.Time) bool {
- if !c.isClosingOrDraining() {
+ switch c.lifetime.state {
+ case connStateAlive:
return true
- }
- // We are closing or draining.
- if c.lifetime.localErr == nil {
- // We're waiting for the user to close the connection, providing us with
- // a final status to send to the peer.
+ case connStatePeerClosed:
+ if c.lifetime.localErr == nil {
+ // We're waiting for the user to close the connection, providing us with
+ // a final status to send to the peer.
+ return false
+ }
+ // We should send a CONNECTION_CLOSE.
+ return true
+ case connStateClosing:
+ if c.lifetime.connCloseSentTime.IsZero() {
+ return true
+ }
+ maxRecvTime := c.acks[initialSpace].maxRecvTime
+ if t := c.acks[handshakeSpace].maxRecvTime; t.After(maxRecvTime) {
+ maxRecvTime = t
+ }
+ if t := c.acks[appDataSpace].maxRecvTime; t.After(maxRecvTime) {
+ maxRecvTime = t
+ }
+ if maxRecvTime.Before(c.lifetime.connCloseSentTime.Add(c.lifetime.connCloseDelay)) {
+ // After sending CONNECTION_CLOSE, ignore packets from the peer for
+ // a delay. On the next packet received after the delay, send another
+ // CONNECTION_CLOSE.
+ return false
+ }
+ return true
+ case connStateDraining:
+ // We are in the draining state, and will send no more packets.
return false
+ case connStateDone:
+ return false
+ default:
+ panic("BUG: unhandled connection state")
}
- // Past this point, returning true will result in the conn sending a CONNECTION_CLOSE
- // due to localErr being set.
- if c.lifetime.drainEndTime.IsZero() {
- // The closing and draining states should last for at least three times
- // the current PTO interval. We currently use exactly that minimum.
- // https://www.rfc-editor.org/rfc/rfc9000.html#section-10.2-5
- //
- // The drain period begins when we send or receive a CONNECTION_CLOSE,
- // whichever comes first.
- // https://www.rfc-editor.org/rfc/rfc9000.html#section-10.2.2-3
- c.lifetime.drainEndTime = now.Add(3 * c.loss.ptoBasePeriod())
+}
+
+// sendConnectionClose reports that the conn has sent a CONNECTION_CLOSE to the peer.
+func (c *Conn) sentConnectionClose(now time.Time) {
+ switch c.lifetime.state {
+ case connStatePeerClosed:
+ c.enterDraining(now)
}
if c.lifetime.connCloseSentTime.IsZero() {
- // We haven't sent a CONNECTION_CLOSE yet. Do so.
- // Either we're initiating an immediate close
- // (and will enter the closing state as soon as we send CONNECTION_CLOSE),
- // or we've read a CONNECTION_CLOSE from our peer
- // (and may send one CONNECTION_CLOSE before entering the draining state).
- //
// Set the initial delay before we will send another CONNECTION_CLOSE.
//
// RFC 9000 states that we should rate limit CONNECTION_CLOSE frames,
@@ -126,65 +181,56 @@
// with the same delay as the PTO timer (RFC 9002, Section 6.2.1),
// not including max_ack_delay, and double it on every CONNECTION_CLOSE sent.
c.lifetime.connCloseDelay = c.loss.rtt.smoothedRTT + max(4*c.loss.rtt.rttvar, timerGranularity)
- c.lifetime.drainEndTime = now.Add(3 * c.loss.ptoBasePeriod())
- return true
- }
- if c.isDraining() {
- // We are in the draining state, and will send no more packets.
- return false
- }
- maxRecvTime := c.acks[initialSpace].maxRecvTime
- if t := c.acks[handshakeSpace].maxRecvTime; t.After(maxRecvTime) {
- maxRecvTime = t
- }
- if t := c.acks[appDataSpace].maxRecvTime; t.After(maxRecvTime) {
- maxRecvTime = t
- }
- if maxRecvTime.Before(c.lifetime.connCloseSentTime.Add(c.lifetime.connCloseDelay)) {
- // After sending CONNECTION_CLOSE, ignore packets from the peer for
- // a delay. On the next packet received after the delay, send another
- // CONNECTION_CLOSE.
- return false
+ } else if !c.lifetime.connCloseSentTime.Equal(now) {
+ // If connCloseSentTime == now, we're sending two CONNECTION_CLOSE frames
+ // coalesced into the same datagram. We only want to increase the delay once.
+ c.lifetime.connCloseDelay *= 2
}
c.lifetime.connCloseSentTime = now
- c.lifetime.connCloseDelay *= 2
- return true
}
-// enterDraining enters the draining state.
-func (c *Conn) enterDraining(now time.Time, err error) {
- if c.isDraining() {
- return
+// handlePeerConnectionClose handles a CONNECTION_CLOSE from the peer.
+func (c *Conn) handlePeerConnectionClose(now time.Time, err error) {
+ c.setFinalError(err)
+ switch c.lifetime.state {
+ case connStateAlive:
+ c.setState(now, connStatePeerClosed)
+ case connStatePeerClosed:
+ // Duplicate CONNECTION_CLOSE, ignore.
+ case connStateClosing:
+ if c.lifetime.connCloseSentTime.IsZero() {
+ c.setState(now, connStatePeerClosed)
+ } else {
+ c.setState(now, connStateDraining)
+ }
+ case connStateDraining:
+ case connStateDone:
}
- if err == errStatelessReset {
- // If we've received a stateless reset, then we must not send a CONNECTION_CLOSE.
- // Setting connCloseSentTime here prevents us from doing so.
- c.lifetime.finalErr = errStatelessReset
- c.lifetime.localErr = errStatelessReset
- c.lifetime.connCloseSentTime = now
- } else if e, ok := c.lifetime.localErr.(localTransportError); ok && e.code != errNo {
- // If we've terminated the connection due to a peer protocol violation,
- // record the final error on the connection as our reason for termination.
- c.lifetime.finalErr = c.lifetime.localErr
- } else {
- c.lifetime.finalErr = err
+}
+
+// setFinalError records the final connection status we report to the user.
+func (c *Conn) setFinalError(err error) {
+ select {
+ case <-c.lifetime.donec:
+ return // already set
+ default:
}
- close(c.lifetime.drainingc)
- c.streams.queue.close(c.lifetime.finalErr)
+ c.lifetime.finalErr = err
+ close(c.lifetime.donec)
}
func (c *Conn) waitReady(ctx context.Context) error {
select {
case <-c.lifetime.readyc:
return nil
- case <-c.lifetime.drainingc:
+ case <-c.lifetime.donec:
return c.lifetime.finalErr
default:
}
select {
case <-c.lifetime.readyc:
return nil
- case <-c.lifetime.drainingc:
+ case <-c.lifetime.donec:
return c.lifetime.finalErr
case <-ctx.Done():
return ctx.Err()
@@ -199,7 +245,7 @@
// err := conn.Wait(context.Background())
func (c *Conn) Close() error {
c.Abort(nil)
- <-c.lifetime.drainingc
+ <-c.lifetime.donec
return c.lifetime.finalErr
}
@@ -213,7 +259,7 @@
// containing the peer's error code and reason.
// If the peer closes the connection with any other status, Wait returns a non-nil error.
func (c *Conn) Wait(ctx context.Context) error {
- if err := c.waitOnDone(ctx, c.lifetime.drainingc); err != nil {
+ if err := c.waitOnDone(ctx, c.lifetime.donec); err != nil {
return err
}
return c.lifetime.finalErr
@@ -229,30 +275,46 @@
err = localTransportError{code: errNo}
}
c.sendMsg(func(now time.Time, c *Conn) {
- c.abort(now, err)
+ c.enterClosing(now, err)
})
}
// abort terminates a connection with an error.
func (c *Conn) abort(now time.Time, err error) {
- if c.lifetime.localErr != nil {
- return // already closing
- }
- c.lifetime.localErr = err
+ c.setFinalError(err) // this error takes precedence over the peer's CONNECTION_CLOSE
+ c.enterClosing(now, err)
}
// abortImmediately terminates a connection.
// The connection does not send a CONNECTION_CLOSE, and skips the draining period.
func (c *Conn) abortImmediately(now time.Time, err error) {
- c.abort(now, err)
- c.enterDraining(now, err)
- c.exited = true
+ c.setFinalError(err)
+ c.setState(now, connStateDone)
+}
+
+// enterClosing starts an immediate close.
+// We will send a CONNECTION_CLOSE to the peer and wait for their response.
+func (c *Conn) enterClosing(now time.Time, err error) {
+ switch c.lifetime.state {
+ case connStateAlive:
+ c.lifetime.localErr = err
+ c.setState(now, connStateClosing)
+ case connStatePeerClosed:
+ c.lifetime.localErr = err
+ }
+}
+
+// enterDraining moves directly to the draining state, without sending a CONNECTION_CLOSE.
+func (c *Conn) enterDraining(now time.Time) {
+ switch c.lifetime.state {
+ case connStateAlive, connStatePeerClosed, connStateClosing:
+ c.setState(now, connStateDraining)
+ }
}
// exit fully terminates a connection immediately.
func (c *Conn) exit() {
c.sendMsg(func(now time.Time, c *Conn) {
- c.enterDraining(now, errors.New("connection closed"))
- c.exited = true
+ c.abortImmediately(now, errors.New("connection closed"))
})
}
diff --git a/internal/quic/conn_recv.go b/internal/quic/conn_recv.go
index 896c6d7..156ef5d 100644
--- a/internal/quic/conn_recv.go
+++ b/internal/quic/conn_recv.go
@@ -61,7 +61,7 @@
// Invalid data at the end of a datagram is ignored.
break
}
- c.idleTimeout = now.Add(c.maxIdleTimeout)
+ c.idleHandlePacketReceived(now)
buf = buf[n:]
}
}
@@ -525,7 +525,7 @@
if n < 0 {
return -1
}
- c.enterDraining(now, peerTransportError{code: code, reason: reason})
+ c.handlePeerConnectionClose(now, peerTransportError{code: code, reason: reason})
return n
}
@@ -534,7 +534,7 @@
if n < 0 {
return -1
}
- c.enterDraining(now, &ApplicationError{Code: code, Reason: reason})
+ c.handlePeerConnectionClose(now, &ApplicationError{Code: code, Reason: reason})
return n
}
@@ -548,7 +548,7 @@
})
return -1
}
- if !c.isClosingOrDraining() {
+ if c.isAlive() {
c.confirmHandshake(now)
}
return 1
@@ -560,5 +560,6 @@
if !c.connIDState.isValidStatelessResetToken(resetToken) {
return
}
- c.enterDraining(now, errStatelessReset)
+ c.setFinalError(errStatelessReset)
+ c.enterDraining(now)
}
diff --git a/internal/quic/conn_send.go b/internal/quic/conn_send.go
index 22e7804..e45dc8a 100644
--- a/internal/quic/conn_send.go
+++ b/internal/quic/conn_send.go
@@ -77,6 +77,7 @@
}
sentInitial = c.w.finishProtectedLongHeaderPacket(pnumMaxAcked, c.keysInitial.w, p)
if sentInitial != nil {
+ c.idleHandlePacketSent(now, sentInitial)
// Client initial packets and ack-eliciting server initial packaets
// need to be sent in a datagram padded to at least 1200 bytes.
// We can't add the padding yet, however, since we may want to
@@ -104,6 +105,7 @@
logSentPacket(c, packetTypeHandshake, pnum, p.srcConnID, p.dstConnID, c.w.payload())
}
if sent := c.w.finishProtectedLongHeaderPacket(pnumMaxAcked, c.keysHandshake.w, p); sent != nil {
+ c.idleHandlePacketSent(now, sent)
c.loss.packetSent(now, handshakeSpace, sent)
if c.side == clientSide {
// "[...] a client MUST discard Initial keys when it first
@@ -131,6 +133,7 @@
logSentPacket(c, packetType1RTT, pnum, nil, dstConnID, c.w.payload())
}
if sent := c.w.finish1RTTPacket(pnum, pnumMaxAcked, dstConnID, &c.keysAppData); sent != nil {
+ c.idleHandlePacketSent(now, sent)
c.loss.packetSent(now, appDataSpace, sent)
}
}
@@ -261,6 +264,10 @@
if !c.appendStreamFrames(&c.w, pnum, pto) {
return
}
+
+ if !c.appendKeepAlive(now) {
+ return
+ }
}
// If this is a PTO probe and we haven't added an ack-eliciting frame yet,
@@ -325,7 +332,7 @@
}
func (c *Conn) appendConnectionCloseFrame(now time.Time, space numberSpace, err error) {
- c.lifetime.connCloseSentTime = now
+ c.sentConnectionClose(now)
switch e := err.(type) {
case localTransportError:
c.w.appendConnectionCloseTransportFrame(e.code, 0, e.reason)
@@ -342,11 +349,12 @@
// TLS alerts are sent using error codes [0x0100,0x01ff).
// https://www.rfc-editor.org/rfc/rfc9000#section-20.1-2.36.1
var alert tls.AlertError
- if errors.As(err, &alert) {
+ switch {
+ case errors.As(err, &alert):
// tls.AlertError is a uint8, so this can't exceed 0x01ff.
code := errTLSBase + transportError(alert)
c.w.appendConnectionCloseTransportFrame(code, 0, "")
- } else {
+ default:
c.w.appendConnectionCloseTransportFrame(errInternal, 0, "")
}
}
diff --git a/internal/quic/conn_test.go b/internal/quic/conn_test.go
index 514a877..70ba7b3 100644
--- a/internal/quic/conn_test.go
+++ b/internal/quic/conn_test.go
@@ -25,6 +25,7 @@
func TestConnTestConn(t *testing.T) {
tc := newTestConn(t, serverSide)
+ tc.handshake()
if got, want := tc.timeUntilEvent(), defaultMaxIdleTimeout; got != want {
t.Errorf("new conn timeout=%v, want %v (max_idle_timeout)", got, want)
}
@@ -49,8 +50,8 @@
tc.wait()
tc.advanceToTimer()
- if !tc.conn.exited {
- t.Errorf("after advancing to idle timeout, exited = false, want true")
+ if got := tc.conn.lifetime.state; got != connStateDone {
+ t.Errorf("after advancing to idle timeout, conn state = %v, want done", got)
}
}
diff --git a/internal/quic/idle.go b/internal/quic/idle.go
new file mode 100644
index 0000000..f5b2422
--- /dev/null
+++ b/internal/quic/idle.go
@@ -0,0 +1,170 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build go1.21
+
+package quic
+
+import (
+ "time"
+)
+
+// idleState tracks connection idle events.
+//
+// Before the handshake is confirmed, the idle timeout is Config.HandshakeTimeout.
+//
+// After the handshake is confirmed, the idle timeout is
+// the minimum of Config.MaxIdleTimeout and the peer's max_idle_timeout transport parameter.
+//
+// If KeepAlivePeriod is set, keep-alive pings are sent.
+// Keep-alives are only sent after the handshake is confirmed.
+//
+// https://www.rfc-editor.org/rfc/rfc9000#section-10.1
+type idleState struct {
+ // idleDuration is the negotiated idle timeout for the connection.
+ idleDuration time.Duration
+
+ // idleTimeout is the time at which the connection will be closed due to inactivity.
+ idleTimeout time.Time
+
+ // nextTimeout is the time of the next idle event.
+ // If nextTimeout == idleTimeout, this is the idle timeout.
+ // Otherwise, this is the keep-alive timeout.
+ nextTimeout time.Time
+
+ // sentSinceLastReceive is set if we have sent an ack-eliciting packet
+ // since the last time we received and processed a packet from the peer.
+ sentSinceLastReceive bool
+}
+
+// receivePeerMaxIdleTimeout handles the peer's max_idle_timeout transport parameter.
+func (c *Conn) receivePeerMaxIdleTimeout(peerMaxIdleTimeout time.Duration) {
+ localMaxIdleTimeout := c.config.maxIdleTimeout()
+ switch {
+ case localMaxIdleTimeout == 0:
+ c.idle.idleDuration = peerMaxIdleTimeout
+ case peerMaxIdleTimeout == 0:
+ c.idle.idleDuration = localMaxIdleTimeout
+ default:
+ c.idle.idleDuration = min(localMaxIdleTimeout, peerMaxIdleTimeout)
+ }
+}
+
+func (c *Conn) idleHandlePacketReceived(now time.Time) {
+ if !c.handshakeConfirmed.isSet() {
+ return
+ }
+ // "An endpoint restarts its idle timer when a packet from its peer is
+ // received and processed successfully."
+ // https://www.rfc-editor.org/rfc/rfc9000#section-10.1-3
+ c.idle.sentSinceLastReceive = false
+ c.restartIdleTimer(now)
+}
+
+func (c *Conn) idleHandlePacketSent(now time.Time, sent *sentPacket) {
+ // "An endpoint also restarts its idle timer when sending an ack-eliciting packet
+ // if no other ack-eliciting packets have been sent since
+ // last receiving and processing a packet."
+ // https://www.rfc-editor.org/rfc/rfc9000#section-10.1-3
+ if c.idle.sentSinceLastReceive || !sent.ackEliciting || !c.handshakeConfirmed.isSet() {
+ return
+ }
+ c.idle.sentSinceLastReceive = true
+ c.restartIdleTimer(now)
+}
+
+func (c *Conn) restartIdleTimer(now time.Time) {
+ if !c.isAlive() {
+ // Connection is closing, disable timeouts.
+ c.idle.idleTimeout = time.Time{}
+ c.idle.nextTimeout = time.Time{}
+ return
+ }
+ var idleDuration time.Duration
+ if c.handshakeConfirmed.isSet() {
+ idleDuration = c.idle.idleDuration
+ } else {
+ idleDuration = c.config.handshakeTimeout()
+ }
+ if idleDuration == 0 {
+ c.idle.idleTimeout = time.Time{}
+ } else {
+ // "[...] endpoints MUST increase the idle timeout period to be
+ // at least three times the current Probe Timeout (PTO)."
+ // https://www.rfc-editor.org/rfc/rfc9000#section-10.1-4
+ idleDuration = max(idleDuration, 3*c.loss.ptoPeriod())
+ c.idle.idleTimeout = now.Add(idleDuration)
+ }
+ // Set the time of our next event:
+ // The idle timer if no keep-alive is set, or the keep-alive timer if one is.
+ c.idle.nextTimeout = c.idle.idleTimeout
+ keepAlive := c.config.keepAlivePeriod()
+ switch {
+ case !c.handshakeConfirmed.isSet():
+ // We do not send keep-alives before the handshake is complete.
+ case keepAlive <= 0:
+ // Keep-alives are not enabled.
+ case c.idle.sentSinceLastReceive:
+ // We have sent an ack-eliciting packet to the peer.
+ // If they don't acknowledge it, loss detection will follow up with PTO probes,
+ // which will function as keep-alives.
+ // We don't need to send further pings.
+ case idleDuration == 0:
+ // The connection does not have a negotiated idle timeout.
+ // Send keep-alives anyway, since they may be required to keep middleboxes
+ // from losing state.
+ c.idle.nextTimeout = now.Add(keepAlive)
+ default:
+ // Schedule our next keep-alive.
+ // If our configured keep-alive period is greater than half the negotiated
+ // connection idle timeout, we reduce the keep-alive period to half
+ // the idle timeout to ensure we have time for the ping to arrive.
+ c.idle.nextTimeout = now.Add(min(keepAlive, idleDuration/2))
+ }
+}
+
+func (c *Conn) appendKeepAlive(now time.Time) bool {
+ if c.idle.nextTimeout.IsZero() || c.idle.nextTimeout.After(now) {
+ return true // timer has not expired
+ }
+ if c.idle.nextTimeout.Equal(c.idle.idleTimeout) {
+ return true // no keepalive timer set, only idle
+ }
+ if c.idle.sentSinceLastReceive {
+ return true // already sent an ack-eliciting packet
+ }
+ if c.w.sent.ackEliciting {
+ return true // this packet is already ack-eliciting
+ }
+ // Send an ack-eliciting PING frame to the peer to keep the connection alive.
+ return c.w.appendPingFrame()
+}
+
+var errHandshakeTimeout error = localTransportError{
+ code: errConnectionRefused,
+ reason: "handshake timeout",
+}
+
+func (c *Conn) idleAdvance(now time.Time) (shouldExit bool) {
+ if c.idle.idleTimeout.IsZero() || now.Before(c.idle.idleTimeout) {
+ return false
+ }
+ c.idle.idleTimeout = time.Time{}
+ c.idle.nextTimeout = time.Time{}
+ if !c.handshakeConfirmed.isSet() {
+ // Handshake timeout has expired.
+ // If we're a server, we're refusing the too-slow client.
+ // If we're a client, we're giving up.
+ // In either case, we're going to send a CONNECTION_CLOSE frame and
+ // enter the closing state rather than unceremoniously dropping the connection,
+ // since the peer might still be trying to complete the handshake.
+ c.abort(now, errHandshakeTimeout)
+ return false
+ }
+ // Idle timeout has expired.
+ //
+ // "[...] the connection is silently closed and its state is discarded [...]"
+ // https://www.rfc-editor.org/rfc/rfc9000#section-10.1-1
+ return true
+}
diff --git a/internal/quic/idle_test.go b/internal/quic/idle_test.go
new file mode 100644
index 0000000..18f6a69
--- /dev/null
+++ b/internal/quic/idle_test.go
@@ -0,0 +1,225 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build go1.21
+
+package quic
+
+import (
+ "context"
+ "crypto/tls"
+ "fmt"
+ "testing"
+ "time"
+)
+
+func TestHandshakeTimeoutExpiresServer(t *testing.T) {
+ const timeout = 5 * time.Second
+ tc := newTestConn(t, serverSide, func(c *Config) {
+ c.HandshakeTimeout = timeout
+ })
+ tc.ignoreFrame(frameTypeAck)
+ tc.ignoreFrame(frameTypeNewConnectionID)
+ tc.writeFrames(packetTypeInitial,
+ debugFrameCrypto{
+ data: tc.cryptoDataIn[tls.QUICEncryptionLevelInitial],
+ })
+ // Server starts its end of the handshake.
+ // Client acks these packets to avoid starting the PTO timer.
+ tc.wantFrameType("server sends Initial CRYPTO flight",
+ packetTypeInitial, debugFrameCrypto{})
+ tc.writeAckForAll()
+ tc.wantFrameType("server sends Handshake CRYPTO flight",
+ packetTypeHandshake, debugFrameCrypto{})
+ tc.writeAckForAll()
+
+ if got, want := tc.timerDelay(), timeout; got != want {
+ t.Errorf("connection timer = %v, want %v (handshake timeout)", got, want)
+ }
+
+ // Client sends a packet, but this does not extend the handshake timer.
+ tc.advance(1 * time.Second)
+ tc.writeFrames(packetTypeHandshake, debugFrameCrypto{
+ data: tc.cryptoDataIn[tls.QUICEncryptionLevelHandshake][:1], // partial data
+ })
+ tc.wantIdle("handshake is not complete")
+
+ tc.advance(timeout - 1*time.Second)
+ tc.wantFrame("server closes connection after handshake timeout",
+ packetTypeHandshake, debugFrameConnectionCloseTransport{
+ code: errConnectionRefused,
+ })
+}
+
+func TestHandshakeTimeoutExpiresClient(t *testing.T) {
+ const timeout = 5 * time.Second
+ tc := newTestConn(t, clientSide, func(c *Config) {
+ c.HandshakeTimeout = timeout
+ })
+ tc.ignoreFrame(frameTypeAck)
+ tc.ignoreFrame(frameTypeNewConnectionID)
+ // Start the handshake.
+ // The client always sets a PTO timer until it gets an ack for a handshake packet
+ // or confirms the handshake, so proceed far enough through the handshake to
+ // let us not worry about PTO.
+ tc.wantFrameType("client sends Initial CRYPTO flight",
+ packetTypeInitial, debugFrameCrypto{})
+ tc.writeAckForAll()
+ tc.writeFrames(packetTypeInitial,
+ debugFrameCrypto{
+ data: tc.cryptoDataIn[tls.QUICEncryptionLevelInitial],
+ })
+ tc.writeFrames(packetTypeHandshake,
+ debugFrameCrypto{
+ data: tc.cryptoDataIn[tls.QUICEncryptionLevelHandshake],
+ })
+ tc.wantFrameType("client sends Handshake CRYPTO flight",
+ packetTypeHandshake, debugFrameCrypto{})
+ tc.writeAckForAll()
+ tc.wantIdle("client is waiting for end of handshake")
+
+ if got, want := tc.timerDelay(), timeout; got != want {
+ t.Errorf("connection timer = %v, want %v (handshake timeout)", got, want)
+ }
+ tc.advance(timeout)
+ tc.wantFrame("client closes connection after handshake timeout",
+ packetTypeHandshake, debugFrameConnectionCloseTransport{
+ code: errConnectionRefused,
+ })
+}
+
+func TestIdleTimeoutExpires(t *testing.T) {
+ for _, test := range []struct {
+ localMaxIdleTimeout time.Duration
+ peerMaxIdleTimeout time.Duration
+ wantTimeout time.Duration
+ }{{
+ localMaxIdleTimeout: 10 * time.Second,
+ peerMaxIdleTimeout: 20 * time.Second,
+ wantTimeout: 10 * time.Second,
+ }, {
+ localMaxIdleTimeout: 20 * time.Second,
+ peerMaxIdleTimeout: 10 * time.Second,
+ wantTimeout: 10 * time.Second,
+ }, {
+ localMaxIdleTimeout: 0,
+ peerMaxIdleTimeout: 10 * time.Second,
+ wantTimeout: 10 * time.Second,
+ }, {
+ localMaxIdleTimeout: 10 * time.Second,
+ peerMaxIdleTimeout: 0,
+ wantTimeout: 10 * time.Second,
+ }} {
+ name := fmt.Sprintf("local=%v/peer=%v", test.localMaxIdleTimeout, test.peerMaxIdleTimeout)
+ t.Run(name, func(t *testing.T) {
+ tc := newTestConn(t, serverSide, func(p *transportParameters) {
+ p.maxIdleTimeout = test.peerMaxIdleTimeout
+ }, func(c *Config) {
+ c.MaxIdleTimeout = test.localMaxIdleTimeout
+ })
+ tc.handshake()
+ if got, want := tc.timeUntilEvent(), test.wantTimeout; got != want {
+ t.Errorf("new conn timeout=%v, want %v (idle timeout)", got, want)
+ }
+ tc.advance(test.wantTimeout - 1)
+ tc.wantIdle("connection is idle and alive prior to timeout")
+ ctx := canceledContext()
+ if err := tc.conn.Wait(ctx); err != context.Canceled {
+ t.Fatalf("conn.Wait() = %v, want Canceled", err)
+ }
+ tc.advance(1)
+ tc.wantIdle("connection exits after timeout")
+ if err := tc.conn.Wait(ctx); err != errIdleTimeout {
+ t.Fatalf("conn.Wait() = %v, want errIdleTimeout", err)
+ }
+ })
+ }
+}
+
+func TestIdleTimeoutKeepAlive(t *testing.T) {
+ for _, test := range []struct {
+ idleTimeout time.Duration
+ keepAlive time.Duration
+ wantTimeout time.Duration
+ }{{
+ idleTimeout: 30 * time.Second,
+ keepAlive: 10 * time.Second,
+ wantTimeout: 10 * time.Second,
+ }, {
+ idleTimeout: 10 * time.Second,
+ keepAlive: 30 * time.Second,
+ wantTimeout: 5 * time.Second,
+ }, {
+ idleTimeout: -1, // disabled
+ keepAlive: 30 * time.Second,
+ wantTimeout: 30 * time.Second,
+ }} {
+ name := fmt.Sprintf("idle_timeout=%v/keepalive=%v", test.idleTimeout, test.keepAlive)
+ t.Run(name, func(t *testing.T) {
+ tc := newTestConn(t, serverSide, func(c *Config) {
+ c.MaxIdleTimeout = test.idleTimeout
+ c.KeepAlivePeriod = test.keepAlive
+ })
+ tc.handshake()
+ if got, want := tc.timeUntilEvent(), test.wantTimeout; got != want {
+ t.Errorf("new conn timeout=%v, want %v (keepalive timeout)", got, want)
+ }
+ tc.advance(test.wantTimeout - 1)
+ tc.wantIdle("connection is idle prior to timeout")
+ tc.advance(1)
+ tc.wantFrameType("keep-alive ping is sent", packetType1RTT,
+ debugFramePing{})
+ })
+ }
+}
+
+func TestIdleLongTermKeepAliveSent(t *testing.T) {
+ // This test examines a connection sitting idle and sending periodic keep-alive pings.
+ const keepAlivePeriod = 30 * time.Second
+ tc := newTestConn(t, clientSide, func(c *Config) {
+ c.KeepAlivePeriod = keepAlivePeriod
+ c.MaxIdleTimeout = -1
+ })
+ tc.handshake()
+ // The handshake will have completed a little bit after the point at which the
+ // keepalive timer was set. Send two PING frames to the conn, triggering an immediate ack
+ // and resetting the timer.
+ tc.writeFrames(packetType1RTT, debugFramePing{})
+ tc.writeFrames(packetType1RTT, debugFramePing{})
+ tc.wantFrameType("conn acks received pings", packetType1RTT, debugFrameAck{})
+ for i := 0; i < 10; i++ {
+ tc.wantIdle("conn has nothing more to send")
+ if got, want := tc.timeUntilEvent(), keepAlivePeriod; got != want {
+ t.Errorf("i=%v conn timeout=%v, want %v (keepalive timeout)", i, got, want)
+ }
+ tc.advance(keepAlivePeriod)
+ tc.wantFrameType("keep-alive ping is sent", packetType1RTT,
+ debugFramePing{})
+ tc.writeAckForAll()
+ }
+}
+
+func TestIdleLongTermKeepAliveReceived(t *testing.T) {
+ // This test examines a connection sitting idle, but receiving periodic peer
+ // traffic to keep the connection alive.
+ const idleTimeout = 30 * time.Second
+ tc := newTestConn(t, serverSide, func(c *Config) {
+ c.MaxIdleTimeout = idleTimeout
+ })
+ tc.handshake()
+ for i := 0; i < 10; i++ {
+ tc.advance(idleTimeout - 1*time.Second)
+ tc.writeFrames(packetType1RTT, debugFramePing{})
+ if got, want := tc.timeUntilEvent(), maxAckDelay-timerGranularity; got != want {
+ t.Errorf("i=%v conn timeout=%v, want %v (max_ack_delay)", i, got, want)
+ }
+ tc.advanceToTimer()
+ tc.wantFrameType("conn acks received ping", packetType1RTT, debugFrameAck{})
+ }
+ // Connection is still alive.
+ ctx := canceledContext()
+ if err := tc.conn.Wait(ctx); err != context.Canceled {
+ t.Fatalf("conn.Wait() = %v, want Canceled", err)
+ }
+}
diff --git a/internal/quic/loss.go b/internal/quic/loss.go
index c0f915b..4a0767b 100644
--- a/internal/quic/loss.go
+++ b/internal/quic/loss.go
@@ -431,12 +431,15 @@
c.timer = time.Time{}
return
}
- // https://www.rfc-editor.org/rfc/rfc9002.html#section-6.2.1
- pto := c.ptoBasePeriod() << c.ptoBackoffCount
- c.timer = last.Add(pto)
+ c.timer = last.Add(c.ptoPeriod())
c.ptoTimerArmed = true
}
+func (c *lossState) ptoPeriod() time.Duration {
+ // https://www.rfc-editor.org/rfc/rfc9002.html#section-6.2.1
+ return c.ptoBasePeriod() << c.ptoBackoffCount
+}
+
func (c *lossState) ptoBasePeriod() time.Duration {
// https://www.rfc-editor.org/rfc/rfc9002.html#section-6.2.1
pto := c.rtt.smoothedRTT + max(4*c.rtt.rttvar, timerGranularity)
diff --git a/internal/quic/qlog.go b/internal/quic/qlog.go
index 2987569..c8ee429 100644
--- a/internal/quic/qlog.go
+++ b/internal/quic/qlog.go
@@ -119,8 +119,13 @@
// TODO: Distinguish between peer and locally-initiated close.
trigger = "application"
case localTransportError:
- if e.code == errNo {
- trigger = "clean"
+ switch err {
+ case errHandshakeTimeout:
+ trigger = "handshake_timeout"
+ default:
+ if e.code == errNo {
+ trigger = "clean"
+ }
}
case peerTransportError:
if e.code == errNo {
@@ -128,10 +133,11 @@
}
default:
switch err {
+ case errIdleTimeout:
+ trigger = "idle_timeout"
case errStatelessReset:
trigger = "stateless_reset"
}
- // TODO: idle_timeout, handshake_timeout
}
// https://www.ietf.org/archive/id/draft-ietf-quic-qlog-quic-events-03.html#section-4.3
c.log.LogAttrs(context.Background(), QLogLevelEndpoint,
diff --git a/internal/quic/qlog_test.go b/internal/quic/qlog_test.go
index 5a2858b..119f5d1 100644
--- a/internal/quic/qlog_test.go
+++ b/internal/quic/qlog_test.go
@@ -14,6 +14,7 @@
"log/slog"
"reflect"
"testing"
+ "time"
"golang.org/x/net/internal/quic/qlog"
)
@@ -54,6 +55,75 @@
})
}
+func TestQLogConnectionClosedTrigger(t *testing.T) {
+ for _, test := range []struct {
+ trigger string
+ connOpts []any
+ f func(*testConn)
+ }{{
+ trigger: "clean",
+ f: func(tc *testConn) {
+ tc.handshake()
+ tc.conn.Abort(nil)
+ },
+ }, {
+ trigger: "handshake_timeout",
+ connOpts: []any{
+ func(c *Config) {
+ c.HandshakeTimeout = 5 * time.Second
+ },
+ },
+ f: func(tc *testConn) {
+ tc.ignoreFrame(frameTypeCrypto)
+ tc.ignoreFrame(frameTypeAck)
+ tc.ignoreFrame(frameTypePing)
+ tc.advance(5 * time.Second)
+ },
+ }, {
+ trigger: "idle_timeout",
+ connOpts: []any{
+ func(c *Config) {
+ c.MaxIdleTimeout = 5 * time.Second
+ },
+ },
+ f: func(tc *testConn) {
+ tc.handshake()
+ tc.advance(5 * time.Second)
+ },
+ }, {
+ trigger: "error",
+ f: func(tc *testConn) {
+ tc.handshake()
+ tc.writeFrames(packetType1RTT, debugFrameConnectionCloseTransport{
+ code: errProtocolViolation,
+ })
+ tc.conn.Abort(nil)
+ },
+ }} {
+ t.Run(test.trigger, func(t *testing.T) {
+ qr := &qlogRecord{}
+ tc := newTestConn(t, clientSide, append(test.connOpts, qr.config)...)
+ test.f(tc)
+ fr, ptype := tc.readFrame()
+ switch fr := fr.(type) {
+ case debugFrameConnectionCloseTransport:
+ tc.writeFrames(ptype, fr)
+ case nil:
+ default:
+ t.Fatalf("unexpected frame: %v", fr)
+ }
+ tc.wantIdle("connection should be idle while closing")
+ tc.advance(5 * time.Second) // long enough for the drain timer to expire
+ qr.wantEvents(t, jsonEvent{
+ "name": "connectivity:connection_closed",
+ "data": map[string]any{
+ "trigger": test.trigger,
+ },
+ })
+ })
+ }
+}
+
type nopCloseWriter struct {
io.Writer
}
diff --git a/internal/quic/quic.go b/internal/quic/quic.go
index 084887b..6b60db8 100644
--- a/internal/quic/quic.go
+++ b/internal/quic/quic.go
@@ -54,6 +54,12 @@
maxPeerActiveConnIDLimit = 4
)
+// Time limit for completing the handshake.
+const defaultHandshakeTimeout = 10 * time.Second
+
+// Keep-alive ping frequency.
+const defaultKeepAlivePeriod = 0
+
// Local timer granularity.
// https://www.rfc-editor.org/rfc/rfc9002.html#section-6.1.2-6
const timerGranularity = 1 * time.Millisecond