http3: implement client-side GOAWAY handling (#5143)

When receiving a GOAWAY frame, the client:
* immediately closes the connection if there are no active requests
* refuses to open streams with stream IDs larger than the stream ID in
the GOAWAY frame
* closes the connection once the stream count drops to zero
This commit is contained in:
Marten Seemann
2025-05-18 13:33:43 +08:00
committed by GitHub
parent 06e8ee1bcf
commit 363e0ccafb
5 changed files with 524 additions and 53 deletions

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"errors"
"io"
"testing"
"time"
@@ -149,35 +150,96 @@ func TestConnResetUnknownUniStream(t *testing.T) {
}
func TestConnControlStreamFailures(t *testing.T) {
t.Run("missing settings", func(t *testing.T) {
testConnControlStreamFailures(t, (&dataFrame{}).Append(nil), ErrCodeMissingSettings)
t.Run("missing SETTINGS", func(t *testing.T) {
testConnControlStreamFailures(t, (&dataFrame{}).Append(nil), nil, ErrCodeMissingSettings)
})
t.Run("frame error", func(t *testing.T) {
testConnControlStreamFailures(t,
// 1337 is invalid value for the Extended CONNECT setting
(&settingsFrame{Other: map[uint64]uint64{settingExtendedConnect: 1337}}).Append(nil),
nil,
ErrCodeFrameError,
)
})
t.Run("control stream closed before SETTINGS", func(t *testing.T) {
testConnControlStreamFailures(t, nil, io.EOF, ErrCodeClosedCriticalStream)
})
t.Run("control stream reset before SETTINGS", func(t *testing.T) {
testConnControlStreamFailures(t,
nil,
&quic.StreamError{Remote: true, ErrorCode: 42},
ErrCodeClosedCriticalStream,
)
})
}
func testConnControlStreamFailures(t *testing.T, data []byte, expectedErr ErrCode) {
func TestConnGoAwayFailures(t *testing.T) {
t.Run("invalid frame", func(t *testing.T) {
b := (&settingsFrame{}).Append(nil)
// 1337 is invalid value for the Extended CONNECT setting
b = (&settingsFrame{Other: map[uint64]uint64{settingExtendedConnect: 1337}}).Append(b)
testConnControlStreamFailures(t, b, nil, ErrCodeFrameError)
})
t.Run("not a GOAWAY", func(t *testing.T) {
b := (&settingsFrame{}).Append(nil)
// GOAWAY is the only allowed frame type after SETTINGS
b = (&headersFrame{}).Append(b)
testConnControlStreamFailures(t, b, nil, ErrCodeFrameUnexpected)
})
t.Run("stream closed before GOAWAY", func(t *testing.T) {
testConnControlStreamFailures(t, (&settingsFrame{}).Append(nil), io.EOF, ErrCodeClosedCriticalStream)
})
t.Run("stream reset before GOAWAY", func(t *testing.T) {
testConnControlStreamFailures(t,
(&settingsFrame{}).Append(nil),
&quic.StreamError{Remote: true, ErrorCode: 42},
ErrCodeClosedCriticalStream,
)
})
t.Run("invalid stream ID", func(t *testing.T) {
data := (&settingsFrame{}).Append(nil)
data = (&goAwayFrame{StreamID: 1}).Append(data)
testConnControlStreamFailures(t, data, nil, ErrCodeIDError)
})
t.Run("increased stream ID", func(t *testing.T) {
data := (&settingsFrame{}).Append(nil)
data = (&goAwayFrame{StreamID: 4}).Append(data)
data = (&goAwayFrame{StreamID: 8}).Append(data)
testConnControlStreamFailures(t, data, nil, ErrCodeIDError)
})
}
func testConnControlStreamFailures(t *testing.T, data []byte, readErr error, expectedErr ErrCode) {
mockCtrl := gomock.NewController(t)
qconn := mockquic.NewMockEarlyConnection(mockCtrl)
conn := newConnection(
context.Background(),
qconn,
false,
protocol.PerspectiveServer,
protocol.PerspectiveClient,
nil,
0,
)
b := quicvarint.Append(nil, streamTypeControlStream)
b = append(b, data...)
r := bytes.NewReader(b)
controlStr := mockquic.NewMockStream(mockCtrl)
controlStr.EXPECT().Read(gomock.Any()).DoAndReturn(bytes.NewReader(b).Read).AnyTimes()
controlStr.EXPECT().Read(gomock.Any()).DoAndReturn(func(b []byte) (int, error) {
if r.Len() == 0 {
return 0, readErr
}
return r.Read(b)
}).AnyTimes()
qconn.EXPECT().AcceptUniStream(gomock.Any()).Return(controlStr, nil)
qconn.EXPECT().AcceptUniStream(gomock.Any()).Return(nil, errors.New("test done"))
closed := make(chan struct{})
str := mockquic.NewMockStream(mockCtrl)
str.EXPECT().StreamID().Return(4).AnyTimes()
str.EXPECT().Context().Return(context.Background()).AnyTimes()
qconn.EXPECT().OpenStreamSync(gomock.Any()).Return(str, nil)
conn.openRequestStream(context.Background(), nil, nil, true, 1000)
qconn.EXPECT().CloseWithError(quic.ApplicationErrorCode(expectedErr), gomock.Any()).Do(func(quic.ApplicationErrorCode, string) error {
close(closed)
return nil
@@ -199,6 +261,88 @@ func testConnControlStreamFailures(t *testing.T, data []byte, expectedErr ErrCod
}
}
func TestConnGoAway(t *testing.T) {
t.Run("no active streams", func(t *testing.T) {
testConnGoAway(t, false)
})
t.Run("active stream", func(t *testing.T) {
testConnGoAway(t, true)
})
}
func testConnGoAway(t *testing.T, withStream bool) {
mockCtrl := gomock.NewController(t)
qconn := mockquic.NewMockEarlyConnection(mockCtrl)
conn := newConnection(
context.Background(),
qconn,
false,
protocol.PerspectiveClient,
nil,
0,
)
b := quicvarint.Append(nil, streamTypeControlStream)
b = (&settingsFrame{}).Append(b)
b = (&goAwayFrame{StreamID: 4}).Append(b)
var mockStr *mockquic.MockStream
var str quic.Stream
if withStream {
mockStr = mockquic.NewMockStream(mockCtrl)
mockStr.EXPECT().StreamID().Return(4).AnyTimes()
mockStr.EXPECT().Context().Return(context.Background()).AnyTimes()
qconn.EXPECT().OpenStreamSync(gomock.Any()).Return(mockStr, nil)
s, err := conn.openRequestStream(context.Background(), nil, nil, true, 1000)
require.NoError(t, err)
str = s
}
done := make(chan struct{})
defer close(done)
r := bytes.NewReader(b)
controlStr := mockquic.NewMockStream(mockCtrl)
controlStr.EXPECT().Read(gomock.Any()).DoAndReturn(func(b []byte) (int, error) {
if r.Len() == 0 {
<-done
return 0, errors.New("test done")
}
return r.Read(b)
}).AnyTimes()
qconn.EXPECT().AcceptUniStream(gomock.Any()).Return(controlStr, nil)
qconn.EXPECT().AcceptUniStream(gomock.Any()).Return(nil, errors.New("test done"))
closed := make(chan struct{})
qconn.EXPECT().CloseWithError(quic.ApplicationErrorCode(ErrCodeNoError), gomock.Any()).Do(func(quic.ApplicationErrorCode, string) error {
close(closed)
return nil
})
// duplicate calls to CloseWithError are a no-op
qconn.EXPECT().CloseWithError(gomock.Any(), gomock.Any()).AnyTimes()
go conn.handleUnidirectionalStreams(nil)
// the connection should be closed after the stream is closed
if withStream {
select {
case <-closed:
t.Fatal("connection closed")
case <-time.After(scaleDuration(10 * time.Millisecond)):
}
_, err := conn.openRequestStream(context.Background(), nil, nil, true, 1000)
require.ErrorIs(t, err, errGoAway)
mockStr.EXPECT().Close()
str.Close()
mockStr.EXPECT().CancelRead(gomock.Any())
str.CancelRead(1337)
}
select {
case <-closed:
case <-time.After(time.Second):
t.Fatal("timeout waiting for close")
}
}
func TestConnRejectPushStream(t *testing.T) {
t.Run("client", func(t *testing.T) {
testConnRejectPushStream(t, protocol.PerspectiveClient, ErrCodeStreamCreationError)
@@ -299,8 +443,16 @@ func TestConnSendAndReceiveDatagram(t *testing.T) {
)
b := quicvarint.Append(nil, streamTypeControlStream)
b = (&settingsFrame{Datagram: true}).Append(b)
r := bytes.NewReader(b)
done := make(chan struct{})
defer close(done)
controlStr := mockquic.NewMockStream(mockCtrl)
controlStr.EXPECT().Read(gomock.Any()).DoAndReturn(bytes.NewReader(b).Read).AnyTimes()
controlStr.EXPECT().Read(gomock.Any()).DoAndReturn(func(b []byte) (int, error) {
if r.Len() == 0 {
<-done
}
return r.Read(b)
}).AnyTimes()
qconn.EXPECT().AcceptUniStream(gomock.Any()).Return(controlStr, nil).MaxTimes(1)
qconn.EXPECT().AcceptUniStream(gomock.Any()).Return(nil, errors.New("test done")).MaxTimes(1)
qconn.EXPECT().ConnectionState().Return(quic.ConnectionState{SupportsDatagrams: true}).MaxTimes(1)
@@ -351,6 +503,8 @@ func TestConnSendAndReceiveDatagram(t *testing.T) {
expected = append(expected, []byte("foobaz")...)
qconn.EXPECT().SendDatagram(expected).Return(assert.AnError)
require.ErrorIs(t, conn.sendDatagram(strID2, []byte("foobaz")), assert.AnError)
qconn.EXPECT().CloseWithError(gomock.Any(), gomock.Any()).AnyTimes()
}
func TestConnDatagramFailures(t *testing.T) {
@@ -376,18 +530,24 @@ func testConnDatagramFailures(t *testing.T, datagram []byte) {
b := quicvarint.Append(nil, streamTypeControlStream)
b = (&settingsFrame{Datagram: true}).Append(b)
r := bytes.NewReader(b)
done := make(chan struct{})
controlStr := mockquic.NewMockStream(mockCtrl)
controlStr.EXPECT().Read(gomock.Any()).DoAndReturn(r.Read).AnyTimes()
controlStr.EXPECT().Read(gomock.Any()).DoAndReturn(func(b []byte) (int, error) {
if r.Len() == 0 {
<-done
}
return r.Read(b)
}).AnyTimes()
qconn.EXPECT().AcceptUniStream(gomock.Any()).Return(controlStr, nil).MaxTimes(1)
qconn.EXPECT().AcceptUniStream(gomock.Any()).Return(nil, errors.New("test done")).MaxTimes(1)
qconn.EXPECT().ConnectionState().Return(quic.ConnectionState{SupportsDatagrams: true}).MaxTimes(1)
qconn.EXPECT().ReceiveDatagram(gomock.Any()).Return(datagram, nil)
done := make(chan struct{})
qconn.EXPECT().CloseWithError(qerr.ApplicationErrorCode(ErrCodeDatagramError), gomock.Any()).Do(func(qerr.ApplicationErrorCode, string) error {
close(done)
return nil
})
qconn.EXPECT().CloseWithError(gomock.Any(), gomock.Any()).AnyTimes() // further calls to CloseWithError are a no-op
go func() { conn.handleUnidirectionalStreams(nil) }()
select {
case <-done: