Skip to content

Commit 0b21d06

Browse files
committed
quic: framework for testing blocking operations
For some tests, we want to start a blocking operation and then subsequently control the progress of that operation. For example, we might write to a stream, and then feed the connection MAX_STREAM_DATA frames to permit it to gradually send the written data. This is difficult to coordinate: We can start the write in a goroutine, but we have no way to synchronize with it. Add support for testing this sort of operation, instrumenting locations where operations can block and tracking when operations are in progress and when they are blocked. This is all rather terribly complicated, but it mostly puts the complexity in one place rather than in every test. For golang/go#58547 Change-Id: I09d8f0e359f3c9fd0d444bc0320e9d53391d4877 Reviewed-on: https://go-review.googlesource.com/c/net/+/515340 TryBot-Result: Gopher Robot <[email protected]> Reviewed-by: Olif Oftimis <[email protected]> Run-TryBot: Damien Neil <[email protected]> Reviewed-by: Jonathan Amsterdam <[email protected]>
1 parent 4648651 commit 0b21d06

File tree

6 files changed

+258
-16
lines changed

6 files changed

+258
-16
lines changed

internal/quic/conn.go

+9
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
package quic
88

99
import (
10+
"context"
1011
"crypto/tls"
1112
"errors"
1213
"fmt"
@@ -71,6 +72,7 @@ type connTestHooks interface {
7172
nextMessage(msgc chan any, nextTimeout time.Time) (now time.Time, message any)
7273
handleTLSEvent(tls.QUICEvent)
7374
newConnID(seq int64) ([]byte, error)
75+
waitAndLockGate(ctx context.Context, g *gate) error
7476
}
7577

7678
func newConn(now time.Time, side connSide, initialConnID []byte, peerAddr netip.AddrPort, config *Config, l connListener, hooks connTestHooks) (*Conn, error) {
@@ -299,6 +301,13 @@ func (c *Conn) runOnLoop(f func(now time.Time, c *Conn)) error {
299301
return nil
300302
}
301303

304+
func (c *Conn) waitAndLockGate(ctx context.Context, g *gate) error {
305+
if c.testHooks != nil {
306+
return c.testHooks.waitAndLockGate(ctx, g)
307+
}
308+
return g.waitAndLockContext(ctx)
309+
}
310+
302311
// abort terminates a connection with an error.
303312
func (c *Conn) abort(now time.Time, err error) {
304313
if c.errForPeer == nil {

internal/quic/conn_async_test.go

+185
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Copyright 2023 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
//go:build go1.21
6+
7+
package quic
8+
9+
import (
10+
"context"
11+
"errors"
12+
"fmt"
13+
"path/filepath"
14+
"runtime"
15+
"sync"
16+
)
17+
18+
// asyncTestState permits handling asynchronous operations in a synchronous test.
19+
//
20+
// For example, a test may want to write to a stream and observe that
21+
// STREAM frames are sent with the contents of the write in response
22+
// to MAX_STREAM_DATA frames received from the peer.
23+
// The Stream.Write is an asynchronous operation, but the test is simpler
24+
// if we can start the write, observe the first STREAM frame sent,
25+
// send a MAX_STREAM_DATA frame, observe the next STREAM frame sent, etc.
26+
//
27+
// We do this by instrumenting points where operations can block.
28+
// We start async operations like Write in a goroutine,
29+
// and wait for the operation to either finish or hit a blocking point.
30+
// When the connection event loop is idle, we check a list of
31+
// blocked operations to see if any can be woken.
32+
type asyncTestState struct {
33+
mu sync.Mutex
34+
notify chan struct{}
35+
blocked map[*blockedAsync]struct{}
36+
}
37+
38+
// An asyncOp is an asynchronous operation that results in (T, error).
39+
type asyncOp[T any] struct {
40+
v T
41+
err error
42+
43+
caller string
44+
state *asyncTestState
45+
donec chan struct{}
46+
cancelFunc context.CancelFunc
47+
}
48+
49+
// cancel cancels the async operation's context, and waits for
50+
// the operation to complete.
51+
func (a *asyncOp[T]) cancel() {
52+
select {
53+
case <-a.donec:
54+
return // already done
55+
default:
56+
}
57+
a.cancelFunc()
58+
<-a.state.notify
59+
select {
60+
case <-a.donec:
61+
default:
62+
panic(fmt.Errorf("%v: async op failed to finish after being canceled", a.caller))
63+
}
64+
}
65+
66+
var errNotDone = errors.New("async op is not done")
67+
68+
// result returns the result of the async operation.
69+
// It returns errNotDone if the operation is still in progress.
70+
//
71+
// Note that unlike a traditional async/await, this doesn't block
72+
// waiting for the operation to complete. Since tests have full
73+
// control over the progress of operations, an asyncOp can only
74+
// become done in reaction to the test taking some action.
75+
func (a *asyncOp[T]) result() (v T, err error) {
76+
select {
77+
case <-a.donec:
78+
return a.v, a.err
79+
default:
80+
return v, errNotDone
81+
}
82+
}
83+
84+
// A blockedAsync is a blocked async operation.
85+
//
86+
// Currently, the only type of blocked operation is one waiting on a gate.
87+
type blockedAsync struct {
88+
g *gate
89+
donec chan struct{} // closed when the operation is unblocked
90+
}
91+
92+
type asyncContextKey struct{}
93+
94+
// runAsync starts an asynchronous operation.
95+
//
96+
// The function f should call a blocking function such as
97+
// Stream.Write or Conn.AcceptStream and return its result.
98+
// It must use the provided context.
99+
func runAsync[T any](ts *testConn, f func(context.Context) (T, error)) *asyncOp[T] {
100+
as := &ts.asyncTestState
101+
if as.notify == nil {
102+
as.notify = make(chan struct{})
103+
as.blocked = make(map[*blockedAsync]struct{})
104+
}
105+
_, file, line, _ := runtime.Caller(1)
106+
ctx := context.WithValue(context.Background(), asyncContextKey{}, true)
107+
ctx, cancel := context.WithCancel(ctx)
108+
a := &asyncOp[T]{
109+
state: as,
110+
caller: fmt.Sprintf("%v:%v", filepath.Base(file), line),
111+
donec: make(chan struct{}),
112+
cancelFunc: cancel,
113+
}
114+
go func() {
115+
a.v, a.err = f(ctx)
116+
close(a.donec)
117+
as.notify <- struct{}{}
118+
}()
119+
ts.t.Cleanup(func() {
120+
if _, err := a.result(); err == errNotDone {
121+
ts.t.Errorf("%v: async operation is still executing at end of test", a.caller)
122+
a.cancel()
123+
}
124+
})
125+
// Wait for the operation to either finish or block.
126+
<-as.notify
127+
return a
128+
}
129+
130+
// waitAndLockGate replaces gate.waitAndLock in tests.
131+
func (as *asyncTestState) waitAndLockGate(ctx context.Context, g *gate) error {
132+
if g.lockIfSet() {
133+
// Gate can be acquired without blocking.
134+
return nil
135+
}
136+
if err := ctx.Err(); err != nil {
137+
// Context has already expired.
138+
return err
139+
}
140+
if ctx.Value(asyncContextKey{}) == nil {
141+
// Context is not one that we've created, and hasn't expired.
142+
// This probably indicates that we've tried to perform a
143+
// blocking operation without using the async test harness here,
144+
// which may have unpredictable results.
145+
panic("blocking async point with unexpected Context")
146+
}
147+
// Record this as a pending blocking operation.
148+
as.mu.Lock()
149+
b := &blockedAsync{
150+
g: g,
151+
donec: make(chan struct{}),
152+
}
153+
as.blocked[b] = struct{}{}
154+
as.mu.Unlock()
155+
// Notify the creator of the operation that we're blocked,
156+
// and wait to be woken up.
157+
as.notify <- struct{}{}
158+
select {
159+
case <-b.donec:
160+
case <-ctx.Done():
161+
return ctx.Err()
162+
}
163+
return nil
164+
}
165+
166+
// wakeAsync tries to wake up a blocked async operation.
167+
// It returns true if one was woken, false otherwise.
168+
func (as *asyncTestState) wakeAsync() bool {
169+
as.mu.Lock()
170+
var woken *blockedAsync
171+
for w := range as.blocked {
172+
if w.g.lockIfSet() {
173+
woken = w
174+
delete(as.blocked, woken)
175+
break
176+
}
177+
}
178+
as.mu.Unlock()
179+
if woken == nil {
180+
return false
181+
}
182+
close(woken.donec)
183+
<-as.notify // must not hold as.mu while blocked here
184+
return true
185+
}

internal/quic/conn_streams.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func (c *Conn) streamsInit() {
3636

3737
// AcceptStream waits for and returns the next stream created by the peer.
3838
func (c *Conn) AcceptStream(ctx context.Context) (*Stream, error) {
39-
return c.streams.queue.get(ctx)
39+
return c.streams.queue.getWithHooks(ctx, c.testHooks)
4040
}
4141

4242
// NewStream creates a stream.

internal/quic/conn_streams_test.go

+29
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,35 @@ func TestStreamsAccept(t *testing.T) {
9595
}
9696
}
9797

98+
func TestStreamsBlockingAccept(t *testing.T) {
99+
tc := newTestConn(t, serverSide)
100+
tc.handshake()
101+
102+
a := runAsync(tc, func(ctx context.Context) (*Stream, error) {
103+
return tc.conn.AcceptStream(ctx)
104+
})
105+
if _, err := a.result(); err != errNotDone {
106+
tc.t.Fatalf("AcceptStream() = _, %v; want errNotDone", err)
107+
}
108+
109+
sid := newStreamID(clientSide, bidiStream, 0)
110+
tc.writeFrames(packetType1RTT,
111+
debugFrameStream{
112+
id: sid,
113+
})
114+
115+
s, err := a.result()
116+
if err != nil {
117+
t.Fatalf("conn.AcceptStream() = _, %v, want stream", err)
118+
}
119+
if got, want := s.id, sid; got != want {
120+
t.Fatalf("conn.AcceptStream() = stream %v, want %v", got, want)
121+
}
122+
if got, want := s.IsReadOnly(), false; got != want {
123+
t.Fatalf("s.IsReadOnly() = %v, want %v", got, want)
124+
}
125+
}
126+
98127
func TestStreamsStreamNotCreated(t *testing.T) {
99128
// "An endpoint MUST terminate the connection with error STREAM_STATE_ERROR
100129
// if it receives a STREAM frame for a locally initiated stream that has

internal/quic/conn_test.go

+21-14
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ type testConn struct {
144144

145145
// Frame types to ignore in tests.
146146
ignoreFrames map[byte]bool
147+
148+
asyncTestState
147149
}
148150

149151
type keyData struct {
@@ -700,21 +702,26 @@ func (tc *testConnHooks) handleTLSEvent(e tls.QUICEvent) {
700702
// nextMessage is called by the Conn's event loop to request its next event.
701703
func (tc *testConnHooks) nextMessage(msgc chan any, timer time.Time) (now time.Time, m any) {
702704
tc.timer = timer
703-
if !timer.IsZero() && !timer.After(tc.now) {
704-
if timer.Equal(tc.timerLastFired) {
705-
// If the connection timer fires at time T, the Conn should take some
706-
// action to advance the timer into the future. If the Conn reschedules
707-
// the timer for the same time, it isn't making progress and we have a bug.
708-
tc.t.Errorf("connection timer spinning; now=%v timer=%v", tc.now, timer)
709-
} else {
710-
tc.timerLastFired = timer
711-
return tc.now, timerEvent{}
705+
for {
706+
if !timer.IsZero() && !timer.After(tc.now) {
707+
if timer.Equal(tc.timerLastFired) {
708+
// If the connection timer fires at time T, the Conn should take some
709+
// action to advance the timer into the future. If the Conn reschedules
710+
// the timer for the same time, it isn't making progress and we have a bug.
711+
tc.t.Errorf("connection timer spinning; now=%v timer=%v", tc.now, timer)
712+
} else {
713+
tc.timerLastFired = timer
714+
return tc.now, timerEvent{}
715+
}
716+
}
717+
select {
718+
case m := <-msgc:
719+
return tc.now, m
720+
default:
721+
}
722+
if !tc.wakeAsync() {
723+
break
712724
}
713-
}
714-
select {
715-
case m := <-msgc:
716-
return tc.now, m
717-
default:
718725
}
719726
// If the message queue is empty, then the conn is idle.
720727
if tc.idlec != nil {

internal/quic/queue.go

+13-1
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,20 @@ func (q *queue[T]) put(v T) bool {
4545
// get removes the first item from the queue, blocking until ctx is done, an item is available,
4646
// or the queue is closed.
4747
func (q *queue[T]) get(ctx context.Context) (T, error) {
48+
return q.getWithHooks(ctx, nil)
49+
}
50+
51+
// getWithHooks is get, but uses testHooks for locking when non-nil.
52+
// This is a bit of an layer violation, but a simplification overall.
53+
func (q *queue[T]) getWithHooks(ctx context.Context, testHooks connTestHooks) (T, error) {
4854
var zero T
49-
if err := q.gate.waitAndLockContext(ctx); err != nil {
55+
var err error
56+
if testHooks != nil {
57+
err = testHooks.waitAndLockGate(ctx, &q.gate)
58+
} else {
59+
err = q.gate.waitAndLockContext(ctx)
60+
}
61+
if err != nil {
5062
return zero, err
5163
}
5264
defer q.unlock()

0 commit comments

Comments
 (0)