Repository: quic-go/quic-go
Pull request #4230 (2024-01-02 22:40:47)

http3: add ConnContext to the server
ConnContext can be used to modify the context used by a new http Request. This is equivalent to `ConnContext` in `http.Server`, but with a `quic.Connection` instead of a `net.Conn`.

```
// ConnContext optionally specifies a function that modifies
// the context used for a new connection c. The provided ctx
// has a ServerContextKey value.
ConnContext func(ctx context.Context, c quic.Connection) context.Context
```

google-cla[bot]: Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this [failed invocation](https://github.com/quic-go/quic-go/pull/4230/checks?check_run_id=20107714809) of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.
rthellend: > Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).
> 
> View this [failed invocation](https://github.com/quic-go/quic-go/pull/4230/checks?check_run_id=20107714809) of the CLA check for more information.
> 
> For the most up to date status, view the checks section at the bottom of the pull request.

This check is ✔️ now
marten-seemann: Have you seen the discussion in #3603? Is this PR attempting to resolve that issue? If so, can you explain how your PR resolves the problems raised there?
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/quic-go/quic-go/pull/4230?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`1083d1f`)](https://app.codecov.io/gh/quic-go/quic-go/commit/1083d1fb8f98a9ba787fc33fdec405196e9d3108?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) 84.15% compared to head [(`d7ba575`)](https://app.codecov.io/gh/quic-go/quic-go/pull/4230?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) 84.14%.


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #4230      +/-   ##
==========================================
- Coverage   84.15%   84.14%   -0.02%     
==========================================
  Files         149      149              
  Lines       15386    15389       +3     
==========================================
  Hits        12948    12948              
- Misses       1941     1943       +2     
- Partials      497      498       +1     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/quic-go/quic-go/pull/4230?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go).

rthellend: > Have you seen the discussion in #3603? Is this PR attempting to resolve that issue? If so, can you explain how your PR resolves the problems raised there?

I had not actually seen that discussion. Thanks for pointing it out. I would generally agree with the motivation behind it.

The way I use `ConnContext` is pretty straight forward. I wrap the underlying connection (`tls.Conn` or `quic.Connection` here) in my own struct so that I can keep track of metrics and some annotations for each connection. The http handlers need access to some of that data to do what they need to do. (Happy to provide more details if you're interested)

Here's a concrete example the wrapper for `net.Conn`: https://github.com/c2FmZQ/tlsproxy/blob/main/proxy/internal/netw/netw.go#L82

I have something similar for `quic.Connection`.

With `http.Server`, I have:
```
    ConnContext: func(ctx context.Context, c net.Conn) context.Context {
        return context.WithValue(ctx, connCtxKey, c)
     },
```

With `http3.Server`, I would have:
```
    ConnContext: func(ctx context.Context, c quic.Connection) context.Context {
        return context.WithValue(ctx, connCtxKey, c)
    },
```

Then, when the handler needs to access the wrapper's data, it does something like:
```
if wrappedConn, ok := req.Context().Value(connCtxKey).(*netw.Conn); ok {
  // use wrappedConn
}
```
The alternative I've been using so far is to create a new `http3.Server` for each connection and then call `ServeQUICConn(conn)`:
```
    conn := ...

    serv := &http3.Server{
        Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
            req = req.WithContext(context.WithValue(req.Context(), connCtxKey, conn))
            realHandler.ServeHTTP(w, req)
         }),
    }
    serv.ServeQUICConn(conn);
```
It works, but it is not very elegant.

Patch:
diff --git a/README.md b/README.md
index a43375e9..d8b060f1 100644
--- a/README.md
+++ b/README.md
@@ -237,6 +237,7 @@ http.Client{
 | [Hysteria](https://github.com/apernet/hysteria)           | A powerful, lightning fast and censorship resistant proxy                                                                                                         | ![GitHub Repo stars](https://img.shields.io/github/stars/apernet/hysteria?style=flat-square)        |
 | [Mercure](https://github.com/dunglas/mercure)             | An open, easy, fast, reliable and battery-efficient solution for real-time communications                                                                         | ![GitHub Repo stars](https://img.shields.io/github/stars/dunglas/mercure?style=flat-square)         |
 | [OONI Probe](https://github.com/ooni/probe-cli)           | Next generation OONI Probe. Library and CLI tool.                                                                                                                 | ![GitHub Repo stars](https://img.shields.io/github/stars/ooni/probe-cli?style=flat-square)          |
+| [RoadRunner](https://github.com/roadrunner-server/roadrunner) | High-performance PHP application server, process manager written in Go and powered with plugins | ![GitHub Repo stars](https://img.shields.io/github/stars/roadrunner-server/roadrunner?style=flat-square) |
 | [syncthing](https://github.com/syncthing/syncthing/)      | Open Source Continuous File Synchronization                                                                                                                       | ![GitHub Repo stars](https://img.shields.io/github/stars/syncthing/syncthing?style=flat-square)     |
 | [traefik](https://github.com/traefik/traefik)             | The Cloud Native Application Proxy                                                                                                                                | ![GitHub Repo stars](https://img.shields.io/github/stars/traefik/traefik?style=flat-square)         |
 | [v2ray-core](https://github.com/v2fly/v2ray-core)         | A platform for building proxies to bypass network restrictions                                                                                                    | ![GitHub Repo stars](https://img.shields.io/github/stars/v2fly/v2ray-core?style=flat-square)        |
diff --git a/fuzzing/transportparameters/cmd/corpus.go b/fuzzing/transportparameters/cmd/corpus.go
index 9e59cfba..282e7d33 100644
--- a/fuzzing/transportparameters/cmd/corpus.go
+++ b/fuzzing/transportparameters/cmd/corpus.go
@@ -3,7 +3,7 @@ package main
 import (
 	"log"
 	"math"
-	"net"
+	"net/netip"
 	"time"
 
 	"golang.org/x/exp/rand"
@@ -59,11 +59,13 @@ func main() {
 		if rand.Int()%2 == 0 {
 			var token protocol.StatelessResetToken
 			rand.Read(token[:])
+			var ip4 [4]byte
+			rand.Read(ip4[:])
+			var ip6 [16]byte
+			rand.Read(ip6[:])
 			tp.PreferredAddress = &wire.PreferredAddress{
-				IPv4:                net.IPv4(uint8(rand.Int()), uint8(rand.Int()), uint8(rand.Int()), uint8(rand.Int())),
-				IPv4Port:            uint16(rand.Int()),
-				IPv6:                net.IP(getRandomData(16)),
-				IPv6Port:            uint16(rand.Int()),
+				IPv4:                netip.AddrPortFrom(netip.AddrFrom4(ip4), uint16(rand.Int())),
+				IPv6:                netip.AddrPortFrom(netip.AddrFrom16(ip6), uint16(rand.Int())),
 				ConnectionID:        protocol.ParseConnectionID(getRandomData(rand.Intn(21))),
 				StatelessResetToken: token,
 			}
diff --git a/http3/server.go b/http3/server.go
index ca586f0d..769942c1 100644
--- a/http3/server.go
+++ b/http3/server.go
@@ -211,6 +211,11 @@ type Server struct {
 	// In that case, the stream type will not be set.
 	UniStreamHijacker func(StreamType, quic.Connection, quic.ReceiveStream, error) (hijacked bool)
 
+	// ConnContext optionally specifies a function that modifies
+	// the context used for a new connection c. The provided ctx
+	// has a ServerContextKey value.
+	ConnContext func(ctx context.Context, c quic.Connection) context.Context
+
 	mutex     sync.RWMutex
 	listeners map[*QUICEarlyListener]listenerInfo
 
@@ -610,6 +615,12 @@ func (s *Server) handleRequest(conn quic.Connection, str quic.Stream, decoder *q
 	ctx = context.WithValue(ctx, ServerContextKey, s)
 	ctx = context.WithValue(ctx, http.LocalAddrContextKey, conn.LocalAddr())
 	ctx = context.WithValue(ctx, RemoteAddrContextKey, conn.RemoteAddr())
+	if s.ConnContext != nil {
+		ctx = s.ConnContext(ctx, conn)
+		if ctx == nil {
+			panic("http3: ConnContext returned nil")
+		}
+	}
 	req = req.WithContext(ctx)
 	r := newResponseWriter(str, conn, s.logger)
 	if req.Method == http.MethodHead {
diff --git a/internal/ackhandler/received_packet_tracker.go b/internal/ackhandler/received_packet_tracker.go
index 6d8eec4e..fec863e4 100644
--- a/internal/ackhandler/received_packet_tracker.go
+++ b/internal/ackhandler/received_packet_tracker.go
@@ -56,10 +56,6 @@ func (h *receivedPacketTracker) ReceivedPacket(pn protocol.PacketNumber, ecn pro
 		h.largestObservedRcvdTime = rcvTime
 	}
 
-	if ackEliciting {
-		h.hasNewAck = true
-		h.maybeQueueACK(pn, rcvTime, ecn, isMissing)
-	}
 	//nolint:exhaustive // Only need to count ECT(0), ECT(1) and ECN-CE.
 	switch ecn {
 	case protocol.ECT0:
@@ -69,6 +65,24 @@ func (h *receivedPacketTracker) ReceivedPacket(pn protocol.PacketNumber, ecn pro
 	case protocol.ECNCE:
 		h.ecnce++
 	}
+
+	if !ackEliciting {
+		return nil
+	}
+
+	h.hasNewAck = true
+	h.ackElicitingPacketsReceivedSinceLastAck++
+	if !h.ackQueued && h.shouldQueueACK(pn, ecn, isMissing) {
+		h.ackQueued = true
+		h.ackAlarm = time.Time{} // cancel the ack alarm
+	}
+	if !h.ackQueued {
+		// No ACK queued, but we'll need to acknowledge the packet after max_ack_delay.
+		h.ackAlarm = rcvTime.Add(h.maxAckDelay)
+		if h.logger.Debug() {
+			h.logger.Debugf("\tSetting ACK timer to max ack delay: %s", h.maxAckDelay)
+		}
+	}
 	return nil
 }
 
@@ -101,23 +115,13 @@ func (h *receivedPacketTracker) hasNewMissingPackets() bool {
 	return highestRange.Smallest > h.lastAck.LargestAcked()+1 && highestRange.Len() == 1
 }
 
-// maybeQueueACK queues an ACK, if necessary.
-func (h *receivedPacketTracker) maybeQueueACK(pn protocol.PacketNumber, rcvTime time.Time, ecn protocol.ECN, wasMissing bool) {
+func (h *receivedPacketTracker) shouldQueueACK(pn protocol.PacketNumber, ecn protocol.ECN, wasMissing bool) bool {
 	// always acknowledge the first packet
 	if h.lastAck == nil {
-		if !h.ackQueued {
-			h.logger.Debugf("\tQueueing ACK because the first packet should be acknowledged.")
-		}
-		h.ackQueued = true
-		return
+		h.logger.Debugf("\tQueueing ACK because the first packet should be acknowledged.")
+		return true
 	}
 
-	if h.ackQueued {
-		return
-	}
-
-	h.ackElicitingPacketsReceivedSinceLastAck++
-
 	// Send an ACK if this packet was reported missing in an ACK sent before.
 	// Ack decimation with reordering relies on the timer to send an ACK, but if
 	// missing packets we reported in the previous ack, send an ACK immediately.
@@ -125,7 +129,7 @@ func (h *receivedPacketTracker) maybeQueueACK(pn protocol.PacketNumber, rcvTime
 		if h.logger.Debug() {
 			h.logger.Debugf("\tQueueing ACK because packet %d was missing before.", pn)
 		}
-		h.ackQueued = true
+		return true
 	}
 
 	// send an ACK every 2 ack-eliciting packets
@@ -133,30 +137,21 @@ func (h *receivedPacketTracker) maybeQueueACK(pn protocol.PacketNumber, rcvTime
 		if h.logger.Debug() {
 			h.logger.Debugf("\tQueueing ACK because packet %d packets were received after the last ACK (using initial threshold: %d).", h.ackElicitingPacketsReceivedSinceLastAck, packetsBeforeAck)
 		}
-		h.ackQueued = true
-	} else if h.ackAlarm.IsZero() {
-		if h.logger.Debug() {
-			h.logger.Debugf("\tSetting ACK timer to max ack delay: %s", h.maxAckDelay)
-		}
-		h.ackAlarm = rcvTime.Add(h.maxAckDelay)
+		return true
 	}
 
 	// queue an ACK if there are new missing packets to report
 	if h.hasNewMissingPackets() {
 		h.logger.Debugf("\tQueuing ACK because there's a new missing packet to report.")
-		h.ackQueued = true
+		return true
 	}
 
 	// queue an ACK if the packet was ECN-CE marked
 	if ecn == protocol.ECNCE {
 		h.logger.Debugf("\tQueuing ACK because the packet was ECN-CE marked.")
-		h.ackQueued = true
-	}
-
-	if h.ackQueued {
-		// cancel the ack alarm
-		h.ackAlarm = time.Time{}
+		return true
 	}
+	return false
 }
 
 func (h *receivedPacketTracker) GetAckFrame(onlyIfQueued bool) *wire.AckFrame {
diff --git a/internal/wire/transport_parameters.go b/internal/wire/transport_parameters.go
index 104142a0..c03be3cd 100644
--- a/internal/wire/transport_parameters.go
+++ b/internal/wire/transport_parameters.go
@@ -7,7 +7,7 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"net"
+	"net/netip"
 	"sort"
 	"time"
 
@@ -51,10 +51,7 @@ const (
 
 // PreferredAddress is the value encoding in the preferred_address transport parameter
 type PreferredAddress struct {
-	IPv4                net.IP
-	IPv4Port            uint16
-	IPv6                net.IP
-	IPv6Port            uint16
+	IPv4, IPv6          netip.AddrPort
 	ConnectionID        protocol.ConnectionID
 	StatelessResetToken protocol.StatelessResetToken
 }
@@ -218,26 +215,24 @@ func (p *TransportParameters) unmarshal(r *bytes.Reader, sentBy protocol.Perspec
 func (p *TransportParameters) readPreferredAddress(r *bytes.Reader, expectedLen int) error {
 	remainingLen := r.Len()
 	pa := &PreferredAddress{}
-	ipv4 := make([]byte, 4)
-	if _, err := io.ReadFull(r, ipv4); err != nil {
+	var ipv4 [4]byte
+	if _, err := io.ReadFull(r, ipv4[:]); err != nil {
 		return err
 	}
-	pa.IPv4 = net.IP(ipv4)
 	port, err := utils.BigEndian.ReadUint16(r)
 	if err != nil {
 		return err
 	}
-	pa.IPv4Port = port
-	ipv6 := make([]byte, 16)
-	if _, err := io.ReadFull(r, ipv6); err != nil {
+	pa.IPv4 = netip.AddrPortFrom(netip.AddrFrom4(ipv4), port)
+	var ipv6 [16]byte
+	if _, err := io.ReadFull(r, ipv6[:]); err != nil {
 		return err
 	}
-	pa.IPv6 = net.IP(ipv6)
 	port, err = utils.BigEndian.ReadUint16(r)
 	if err != nil {
 		return err
 	}
-	pa.IPv6Port = port
+	pa.IPv6 = netip.AddrPortFrom(netip.AddrFrom16(ipv6), port)
 	connIDLen, err := r.ReadByte()
 	if err != nil {
 		return err
@@ -384,13 +379,12 @@ func (p *TransportParameters) Marshal(pers protocol.Perspective) []byte {
 		if p.PreferredAddress != nil {
 			b = quicvarint.Append(b, uint64(preferredAddressParameterID))
 			b = quicvarint.Append(b, 4+2+16+2+1+uint64(p.PreferredAddress.ConnectionID.Len())+16)
-			ipv4 := p.PreferredAddress.IPv4
-			b = append(b, ipv4[len(ipv4)-4:]...)
-			b = append(b, []byte{0, 0}...)
-			binary.BigEndian.PutUint16(b[len(b)-2:], p.PreferredAddress.IPv4Port)
-			b = append(b, p.PreferredAddress.IPv6...)
-			b = append(b, []byte{0, 0}...)
-			binary.BigEndian.PutUint16(b[len(b)-2:], p.PreferredAddress.IPv6Port)
+			ip4 := p.PreferredAddress.IPv4.Addr().As4()
+			b = append(b, ip4[:]...)
+			b = binary.BigEndian.AppendUint16(b, p.PreferredAddress.IPv4.Port())
+			ip6 := p.PreferredAddress.IPv6.Addr().As16()
+			b = append(b, ip6[:]...)
+			b = binary.BigEndian.AppendUint16(b, p.PreferredAddress.IPv6.Port())
 			b = append(b, uint8(p.PreferredAddress.ConnectionID.Len()))
 			b = append(b, p.PreferredAddress.ConnectionID.Bytes()...)
 			b = append(b, p.PreferredAddress.StatelessResetToken[:]...)
diff --git a/qlog/event.go b/qlog/event.go
index f345d139..e2ceca9f 100644
--- a/qlog/event.go
+++ b/qlog/event.go
@@ -4,6 +4,7 @@ import (
 	"errors"
 	"fmt"
 	"net"
+	"net/netip"
 	"time"
 
 	"github.com/quic-go/quic-go"
@@ -456,8 +457,7 @@ func (e eventTransportParameters) MarshalJSONObject(enc *gojay.Encoder) {
 }
 
 type preferredAddress struct {
-	IPv4, IPv6          net.IP
-	PortV4, PortV6      uint16
+	IPv4, IPv6          netip.AddrPort
 	ConnectionID        protocol.ConnectionID
 	StatelessResetToken protocol.StatelessResetToken
 }
@@ -466,10 +466,10 @@ var _ gojay.MarshalerJSONObject = &preferredAddress{}
 
 func (a preferredAddress) IsNil() bool { return false }
 func (a preferredAddress) MarshalJSONObject(enc *gojay.Encoder) {
-	enc.StringKey("ip_v4", a.IPv4.String())
-	enc.Uint16Key("port_v4", a.PortV4)
-	enc.StringKey("ip_v6", a.IPv6.String())
-	enc.Uint16Key("port_v6", a.PortV6)
+	enc.StringKey("ip_v4", a.IPv4.Addr().String())
+	enc.Uint16Key("port_v4", a.IPv4.Port())
+	enc.StringKey("ip_v6", a.IPv6.Addr().String())
+	enc.Uint16Key("port_v6", a.IPv6.Port())
 	enc.StringKey("connection_id", a.ConnectionID.String())
 	enc.StringKey("stateless_reset_token", fmt.Sprintf("%x", a.StatelessResetToken))
 }
diff --git a/qlog/qlog.go b/qlog/qlog.go
index 0df77ce5..1bc1259c 100644
--- a/qlog/qlog.go
+++ b/qlog/qlog.go
@@ -304,9 +304,7 @@ func (t *connectionTracer) toTransportParameters(tp *wire.TransportParameters) *
 	if tp.PreferredAddress != nil {
 		pa = &preferredAddress{
 			IPv4:                tp.PreferredAddress.IPv4,
-			PortV4:              tp.PreferredAddress.IPv4Port,
 			IPv6:                tp.PreferredAddress.IPv6,
-			PortV6:              tp.PreferredAddress.IPv6Port,
 			ConnectionID:        tp.PreferredAddress.ConnectionID,
 			StatelessResetToken: tp.PreferredAddress.StatelessResetToken,
 		}
diff --git a/server.go b/server.go
index 124e30be..f509ae14 100644
--- a/server.go
+++ b/server.go
@@ -542,10 +542,10 @@ func (s *baseServer) validateToken(token *handshake.Token, addr net.Addr) bool {
 
 func (s *baseServer) handleInitialImpl(p receivedPacket, hdr *wire.Header) error {
 	if len(hdr.Token) == 0 && hdr.DestConnectionID.Len() < protocol.MinConnectionIDLenInitial {
-		p.buffer.Release()
 		if s.tracer != nil && s.tracer.DroppedPacket != nil {
 			s.tracer.DroppedPacket(p.remoteAddr, logging.PacketTypeInitial, p.Size(), logging.PacketDropUnexpectedPacket)
 		}
+		p.buffer.Release()
 		return errors.New("too short connection ID")
 	}
 


Test patch:
diff --git a/connection_test.go b/connection_test.go
index 973f1b6f..c4ea0657 100644
--- a/connection_test.go
+++ b/connection_test.go
@@ -9,6 +9,7 @@ import (
 	"fmt"
 	"io"
 	"net"
+	"net/netip"
 	"runtime/pprof"
 	"strings"
 	"time"
@@ -1269,7 +1270,6 @@ var _ = Describe("Connection", func() {
 			sph.EXPECT().ECNMode(true).AnyTimes()
 			runConn()
 			packer.EXPECT().AppendPacket(gomock.Any(), gomock.Any(), conn.version).Return(shortHeaderPacket{}, errNothingToPack).AnyTimes()
-			conn.receivedPacketHandler.ReceivedPacket(0x035e, protocol.ECNNon, protocol.Encryption1RTT, time.Now(), true)
 			conn.scheduleSending()
 			time.Sleep(50 * time.Millisecond) // make sure there are no calls to mconn.Write()
 		})
@@ -2940,8 +2940,8 @@ var _ = Describe("Client Connection", func() {
 				OriginalDestinationConnectionID: destConnID,
 				InitialSourceConnectionID:       destConnID,
 				PreferredAddress: &wire.PreferredAddress{
-					IPv4:                net.IPv4(127, 0, 0, 1),
-					IPv6:                net.IP{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16},
+					IPv4:                netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 42),
+					IPv6:                netip.AddrPortFrom(netip.AddrFrom16([16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}), 13),
 					ConnectionID:        protocol.ParseConnectionID([]byte{1, 2, 3, 4}),
 					StatelessResetToken: protocol.StatelessResetToken{16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1},
 				},
diff --git a/http3/server_test.go b/http3/server_test.go
index 8da80611..ab702c42 100644
--- a/http3/server_test.go
+++ b/http3/server_test.go
@@ -67,11 +67,15 @@ var _ = Describe("Server", func() {
 		s                  *Server
 		origQuicListenAddr = quicListenAddr
 	)
+	type testConnContextKey string
 
 	BeforeEach(func() {
 		s = &Server{
 			TLSConfig: testdata.GetTLSConfig(),
 			logger:    utils.DefaultLogger,
+			ConnContext: func(ctx context.Context, c quic.Connection) context.Context {
+				return context.WithValue(ctx, testConnContextKey("test"), c)
+			},
 		}
 		origQuicListenAddr = quicListenAddr
 	})
@@ -163,6 +167,7 @@ var _ = Describe("Server", func() {
 			Expect(req.Host).To(Equal("www.example.com"))
 			Expect(req.RemoteAddr).To(Equal("127.0.0.1:1337"))
 			Expect(req.Context().Value(ServerContextKey)).To(Equal(s))
+			Expect(req.Context().Value(testConnContextKey("test"))).ToNot(Equal(nil))
 		})
 
 		It("returns 200 with an empty handler", func() {
diff --git a/integrationtests/self/http_test.go b/integrationtests/self/http_test.go
index 96e72dc7..cf9f683e 100644
--- a/integrationtests/self/http_test.go
+++ b/integrationtests/self/http_test.go
@@ -528,4 +528,35 @@ var _ = Describe("HTTP tests", func() {
 		Expect(err).ToNot(HaveOccurred())
 		Expect(resp.StatusCode).To(Equal(200))
 	})
+
+	It("sets conn context", func() {
+		type ctxKey int
+		server.ConnContext = func(ctx context.Context, c quic.Connection) context.Context {
+			serv, ok := ctx.Value(http3.ServerContextKey).(*http3.Server)
+			Expect(ok).To(BeTrue())
+			Expect(serv).To(Equal(server))
+
+			ctx = context.WithValue(ctx, ctxKey(0), "Hello")
+			ctx = context.WithValue(ctx, ctxKey(1), c)
+			return ctx
+		}
+		mux.HandleFunc("/conn-context", func(w http.ResponseWriter, r *http.Request) {
+			defer GinkgoRecover()
+			v, ok := r.Context().Value(ctxKey(0)).(string)
+			Expect(ok).To(BeTrue())
+			Expect(v).To(Equal("Hello"))
+
+			c, ok := r.Context().Value(ctxKey(1)).(quic.Connection)
+			Expect(ok).To(BeTrue())
+			Expect(c).ToNot(BeNil())
+
+			serv, ok := r.Context().Value(http3.ServerContextKey).(*http3.Server)
+			Expect(ok).To(BeTrue())
+			Expect(serv).To(Equal(server))
+		})
+
+		resp, err := client.Get(fmt.Sprintf("https://localhost:%d/conn-context", port))
+		Expect(err).ToNot(HaveOccurred())
+		Expect(resp.StatusCode).To(Equal(200))
+	})
 })
diff --git a/internal/wire/transport_parameter_test.go b/internal/wire/transport_parameter_test.go
index a82b6dcf..04e31f85 100644
--- a/internal/wire/transport_parameter_test.go
+++ b/internal/wire/transport_parameter_test.go
@@ -4,7 +4,7 @@ import (
 	"bytes"
 	"fmt"
 	"math"
-	"net"
+	"net/netip"
 	"time"
 
 	"golang.org/x/exp/rand"
@@ -425,10 +425,8 @@ var _ = Describe("Transport Parameters", func() {
 
 		BeforeEach(func() {
 			pa = &PreferredAddress{
-				IPv4:                net.IPv4(127, 0, 0, 1),
-				IPv4Port:            42,
-				IPv6:                net.IP{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16},
-				IPv6Port:            13,
+				IPv4:                netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), 42),
+				IPv6:                netip.AddrPortFrom(netip.AddrFrom16([16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}), 13),
 				ConnectionID:        protocol.ParseConnectionID([]byte{0xde, 0xad, 0xbe, 0xef}),
 				StatelessResetToken: protocol.StatelessResetToken{16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1},
 			}
@@ -442,10 +440,8 @@ var _ = Describe("Transport Parameters", func() {
 			}).Marshal(protocol.PerspectiveServer)
 			p := &TransportParameters{}
 			Expect(p.Unmarshal(data, protocol.PerspectiveServer)).To(Succeed())
-			Expect(p.PreferredAddress.IPv4.String()).To(Equal(pa.IPv4.String()))
-			Expect(p.PreferredAddress.IPv4Port).To(Equal(pa.IPv4Port))
-			Expect(p.PreferredAddress.IPv6.String()).To(Equal(pa.IPv6.String()))
-			Expect(p.PreferredAddress.IPv6Port).To(Equal(pa.IPv6Port))
+			Expect(p.PreferredAddress.IPv4).To(Equal(pa.IPv4))
+			Expect(p.PreferredAddress.IPv6).To(Equal(pa.IPv6))
 			Expect(p.PreferredAddress.ConnectionID).To(Equal(pa.ConnectionID))
 			Expect(p.PreferredAddress.StatelessResetToken).To(Equal(pa.StatelessResetToken))
 		})
diff --git a/qlog/qlog_test.go b/qlog/qlog_test.go
index 11a49d88..7684fcc5 100644
--- a/qlog/qlog_test.go
+++ b/qlog/qlog_test.go
@@ -7,6 +7,7 @@ import (
 	"io"
 	"log"
 	"net"
+	"net/netip"
 	"os"
 	"time"
 
@@ -338,10 +339,8 @@ var _ = Describe("Tracing", func() {
 			It("records transport parameters with a preferred address", func() {
 				tracer.SentTransportParameters(&logging.TransportParameters{
 					PreferredAddress: &logging.PreferredAddress{
-						IPv4:                net.IPv4(12, 34, 56, 78),
-						IPv4Port:            123,
-						IPv6:                net.IP{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16},
-						IPv6Port:            456,
+						IPv4:                netip.AddrPortFrom(netip.AddrFrom4([4]byte{12, 34, 56, 78}), 123),
+						IPv6:                netip.AddrPortFrom(netip.AddrFrom16([16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}), 456),
 						ConnectionID:        protocol.ParseConnectionID([]byte{8, 7, 6, 5, 4, 3, 2, 1}),
 						StatelessResetToken: protocol.StatelessResetToken{15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0},
 					},