quic: connection lifetime management

Manage the closing and draining states.

A connection enters the closing state after sending a CONNECTION_CLOSE
frame to terminate the connection.

A connection enters the draining state after receiving a
CONNECTION_CLOSE frame.

Handle retransmission of CONNECTION_CLOSE frames when in the
closing state, and properly ignore received frames when in the
draining state.

RFC 9000, Section 10.2.

For golang/go#58547

Change-Id: I550ca544bffc4de7c5626f87a32c8902d5e2bc86
Reviewed-on: https://go-review.googlesource.com/c/net/+/528016
Auto-Submit: Damien Neil <dneil@google.com>
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.go b/internal/quic/conn.go
index 0063965..26c25f8 100644
--- a/internal/quic/conn.go
+++ b/internal/quic/conn.go
@@ -27,19 +27,15 @@
 
 	msgc   chan any
 	donec  chan struct{} // closed when conn loop exits
-	readyc chan struct{} // closed when TLS handshake completes
 	exited bool          // set to make the conn loop exit immediately
 
 	w           packetWriter
 	acks        [numberSpaceCount]ackState // indexed by number space
+	lifetime    lifetimeState
 	connIDState connIDState
 	loss        lossState
 	streams     streamsState
 
-	// errForPeer is set when the connection is being closed.
-	errForPeer    error
-	connCloseSent [numberSpaceCount]bool
-
 	// 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
@@ -79,7 +75,6 @@
 		peerAddr:             peerAddr,
 		msgc:                 make(chan any, 1),
 		donec:                make(chan struct{}),
-		readyc:               make(chan struct{}),
 		testHooks:            hooks,
 		maxIdleTimeout:       defaultMaxIdleTimeout,
 		idleTimeout:          now.Add(defaultMaxIdleTimeout),
@@ -106,6 +101,7 @@
 	const maxDatagramSize = 1200
 	c.loss.init(c.side, maxDatagramSize, now)
 	c.streamsInit()
+	c.lifetimeInit()
 
 	// TODO: initial_source_connection_id, retry_source_connection_id
 	c.startTLS(now, initialConnID, transportParameters{
@@ -131,14 +127,6 @@
 	return fmt.Sprintf("quic.Conn(%v,->%v)", c.side, c.peerAddr)
 }
 
-func (c *Conn) Close() error {
-	// TODO: Implement shutdown for real.
-	c.runOnLoop(func(now time.Time, c *Conn) {
-		c.exited = true
-	})
-	return nil
-}
-
 // confirmHandshake is called when the handshake is confirmed.
 // https://www.rfc-editor.org/rfc/rfc9001#section-4.1.2
 func (c *Conn) confirmHandshake(now time.Time) {
@@ -241,8 +229,12 @@
 		// since the Initial and Handshake spaces always ack immediately.
 		nextTimeout := sendTimeout
 		nextTimeout = firstTime(nextTimeout, c.idleTimeout)
-		nextTimeout = firstTime(nextTimeout, c.loss.timer)
-		nextTimeout = firstTime(nextTimeout, c.acks[appDataSpace].nextAck)
+		if !c.isClosingOrDraining() {
+			nextTimeout = firstTime(nextTimeout, c.loss.timer)
+			nextTimeout = firstTime(nextTimeout, c.acks[appDataSpace].nextAck)
+		} else {
+			nextTimeout = firstTime(nextTimeout, c.lifetime.drainEndTime)
+		}
 
 		var m any
 		if hooks != nil {
@@ -279,6 +271,11 @@
 				return
 			}
 			c.loss.advance(now, c.handleAckOrLoss)
+			if c.lifetimeAdvance(now) {
+				// The connection has completed the draining period,
+				// and may be shut down.
+				return
+			}
 		case wakeEvent:
 			// We're being woken up to try sending some frames.
 		case func(time.Time, *Conn):
@@ -350,21 +347,6 @@
 	return nil
 }
 
-// abort terminates a connection with an error.
-func (c *Conn) abort(now time.Time, err error) {
-	if c.errForPeer == nil {
-		c.errForPeer = err
-	}
-}
-
-// exit fully terminates a connection immediately.
-func (c *Conn) exit() {
-	c.runOnLoop(func(now time.Time, c *Conn) {
-		c.exited = true
-	})
-	<-c.donec
-}
-
 // firstTime returns the earliest non-zero time, or zero if both times are zero.
 func firstTime(a, b time.Time) time.Time {
 	switch {
diff --git a/internal/quic/conn_close.go b/internal/quic/conn_close.go
new file mode 100644
index 0000000..ec0b7a3
--- /dev/null
+++ b/internal/quic/conn_close.go
@@ -0,0 +1,238 @@
+// 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"
+	"errors"
+	"time"
+)
+
+// 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
+
+	// 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
+	localErr error // error sent to the peer
+	finalErr error // error sent by the peer, or transport error; always set before draining
+
+	connCloseSentTime time.Time     // send time of last CONNECTION_CLOSE frame
+	connCloseDelay    time.Duration // delay until next CONNECTION_CLOSE frame sent
+	drainEndTime      time.Time     // time the connection exits the draining state
+}
+
+func (c *Conn) lifetimeInit() {
+	c.lifetime.readyc = make(chan struct{})
+	c.lifetime.drainingc = make(chan struct{})
+}
+
+var errNoPeerResponse = errors.New("peer did not respond to CONNECTION_CLOSE")
+
+// advance is called when time passes.
+func (c *Conn) lifetimeAdvance(now time.Time) (done bool) {
+	if c.lifetime.drainEndTime.IsZero() || c.lifetime.drainEndTime.After(now) {
+		return false
+	}
+	// 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(errNoPeerResponse)
+	}
+	return true
+}
+
+// confirmHandshake is called when the TLS handshake completes.
+func (c *Conn) handshakeDone() {
+	close(c.lifetime.readyc)
+}
+
+// isDraining reports whether the conn is in the draining state.
+//
+// The draining state is entered once an endpoint receives a CONNECTION_CLOSE frame.
+// The endpoint will no longer send any packets, but we retain knowledge of the connection
+// until the end of the drain period to ensure we discard packets for the connection
+// rather than treating them as starting a new connection.
+//
+// https://www.rfc-editor.org/rfc/rfc9000.html#section-10.2.2
+func (c *Conn) isDraining() bool {
+	return c.lifetime.finalErr != nil
+}
+
+// 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
+}
+
+// sendOK reports whether the conn can send frames at this time.
+func (c *Conn) sendOK(now time.Time) bool {
+	if !c.isClosingOrDraining() {
+		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.
+		return false
+	}
+	// 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())
+	}
+	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,
+		// but leaves the implementation of the limit up to us. Here, we start
+		// 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
+	}
+	c.lifetime.connCloseSentTime = now
+	c.lifetime.connCloseDelay *= 2
+	return true
+}
+
+// enterDraining enters the draining state.
+func (c *Conn) enterDraining(err error) {
+	if c.isDraining() {
+		return
+	}
+	if e, ok := c.lifetime.localErr.(localTransportError); ok && transportError(e) != 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
+	}
+	close(c.lifetime.drainingc)
+	c.streams.queue.close(c.lifetime.finalErr)
+}
+
+func (c *Conn) waitReady(ctx context.Context) error {
+	select {
+	case <-c.lifetime.readyc:
+		return nil
+	case <-c.lifetime.drainingc:
+		return c.lifetime.finalErr
+	case <-ctx.Done():
+		return ctx.Err()
+	}
+}
+
+// Close closes the connection.
+//
+// Close is equivalent to:
+//
+//	conn.Abort(nil)
+//	err := conn.Wait(context.Background())
+func (c *Conn) Close() error {
+	c.Abort(nil)
+	<-c.lifetime.drainingc
+	return c.lifetime.finalErr
+}
+
+// Wait waits for the peer to close the connection.
+//
+// If the connection is closed locally and the peer does not close its end of the connection,
+// Wait will return with a non-nil error after the drain period expires.
+//
+// If the peer closes the connection with a NO_ERROR transport error, Wait returns nil.
+// If the peer closes the connection with an application error, Wait returns an ApplicationError
+// 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 {
+		return err
+	}
+	return c.lifetime.finalErr
+}
+
+// Abort closes the connection and returns immediately.
+//
+// If err is nil, Abort sends a transport error of NO_ERROR to the peer.
+// If err is an ApplicationError, Abort sends its error code and text.
+// Otherwise, Abort sends a transport error of APPLICATION_ERROR with the error's text.
+func (c *Conn) Abort(err error) {
+	if err == nil {
+		err = localTransportError(errNo)
+	}
+	c.runOnLoop(func(now time.Time, c *Conn) {
+		c.abort(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
+}
+
+// exit fully terminates a connection immediately.
+func (c *Conn) exit() {
+	c.runOnLoop(func(now time.Time, c *Conn) {
+		c.enterDraining(errors.New("connection closed"))
+		c.exited = true
+	})
+	<-c.donec
+}
diff --git a/internal/quic/conn_close_test.go b/internal/quic/conn_close_test.go
new file mode 100644
index 0000000..20c00e7
--- /dev/null
+++ b/internal/quic/conn_close_test.go
@@ -0,0 +1,186 @@
+// 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"
+	"errors"
+	"testing"
+	"time"
+)
+
+func TestConnCloseResponseBackoff(t *testing.T) {
+	tc := newTestConn(t, clientSide)
+	tc.handshake()
+
+	tc.conn.Abort(nil)
+	tc.wantFrame("aborting connection generates CONN_CLOSE",
+		packetType1RTT, debugFrameConnectionCloseTransport{
+			code: errNo,
+		})
+
+	waiting := runAsync(tc, func(ctx context.Context) (struct{}, error) {
+		return struct{}{}, tc.conn.Wait(ctx)
+	})
+	if _, err := waiting.result(); err != errNotDone {
+		t.Errorf("conn.Wait() = %v, want still waiting", err)
+	}
+
+	tc.writeFrames(packetType1RTT, debugFramePing{})
+	tc.wantIdle("packets received immediately after CONN_CLOSE receive no response")
+
+	tc.advance(1100 * time.Microsecond)
+	tc.writeFrames(packetType1RTT, debugFramePing{})
+	tc.wantFrame("receiving packet 1.1ms after CONN_CLOSE generates another CONN_CLOSE",
+		packetType1RTT, debugFrameConnectionCloseTransport{
+			code: errNo,
+		})
+
+	tc.advance(1100 * time.Microsecond)
+	tc.writeFrames(packetType1RTT, debugFramePing{})
+	tc.wantIdle("no response to packet, because CONN_CLOSE backoff is now 2ms")
+
+	tc.advance(1000 * time.Microsecond)
+	tc.writeFrames(packetType1RTT, debugFramePing{})
+	tc.wantFrame("2ms since last CONN_CLOSE, receiving a packet generates another CONN_CLOSE",
+		packetType1RTT, debugFrameConnectionCloseTransport{
+			code: errNo,
+		})
+	if _, err := waiting.result(); err != errNotDone {
+		t.Errorf("conn.Wait() = %v, want still waiting", err)
+	}
+
+	tc.advance(100000 * time.Microsecond)
+	tc.writeFrames(packetType1RTT, debugFramePing{})
+	tc.wantIdle("drain timer expired, no more responses")
+
+	if _, err := waiting.result(); !errors.Is(err, errNoPeerResponse) {
+		t.Errorf("blocked conn.Wait() = %v, want errNoPeerResponse", err)
+	}
+	if err := tc.conn.Wait(canceledContext()); !errors.Is(err, errNoPeerResponse) {
+		t.Errorf("non-blocking conn.Wait() = %v, want errNoPeerResponse", err)
+	}
+}
+
+func TestConnCloseWithPeerResponse(t *testing.T) {
+	tc := newTestConn(t, clientSide)
+	tc.handshake()
+
+	tc.conn.Abort(nil)
+	tc.wantFrame("aborting connection generates CONN_CLOSE",
+		packetType1RTT, debugFrameConnectionCloseTransport{
+			code: errNo,
+		})
+
+	waiting := runAsync(tc, func(ctx context.Context) (struct{}, error) {
+		return struct{}{}, tc.conn.Wait(ctx)
+	})
+	if _, err := waiting.result(); err != errNotDone {
+		t.Errorf("conn.Wait() = %v, want still waiting", err)
+	}
+
+	tc.writeFrames(packetType1RTT, debugFrameConnectionCloseApplication{
+		code: 20,
+	})
+
+	wantErr := &ApplicationError{
+		Code: 20,
+	}
+	if _, err := waiting.result(); !errors.Is(err, wantErr) {
+		t.Errorf("blocked conn.Wait() = %v, want %v", err, wantErr)
+	}
+	if err := tc.conn.Wait(canceledContext()); !errors.Is(err, wantErr) {
+		t.Errorf("non-blocking conn.Wait() = %v, want %v", err, wantErr)
+	}
+}
+
+func TestConnClosePeerCloses(t *testing.T) {
+	tc := newTestConn(t, clientSide)
+	tc.handshake()
+
+	wantErr := &ApplicationError{
+		Code:   42,
+		Reason: "why?",
+	}
+	tc.writeFrames(packetType1RTT, debugFrameConnectionCloseApplication{
+		code:   wantErr.Code,
+		reason: wantErr.Reason,
+	})
+	tc.wantIdle("CONN_CLOSE response not sent until user closes this side")
+
+	if err := tc.conn.Wait(canceledContext()); !errors.Is(err, wantErr) {
+		t.Errorf("conn.Wait() = %v, want %v", err, wantErr)
+	}
+
+	tc.conn.Abort(&ApplicationError{
+		Code:   9,
+		Reason: "because",
+	})
+	tc.wantFrame("CONN_CLOSE sent after user closes connection",
+		packetType1RTT, debugFrameConnectionCloseApplication{
+			code:   9,
+			reason: "because",
+		})
+}
+
+func TestConnCloseReceiveInInitial(t *testing.T) {
+	tc := newTestConn(t, clientSide)
+	tc.wantFrame("client sends Initial CRYPTO frame",
+		packetTypeInitial, debugFrameCrypto{
+			data: tc.cryptoDataOut[tls.QUICEncryptionLevelInitial],
+		})
+	tc.writeFrames(packetTypeInitial, debugFrameConnectionCloseTransport{
+		code: errConnectionRefused,
+	})
+	tc.wantIdle("CONN_CLOSE response not sent until user closes this side")
+
+	wantErr := peerTransportError{code: errConnectionRefused}
+	if err := tc.conn.Wait(canceledContext()); !errors.Is(err, wantErr) {
+		t.Errorf("conn.Wait() = %v, want %v", err, wantErr)
+	}
+
+	tc.conn.Abort(&ApplicationError{Code: 1})
+	tc.wantFrame("CONN_CLOSE in Initial frame is APPLICATION_ERROR",
+		packetTypeInitial, debugFrameConnectionCloseTransport{
+			code: errApplicationError,
+		})
+	tc.wantIdle("no more frames to send")
+}
+
+func TestConnCloseReceiveInHandshake(t *testing.T) {
+	tc := newTestConn(t, clientSide)
+	tc.ignoreFrame(frameTypeAck)
+	tc.wantFrame("client sends Initial CRYPTO frame",
+		packetTypeInitial, debugFrameCrypto{
+			data: tc.cryptoDataOut[tls.QUICEncryptionLevelInitial],
+		})
+	tc.writeFrames(packetTypeInitial, debugFrameCrypto{
+		data: tc.cryptoDataIn[tls.QUICEncryptionLevelInitial],
+	})
+	tc.writeFrames(packetTypeHandshake, debugFrameConnectionCloseTransport{
+		code: errConnectionRefused,
+	})
+	tc.wantIdle("CONN_CLOSE response not sent until user closes this side")
+
+	wantErr := peerTransportError{code: errConnectionRefused}
+	if err := tc.conn.Wait(canceledContext()); !errors.Is(err, wantErr) {
+		t.Errorf("conn.Wait() = %v, want %v", err, wantErr)
+	}
+
+	// The conn has Initial and Handshake keys, so it will send CONN_CLOSE in both spaces.
+	tc.conn.Abort(&ApplicationError{Code: 1})
+	tc.wantFrame("CONN_CLOSE in Initial frame is APPLICATION_ERROR",
+		packetTypeInitial, debugFrameConnectionCloseTransport{
+			code: errApplicationError,
+		})
+	tc.wantFrame("CONN_CLOSE in Handshake frame is APPLICATION_ERROR",
+		packetTypeHandshake, debugFrameConnectionCloseTransport{
+			code: errApplicationError,
+		})
+	tc.wantIdle("no more frames to send")
+}
diff --git a/internal/quic/conn_recv.go b/internal/quic/conn_recv.go
index 92ee8ea..64e5f98 100644
--- a/internal/quic/conn_recv.go
+++ b/internal/quic/conn_recv.go
@@ -13,6 +13,9 @@
 func (c *Conn) handleDatagram(now time.Time, dgram *datagram) {
 	buf := dgram.b
 	c.loss.datagramReceived(now, len(buf))
+	if c.isDraining() {
+		return
+	}
 	for len(buf) > 0 {
 		var n int
 		ptype := getPacketType(buf)
@@ -220,15 +223,13 @@
 			}
 			n = c.handleRetireConnectionIDFrame(now, space, payload)
 		case frameTypeConnectionCloseTransport:
-			// CONNECTION_CLOSE is OK in all spaces.
-			_, _, _, n = consumeConnectionCloseTransportFrame(payload)
-			// TODO: https://www.rfc-editor.org/rfc/rfc9000.html#section-10.2.2
-			c.abort(now, localTransportError(errNo))
+			// Transport CONNECTION_CLOSE is OK in all spaces.
+			n = c.handleConnectionCloseTransportFrame(now, payload)
 		case frameTypeConnectionCloseApplication:
-			// CONNECTION_CLOSE is OK in all spaces.
-			_, _, n = consumeConnectionCloseApplicationFrame(payload)
-			// TODO: https://www.rfc-editor.org/rfc/rfc9000.html#section-10.2.2
-			c.abort(now, localTransportError(errNo))
+			if !frameOK(c, ptype, __01) {
+				return
+			}
+			n = c.handleConnectionCloseApplicationFrame(now, payload)
 		case frameTypeHandshakeDone:
 			if !frameOK(c, ptype, ___1) {
 				return
@@ -385,6 +386,24 @@
 	return n
 }
 
+func (c *Conn) handleConnectionCloseTransportFrame(now time.Time, payload []byte) int {
+	code, _, reason, n := consumeConnectionCloseTransportFrame(payload)
+	if n < 0 {
+		return -1
+	}
+	c.enterDraining(peerTransportError{code: code, reason: reason})
+	return n
+}
+
+func (c *Conn) handleConnectionCloseApplicationFrame(now time.Time, payload []byte) int {
+	code, reason, n := consumeConnectionCloseApplicationFrame(payload)
+	if n < 0 {
+		return -1
+	}
+	c.enterDraining(&ApplicationError{Code: code, Reason: reason})
+	return n
+}
+
 func (c *Conn) handleHandshakeDoneFrame(now time.Time, space numberSpace, payload []byte) int {
 	if c.side == serverSide {
 		// Clients should never send HANDSHAKE_DONE.
@@ -392,6 +411,8 @@
 		c.abort(now, localTransportError(errProtocolViolation))
 		return -1
 	}
-	c.confirmHandshake(now)
+	if !c.isClosingOrDraining() {
+		c.confirmHandshake(now)
+	}
 	return 1
 }
diff --git a/internal/quic/conn_send.go b/internal/quic/conn_send.go
index 9d315fb..853c845 100644
--- a/internal/quic/conn_send.go
+++ b/internal/quic/conn_send.go
@@ -16,6 +16,8 @@
 //
 // If sending is blocked by pacing, it returns the next time
 // a datagram may be sent.
+//
+// If sending is blocked indefinitely, it returns the zero Time.
 func (c *Conn) maybeSend(now time.Time) (next time.Time) {
 	// Assumption: The congestion window is not underutilized.
 	// If congestion control, pacing, and anti-amplification all permit sending,
@@ -39,6 +41,9 @@
 			// If anti-amplification blocks sending, then no packet can be sent.
 			return next
 		}
+		if !c.sendOK(now) {
+			return time.Time{}
+		}
 		// We may still send ACKs, even if congestion control or pacing limit sending.
 
 		// Prepare to write a datagram of at most maxSendSize bytes.
@@ -162,23 +167,8 @@
 }
 
 func (c *Conn) appendFrames(now time.Time, space numberSpace, pnum packetNumber, limit ccLimit) {
-	if c.errForPeer != nil {
-		// This is the bare minimum required to send a CONNECTION_CLOSE frame
-		// when closing a connection immediately, for example in response to a
-		// protocol error.
-		//
-		// This does not handle the closing and draining states
-		// (https://www.rfc-editor.org/rfc/rfc9000.html#section-10.2),
-		// but it's enough to let us write tests that result in a CONNECTION_CLOSE,
-		// and have those tests still pass when we finish implementing
-		// connection shutdown.
-		//
-		// TODO: Finish implementing connection shutdown.
-		if !c.connCloseSent[space] {
-			c.exited = true
-			c.appendConnectionCloseFrame(c.errForPeer)
-			c.connCloseSent[space] = true
-		}
+	if c.lifetime.localErr != nil {
+		c.appendConnectionCloseFrame(now, space, c.lifetime.localErr)
 		return
 	}
 
@@ -322,11 +312,20 @@
 	return c.w.appendAckFrame(seen, d)
 }
 
-func (c *Conn) appendConnectionCloseFrame(err error) {
-	// TODO: Send application errors.
+func (c *Conn) appendConnectionCloseFrame(now time.Time, space numberSpace, err error) {
+	c.lifetime.connCloseSentTime = now
 	switch e := err.(type) {
 	case localTransportError:
 		c.w.appendConnectionCloseTransportFrame(transportError(e), 0, "")
+	case *ApplicationError:
+		if space != appDataSpace {
+			// "CONNECTION_CLOSE frames signaling application errors (type 0x1d)
+			// MUST only appear in the application data packet number space."
+			// https://www.rfc-editor.org/rfc/rfc9000#section-12.5-2.2
+			c.w.appendConnectionCloseTransportFrame(errApplicationError, 0, "")
+		} else {
+			c.w.appendConnectionCloseApplicationFrame(e.Code, e.Reason)
+		}
 	default:
 		// TLS alerts are sent using error codes [0x0100,0x01ff).
 		// https://www.rfc-editor.org/rfc/rfc9000#section-20.1-2.36.1
@@ -335,8 +334,8 @@
 			// tls.AlertError is a uint8, so this can't exceed 0x01ff.
 			code := errTLSBase + transportError(alert)
 			c.w.appendConnectionCloseTransportFrame(code, 0, "")
-			return
+		} else {
+			c.w.appendConnectionCloseTransportFrame(errInternal, 0, "")
 		}
-		c.w.appendConnectionCloseTransportFrame(errInternal, 0, "")
 	}
 }
diff --git a/internal/quic/conn_test.go b/internal/quic/conn_test.go
index cdbd466..4228ce7 100644
--- a/internal/quic/conn_test.go
+++ b/internal/quic/conn_test.go
@@ -304,6 +304,8 @@
 	select {
 	case <-idlec:
 	case <-tc.conn.donec:
+		// We may have async ops that can proceed now that the conn is done.
+		tc.wakeAsync()
 	}
 	if fail {
 		panic(fail)
diff --git a/internal/quic/errors.go b/internal/quic/errors.go
index f156859..8e01bb7 100644
--- a/internal/quic/errors.go
+++ b/internal/quic/errors.go
@@ -114,7 +114,13 @@
 	Reason string
 }
 
-func (e ApplicationError) Error() string {
+func (e *ApplicationError) Error() string {
 	// TODO: Include the Reason string here, but sanitize it first.
 	return fmt.Sprintf("AppError %v", e.Code)
 }
+
+// Is reports a match if err is an *ApplicationError with a matching Code.
+func (e *ApplicationError) Is(err error) bool {
+	e2, ok := err.(*ApplicationError)
+	return ok && e2.Code == e.Code
+}
diff --git a/internal/quic/listener.go b/internal/quic/listener.go
index 9869f6e..a84286e 100644
--- a/internal/quic/listener.go
+++ b/internal/quic/listener.go
@@ -141,11 +141,9 @@
 	if err != nil {
 		return nil, err
 	}
-	select {
-	case <-c.readyc:
-	case <-ctx.Done():
-		c.Close()
-		return nil, ctx.Err()
+	if err := c.waitReady(ctx); err != nil {
+		c.Abort(nil)
+		return nil, err
 	}
 	return c, nil
 }
diff --git a/internal/quic/tls.go b/internal/quic/tls.go
index 1d07f17..e3a430e 100644
--- a/internal/quic/tls.go
+++ b/internal/quic/tls.go
@@ -73,7 +73,7 @@
 				// https://www.rfc-editor.org/rfc/rfc9001#section-4.1.2-1
 				c.confirmHandshake(now)
 			}
-			close(c.readyc)
+			c.handshakeDone()
 		case tls.QUICTransportParameters:
 			params, err := unmarshalTransportParams(e.Data)
 			if err != nil {
diff --git a/internal/quic/tls_test.go b/internal/quic/tls_test.go
index 0f22f4f..1c7b36d 100644
--- a/internal/quic/tls_test.go
+++ b/internal/quic/tls_test.go
@@ -353,6 +353,7 @@
 
 	tc.writeFrames(packetType1RTT,
 		debugFrameConnectionCloseTransport{code: errInternal})
+	tc.conn.Abort(nil)
 	tc.wantFrame("client closes connection after 1-RTT CONNECTION_CLOSE",
 		packetType1RTT, debugFrameConnectionCloseTransport{
 			code: errNo,
@@ -406,6 +407,7 @@
 
 	tc.writeFrames(packetType1RTT,
 		debugFrameConnectionCloseTransport{code: errInternal})
+	tc.conn.Abort(nil)
 	tc.wantFrame("server closes connection after 1-RTT CONNECTION_CLOSE",
 		packetType1RTT, debugFrameConnectionCloseTransport{
 			code: errNo,