blob: c0b5cd170f0a8e7f297a85dd3c99e82bd2ed3e10 [file] [log] [blame]
// 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 (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"reflect"
"testing"
"time"
"golang.org/x/net/quic/qlog"
)
func TestQLogHandshake(t *testing.T) {
testSides(t, "", func(t *testing.T, side connSide) {
qr := &qlogRecord{}
tc := newTestConn(t, side, qr.config)
tc.handshake()
tc.conn.Abort(nil)
tc.wantFrame("aborting connection generates CONN_CLOSE",
packetType1RTT, debugFrameConnectionCloseTransport{
code: errNo,
})
tc.writeFrames(packetType1RTT, debugFrameConnectionCloseTransport{})
tc.advanceToTimer() // let the conn finish draining
var src, dst []byte
if side == clientSide {
src = testLocalConnID(0)
dst = testLocalConnID(-1)
} else {
src = testPeerConnID(-1)
dst = testPeerConnID(0)
}
qr.wantEvents(t, jsonEvent{
"name": "connectivity:connection_started",
"data": map[string]any{
"src_cid": hex.EncodeToString(src),
"dst_cid": hex.EncodeToString(dst),
},
}, jsonEvent{
"name": "connectivity:connection_closed",
"data": map[string]any{
"trigger": "clean",
},
})
})
}
func TestQLogPacketFrames(t *testing.T) {
qr := &qlogRecord{}
tc := newTestConn(t, clientSide, qr.config)
tc.handshake()
tc.conn.Abort(nil)
tc.writeFrames(packetType1RTT, debugFrameConnectionCloseTransport{})
tc.advanceToTimer() // let the conn finish draining
qr.wantEvents(t, jsonEvent{
"name": "transport:packet_sent",
"data": map[string]any{
"header": map[string]any{
"packet_type": "initial",
"packet_number": 0,
"dcid": hex.EncodeToString(testLocalConnID(-1)),
"scid": hex.EncodeToString(testLocalConnID(0)),
},
"frames": []any{
map[string]any{"frame_type": "crypto"},
},
},
}, jsonEvent{
"name": "transport:packet_received",
"data": map[string]any{
"header": map[string]any{
"packet_type": "initial",
"packet_number": 0,
"dcid": hex.EncodeToString(testLocalConnID(0)),
"scid": hex.EncodeToString(testPeerConnID(0)),
},
"frames": []any{map[string]any{"frame_type": "crypto"}},
},
})
}
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,
},
})
})
}
}
func TestQLogRecovery(t *testing.T) {
qr := &qlogRecord{}
tc, s := newTestConnAndLocalStream(t, clientSide, uniStream,
permissiveTransportParameters, qr.config)
// Ignore events from the handshake.
qr.ev = nil
data := make([]byte, 16)
s.Write(data)
s.CloseWrite()
tc.wantFrame("created stream 0",
packetType1RTT, debugFrameStream{
id: newStreamID(clientSide, uniStream, 0),
fin: true,
data: data,
})
tc.writeAckForAll()
tc.wantIdle("connection should be idle now")
// Don't check the contents of fields, but verify that recovery metrics are logged.
qr.wantEvents(t, jsonEvent{
"name": "recovery:metrics_updated",
"data": map[string]any{
"bytes_in_flight": nil,
},
}, jsonEvent{
"name": "recovery:metrics_updated",
"data": map[string]any{
"bytes_in_flight": 0,
"congestion_window": nil,
"latest_rtt": nil,
"min_rtt": nil,
"rtt_variance": nil,
"smoothed_rtt": nil,
},
})
}
func TestQLogLoss(t *testing.T) {
qr := &qlogRecord{}
tc, s := newTestConnAndLocalStream(t, clientSide, uniStream,
permissiveTransportParameters, qr.config)
// Ignore events from the handshake.
qr.ev = nil
data := make([]byte, 16)
s.Write(data)
s.CloseWrite()
tc.wantFrame("created stream 0",
packetType1RTT, debugFrameStream{
id: newStreamID(clientSide, uniStream, 0),
fin: true,
data: data,
})
const pto = false
tc.triggerLossOrPTO(packetType1RTT, pto)
qr.wantEvents(t, jsonEvent{
"name": "recovery:packet_lost",
"data": map[string]any{
"header": map[string]any{
"packet_number": nil,
"packet_type": "1RTT",
},
},
})
}
func TestQLogPacketDropped(t *testing.T) {
qr := &qlogRecord{}
tc := newTestConn(t, clientSide, permissiveTransportParameters, qr.config)
tc.handshake()
// A garbage-filled datagram with a DCID matching this connection.
dgram := bytes.Join([][]byte{
{headerFormShort | fixedBit},
testLocalConnID(0),
make([]byte, 100),
[]byte{1, 2, 3, 4}, // random data, to avoid this looking like a stateless reset
}, nil)
tc.endpoint.write(&datagram{
b: dgram,
})
qr.wantEvents(t, jsonEvent{
"name": "connectivity:packet_dropped",
})
}
type nopCloseWriter struct {
io.Writer
}
func (nopCloseWriter) Close() error { return nil }
type jsonEvent map[string]any
func (j jsonEvent) String() string {
b, _ := json.MarshalIndent(j, "", " ")
return string(b)
}
// jsonPartialEqual compares two JSON structures.
// It ignores fields not set in want (see below for specifics).
func jsonPartialEqual(got, want any) (equal bool) {
cmpval := func(v any) any {
// Map certain types to a common representation.
switch v := v.(type) {
case int:
// JSON uses float64s rather than ints for numbers.
// Map int->float64 so we can use integers in expectations.
return float64(v)
case jsonEvent:
return (map[string]any)(v)
case []jsonEvent:
s := []any{}
for _, e := range v {
s = append(s, e)
}
return s
}
return v
}
if want == nil {
return true // match anything
}
got = cmpval(got)
want = cmpval(want)
if reflect.TypeOf(got) != reflect.TypeOf(want) {
return false
}
switch w := want.(type) {
case map[string]any:
// JSON object: Every field in want must match a field in got.
g := got.(map[string]any)
for k := range w {
if !jsonPartialEqual(g[k], w[k]) {
return false
}
}
case []any:
// JSON slice: Every field in want must match a field in got, in order.
// So want=[2,4] matches got=[1,2,3,4] but not [4,2].
g := got.([]any)
for _, ge := range g {
if jsonPartialEqual(ge, w[0]) {
w = w[1:]
if len(w) == 0 {
return true
}
}
}
return false
default:
if !reflect.DeepEqual(got, want) {
return false
}
}
return true
}
// A qlogRecord records events.
type qlogRecord struct {
ev []jsonEvent
}
func (q *qlogRecord) Write(b []byte) (int, error) {
// This relies on the property that the Handler always makes one Write call per event.
if len(b) < 1 || b[0] != 0x1e {
panic(fmt.Errorf("trace Write should start with record separator, got %q", string(b)))
}
var val map[string]any
if err := json.Unmarshal(b[1:], &val); err != nil {
panic(fmt.Errorf("log unmarshal failure: %v\n%v", err, string(b)))
}
q.ev = append(q.ev, val)
return len(b), nil
}
func (q *qlogRecord) Close() error { return nil }
// config may be passed to newTestConn to configure the conn to use this logger.
func (q *qlogRecord) config(c *Config) {
c.QLogLogger = slog.New(qlog.NewJSONHandler(qlog.HandlerOptions{
Level: QLogLevelFrame,
NewTrace: func(info qlog.TraceInfo) (io.WriteCloser, error) {
return q, nil
},
}))
}
// wantEvents checks that every event in want occurs in the order specified.
func (q *qlogRecord) wantEvents(t *testing.T, want ...jsonEvent) {
t.Helper()
got := q.ev
if !jsonPartialEqual(got, want) {
t.Fatalf("got events:\n%v\n\nwant events:\n%v", got, want)
}
}