quic: send occasional ack-eliciting packets

A receiver that is sending only non-ack-eliciting packets
(for example, a connection reading data from a stream but not sending
anything other than ACKs in response) can accumulate a large amount
of state for in-flight, unacknowledged packets.

Add an occasional PING frame when in this state, to cause the peer
to send an ACK for our outstanding packets.

Change-Id: Iaf6b5a9735fa356fdebaff24200420a280b0c9a5
Reviewed-on: https://go-review.googlesource.com/c/net/+/545215
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/conn_send.go b/internal/quic/conn_send.go
index e2240f2..a8d9308 100644
--- a/internal/quic/conn_send.go
+++ b/internal/quic/conn_send.go
@@ -222,11 +222,7 @@
 			// Either we are willing to send an ACK-only packet,
 			// or we've added additional frames.
 			c.acks[space].sentAck()
-			if !c.w.sent.ackEliciting && c.keysAppData.needAckEliciting() {
-				// The peer has initiated a key update.
-				// We haven't sent them any packets yet in the new phase.
-				// Make this an ack-eliciting packet.
-				// Their ack of this packet will complete the key update.
+			if !c.w.sent.ackEliciting && c.shouldMakePacketAckEliciting() {
 				c.w.appendPingFrame()
 			}
 		}()
@@ -331,6 +327,30 @@
 	}
 }
 
+// shouldMakePacketAckEliciting is called when sending a packet containing nothing but an ACK frame.
+// It reports whether we should add a PING frame to the packet to make it ack-eliciting.
+func (c *Conn) shouldMakePacketAckEliciting() bool {
+	if c.keysAppData.needAckEliciting() {
+		// The peer has initiated a key update.
+		// We haven't sent them any packets yet in the new phase.
+		// Make this an ack-eliciting packet.
+		// Their ack of this packet will complete the key update.
+		return true
+	}
+	if c.loss.consecutiveNonAckElicitingPackets >= 19 {
+		// We've sent a run of non-ack-eliciting packets.
+		// Add in an ack-eliciting one every once in a while so the peer
+		// lets us know which ones have arrived.
+		//
+		// Google QUICHE injects a PING after sending 19 packets. We do the same.
+		//
+		// https://www.rfc-editor.org/rfc/rfc9000#section-13.2.4-2
+		return true
+	}
+	// TODO: Consider making every packet sent when in PTO ack-eliciting to speed up recovery.
+	return false
+}
+
 func (c *Conn) appendAckFrame(now time.Time, space numberSpace) bool {
 	seen, delay := c.acks[space].acksToSend(now)
 	if len(seen) == 0 {
diff --git a/internal/quic/conn_send_test.go b/internal/quic/conn_send_test.go
new file mode 100644
index 0000000..822783c
--- /dev/null
+++ b/internal/quic/conn_send_test.go
@@ -0,0 +1,40 @@
+// 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 (
+	"testing"
+	"time"
+)
+
+func TestAckElicitingAck(t *testing.T) {
+	// "A receiver that sends only non-ack-eliciting packets [...] might not receive
+	// an acknowledgment for a long period of time.
+	// [...] a receiver could send a [...] ack-eliciting frame occasionally [...]
+	// to elicit an ACK from the peer."
+	// https://www.rfc-editor.org/rfc/rfc9000#section-13.2.4-2
+	//
+	// Send a bunch of ack-eliciting packets, verify that the conn doesn't just
+	// send ACKs in response.
+	tc := newTestConn(t, clientSide, permissiveTransportParameters)
+	tc.handshake()
+	const count = 100
+	for i := 0; i < count; i++ {
+		tc.advance(1 * time.Millisecond)
+		tc.writeFrames(packetType1RTT,
+			debugFramePing{},
+		)
+		got, _ := tc.readFrame()
+		switch got.(type) {
+		case debugFrameAck:
+			continue
+		case debugFramePing:
+			return
+		}
+	}
+	t.Errorf("after sending %v PINGs, got no ack-eliciting response", count)
+}
diff --git a/internal/quic/loss.go b/internal/quic/loss.go
index 4a0767b..a59081f 100644
--- a/internal/quic/loss.go
+++ b/internal/quic/loss.go
@@ -50,6 +50,9 @@
 	// https://www.rfc-editor.org/rfc/rfc9000#section-8-2
 	antiAmplificationLimit int
 
+	// Count of non-ack-eliciting packets (ACKs) sent since the last ack-eliciting one.
+	consecutiveNonAckElicitingPackets int
+
 	rtt   rttState
 	pacer pacerState
 	cc    *ccReno
@@ -192,6 +195,11 @@
 		}
 		c.scheduleTimer(now)
 	}
+	if sent.ackEliciting {
+		c.consecutiveNonAckElicitingPackets = 0
+	} else {
+		c.consecutiveNonAckElicitingPackets++
+	}
 }
 
 // datagramReceived records a datagram (not packet!) received from the peer.