1package quic
2
3import (
4 "context"
5 "io"
6 "net"
7 "strconv"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/bnema/vev/internal/ports"
13 "github.com/bnema/vev/internal/protocol/wire"
14 quicgo "github.com/quic-go/quic-go"
15 "github.com/stretchr/testify/require"
16)
17
18func TestQUICEnvelopeRoundTrip(t *testing.T) {
19 cert, fingerprint, err := GenerateEphemeralCert()
20 require.NoError(t, err)
21 require.Len(t, fingerprint, 32)
22
23 listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 4)
24 require.NoError(t, err)
25 defer func() { _ = listener.Close() }()
26
27 accepted := make(chan wire.Transport, 1)
28 go func() {
29 transport, err := listener.Accept()
30 if err != nil {
31 return
32 }
33 accepted <- transport
34 }()
35
36 dialer := DialConfig(listener.Addr(), "vev-bootstrap", fingerprint, Config{}, 10*time.Second)
37 client, err := dialer.Dial(context.Background())
38 require.NoError(t, err)
39 defer func() { _ = client.Close() }()
40
41 select {
42 case server := <-accepted:
43 defer func() { _ = server.Close() }()
44 payload := []byte("quic envelope stays exact")
45 require.NoError(t, client.Send(wire.Envelope{Payload: payload}))
46 got, err := server.Recv()
47 require.NoError(t, err)
48 require.Equal(t, payload, got.Payload)
49 require.NoError(t, server.Send(wire.Envelope{Payload: payload}))
50 echo, err := client.Recv()
51 require.NoError(t, err)
52 require.Equal(t, payload, echo.Payload)
53 case <-time.After(10 * time.Second):
54 t.Fatal("listener did not accept")
55 }
56}
57
58func TestQUICWrongFingerprintFails(t *testing.T) {
59 cert, _, err := GenerateEphemeralCert()
60 require.NoError(t, err)
61 listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 4)
62 require.NoError(t, err)
63 defer func() { _ = listener.Close() }()
64 accepted := make(chan struct{})
65 go func() {
66 defer close(accepted)
67 _, _ = listener.Accept()
68 }()
69
70 wrong := make([]byte, 32)
71 dialer := DialConfig(listener.Addr(), "vev-bootstrap", wrong, Config{}, 5*time.Second)
72 _, err = dialer.Dial(context.Background())
73 require.Error(t, err)
74}
75
76func TestQUICConfigUsesTunnelSafeInitialPacketSize(t *testing.T) {
77 for _, config := range []*quicgo.Config{clientQUICConfig(Config{}), serverQUICConfig(Config{})} {
78 require.Equal(t, uint16(1200), config.InitialPacketSize)
79 }
80}
81
82func TestQUICConfigValidation(t *testing.T) {
83 cert, _, err := GenerateEphemeralCert()
84 require.NoError(t, err)
85 _, err = ListenConfig("", cert, Config{KeepAlivePeriod: -time.Second}, 4)
86 require.Error(t, err)
87 _, err = ListenConfig("", cert, Config{MaxIncomingStreams: -1}, 4)
88 require.Error(t, err)
89 _, err = ListenConfig("", cert, Config{}, -1)
90 require.Error(t, err)
91 // Empty addr selects loopback, never the wildcard.
92 listener, err := ListenConfig("", cert, Config{}, 4)
93 require.NoError(t, err)
94 defer func() { _ = listener.Close() }()
95 require.Contains(t, listener.Addr(), "127.0.0.1")
96}
97
98// TestQUICOrderlyCloseDeliversFinalEnvelope is the regression guard for an
99// orderly close discarding the envelope written immediately before it: the
100// send side must be FINed, not RESET_STREAMed, so the peer still receives the
101// final envelope before observing EOF.
102func TestQUICOrderlyCloseDeliversFinalEnvelope(t *testing.T) {
103 server, client := dialTestPairWithConfig(t, Config{})
104 payload := []byte("final envelope survives an orderly close")
105 require.NoError(t, server.Send(wire.Envelope{Payload: payload}))
106 require.NoError(t, server.Close())
107
108 got, err := client.Recv()
109 require.NoError(t, err, "an orderly close must not discard the final envelope")
110 require.Equal(t, payload, got.Payload)
111 _, err = client.Recv()
112 require.ErrorIs(t, err, io.EOF)
113}
114
115func TestQUICResolveAddrsExpandsLiteralsAndHonorsContext(t *testing.T) {
116 addrs, err := resolveQUICAddrs(context.Background(), "127.0.0.1:4433")
117 require.NoError(t, err)
118 require.Len(t, addrs, 1)
119 require.Equal(t, 4433, addrs[0].Port)
120 require.True(t, addrs[0].IP.Equal(net.ParseIP("127.0.0.1")))
121
122 addrs, err = resolveQUICAddrs(context.Background(), "[::1]:4433")
123 require.NoError(t, err)
124 require.Len(t, addrs, 1)
125 require.True(t, addrs[0].IP.Equal(net.ParseIP("::1")))
126
127 _, err = resolveQUICAddrs(context.Background(), "127.0.0.1:notaport")
128 require.Error(t, err)
129
130 canceled, cancel := context.WithCancel(context.Background())
131 cancel()
132 _, err = resolveQUICAddrs(canceled, "definitely-not-a-real-host.invalid:4433")
133 require.Error(t, err, "resolution must be bounded by the dial context")
134}
135
136// TestQUICDialFallsBackAcrossResolvedAddresses proves every resolved address
137// is attempted under the one shared dial deadline: a black-holed first
138// candidate must not prevent the reachable second from being dialed.
139func TestQUICDialFallsBackAcrossResolvedAddresses(t *testing.T) {
140 cert, fingerprint, err := GenerateEphemeralCert()
141 require.NoError(t, err)
142 listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 4)
143 require.NoError(t, err)
144 defer func() { _ = listener.Close() }()
145 accepted := make(chan wire.Transport, 1)
146 go func() {
147 if transport, err := listener.Accept(); err == nil {
148 accepted <- transport
149 }
150 }()
151 _, portText, err := net.SplitHostPort(listener.Addr())
152 require.NoError(t, err)
153 port, err := strconv.Atoi(portText)
154 require.NoError(t, err)
155
156 dialer := DialConfig("placeholder:1", "vev-bootstrap", fingerprint, Config{}, 4*time.Second)
157 dialer.resolve = func(context.Context, string) ([]*net.UDPAddr, error) {
158 return []*net.UDPAddr{
159 {IP: net.ParseIP("127.0.0.1"), Port: 1}, // nothing listens here
160 {IP: net.ParseIP("127.0.0.1"), Port: port},
161 }, nil
162 }
163 client, err := dialer.Dial(context.Background())
164 require.NoError(t, err, "the dialer must fail over to a later resolved address")
165 defer func() { _ = client.Close() }()
166
167 select {
168 case server := <-accepted:
169 defer func() { _ = server.Close() }()
170 payload := []byte("second address")
171 require.NoError(t, client.Send(wire.Envelope{Payload: payload}))
172 got, err := server.Recv()
173 require.NoError(t, err)
174 require.Equal(t, payload, got.Payload)
175 case <-time.After(10 * time.Second):
176 t.Fatal("fallback address was not dialed")
177 }
178}
179
180type markRecorder struct {
181 mu sync.Mutex
182 marks []ports.RuntimeMark
183}
184
185func (r *markRecorder) ObserveRuntime(mark ports.RuntimeMark) {
186 r.mu.Lock()
187 r.marks = append(r.marks, mark)
188 r.mu.Unlock()
189}
190
191func (r *markRecorder) Flush() {}
192func (r *markRecorder) Close() {}
193
194// TestQUICDialObserverEmitsAdapterMarks proves the observer threaded through
195// DialConfig reaches the dialed transport and observes its raw carriage.
196func TestQUICDialObserverEmitsAdapterMarks(t *testing.T) {
197 cert, fingerprint, err := GenerateEphemeralCert()
198 require.NoError(t, err)
199 listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 4)
200 require.NoError(t, err)
201 defer func() { _ = listener.Close() }()
202 accepted := make(chan wire.Transport, 1)
203 go func() {
204 if transport, err := listener.Accept(); err == nil {
205 accepted <- transport
206 }
207 }()
208
209 recorder := &markRecorder{}
210 client, err := DialConfig(listener.Addr(), "vev-bootstrap", fingerprint, Config{}, 10*time.Second, WithRuntimeObserver(recorder)).Dial(context.Background())
211 require.NoError(t, err)
212 defer func() { _ = client.Close() }()
213
214 select {
215 case server := <-accepted:
216 defer func() { _ = server.Close() }()
217 require.NoError(t, client.Send(wire.Envelope{Payload: []byte("observed")}))
218 _, err = server.Recv()
219 require.NoError(t, err)
220 case <-time.After(10 * time.Second):
221 t.Fatal("listener did not accept")
222 }
223
224 recorder.mu.Lock()
225 defer recorder.mu.Unlock()
226 require.NotEmpty(t, recorder.marks, "observer must receive marks from the QUIC transport")
227 for _, mark := range recorder.marks {
228 require.Equal(t, "quic", mark.Component)
229 }
230}
231
232func TestQUICTransportCloseReportsEOF(t *testing.T) {
233 cert, fingerprint, err := GenerateEphemeralCert()
234 require.NoError(t, err)
235 listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 4)
236 require.NoError(t, err)
237 defer func() { _ = listener.Close() }()
238
239 accepted := make(chan wire.Transport, 1)
240 go func() {
241 transport, err := listener.Accept()
242 if err != nil {
243 return
244 }
245 accepted <- transport
246 }()
247 dialer := DialConfig(listener.Addr(), "vev-bootstrap", fingerprint, Config{}, 10*time.Second)
248 client, err := dialer.Dial(context.Background())
249 require.NoError(t, err)
250
251 select {
252 case server := <-accepted:
253 // A graceful peer close reads as EOF, like IPC/SSH.
254 require.NoError(t, server.Close())
255 _, err = client.Recv()
256 require.ErrorIs(t, err, io.EOF)
257 _ = client.Close()
258 case <-time.After(10 * time.Second):
259 t.Fatal("listener did not accept")
260 _ = client.Close()
261 }
262}
263
264func TestQUICProbeSkippedOnce(t *testing.T) {
265 cert, fingerprint, err := GenerateEphemeralCert()
266 require.NoError(t, err)
267 listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 4)
268 require.NoError(t, err)
269 defer func() { _ = listener.Close() }()
270
271 accepted := make(chan wire.Transport, 1)
272 go func() {
273 transport, err := listener.Accept()
274 if err != nil {
275 return
276 }
277 accepted <- transport
278 }()
279 dialer := DialConfig(listener.Addr(), "vev-bootstrap", fingerprint, Config{}, 10*time.Second)
280 client, err := dialer.Dial(context.Background())
281 require.NoError(t, err)
282
283 select {
284 case server := <-accepted:
285 defer func() { _ = server.Close() }()
286 defer func() { _ = client.Close() }()
287 // The dial probe is skipped silently; the next Recv blocks
288 // for real traffic, so close unblocks it with an error.
289 received := make(chan error, 1)
290 go func() {
291 _, err := server.Recv()
292 received <- err
293 }()
294 require.NoError(t, client.Close())
295 select {
296 case err := <-received:
297 require.Error(t, err)
298 case <-time.After(10 * time.Second):
299 t.Fatal("server Recv was not unblocked")
300 }
301 case <-time.After(10 * time.Second):
302 t.Fatal("listener did not accept")
303 _ = client.Close()
304 }
305}
306
307func TestQUICCloseUnblocksRecv(t *testing.T) {
308 cert, fingerprint, err := GenerateEphemeralCert()
309 require.NoError(t, err)
310 listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 4)
311 require.NoError(t, err)
312 defer func() { _ = listener.Close() }()
313
314 accepted := make(chan wire.Transport, 1)
315 go func() {
316 transport, err := listener.Accept()
317 if err != nil {
318 return
319 }
320 accepted <- transport
321 }()
322 dialer := DialConfig(listener.Addr(), "vev-bootstrap", fingerprint, Config{}, 10*time.Second)
323 client, err := dialer.Dial(context.Background())
324 require.NoError(t, err)
325
326 select {
327 case server := <-accepted:
328 received := make(chan error, 1)
329 go func() {
330 _, err := server.Recv()
331 received <- err
332 }()
333 require.NoError(t, client.Close())
334 select {
335 case err := <-received:
336 require.Error(t, err)
337 case <-time.After(10 * time.Second):
338 t.Fatal("server Recv was not unblocked by client close")
339 }
340 _ = server.Close()
341 case <-time.After(10 * time.Second):
342 t.Fatal("listener did not accept")
343 _ = client.Close()
344 }
345}
346
347// dialTestPairWithConfig returns the server and client ends of one live
348// transport whose listener uses the supplied Config, both enforcing the
349// one-stream contract.
350func dialTestPairWithConfig(t *testing.T, config Config) (*Transport, *Transport) {
351 t.Helper()
352 cert, fingerprint, err := GenerateEphemeralCert()
353 require.NoError(t, err)
354 listener, err := ListenConfig("127.0.0.1:0", cert, config, 4)
355 require.NoError(t, err)
356 t.Cleanup(func() { _ = listener.Close() })
357 accepted := make(chan *Transport, 1)
358 go func() {
359 transport, err := listener.Accept()
360 if err == nil {
361 accepted <- transport.(*Transport)
362 }
363 }()
364 client, err := DialConfig(listener.Addr(), "vev-bootstrap", fingerprint, Config{}, 10*time.Second).Dial(context.Background())
365 require.NoError(t, err)
366 t.Cleanup(func() { _ = client.Close() })
367 select {
368 case server := <-accepted:
369 t.Cleanup(func() { _ = server.Close() })
370 return server, client.(*Transport)
371 case <-time.After(10 * time.Second):
372 t.Fatal("listener did not accept")
373 return nil, nil
374 }
375}
376
377// TestQUICUniStreamsTrulyDisabled proves the zero Config keeps quic-go's
378// 100-stream library default from leaking in: without a negative limit the
379// peer could open unidirectional streams.
380func TestQUICUniStreamsTrulyDisabled(t *testing.T) {
381 _, client := dialTestPairWithConfig(t, Config{})
382 ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
383 defer cancel()
384 _, err := client.conn.OpenUniStreamSync(ctx)
385 require.Error(t, err, "unidirectional streams must be truly disabled, not quic-go's 100-stream default")
386}
387
388// TestQUICServerCannotOpenStreams proves role-forbidden streams are truly
389// disabled on the dialer side: the server never opens streams toward the
390// client.
391func TestQUICServerCannotOpenStreams(t *testing.T) {
392 server, _ := dialTestPairWithConfig(t, Config{})
393 openers := map[string]func(context.Context) error{
394 "bidi": func(ctx context.Context) error {
395 _, err := server.conn.OpenStreamSync(ctx)
396 return err
397 },
398 "uni": func(ctx context.Context) error {
399 _, err := server.conn.OpenUniStreamSync(ctx)
400 return err
401 },
402 }
403 for name, open := range openers {
404 t.Run(name, func(t *testing.T) {
405 ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
406 defer cancel()
407 require.Error(t, open(ctx), "the server role must not open streams toward the dialer")
408 })
409 }
410}
411
412// TestQUICDelayedExtraStreamEnforced proves the one-stream contract holds
413// for the whole connection lifetime, not just a handshake window: a second
414// stream opened well after setup fails the live transport instead of being
415// silently tolerated. The listener's explicit limit of two makes the second
416// stream admissible at the QUIC layer so the test exercises the lifetime
417// guard, not flow control.
418func TestQUICDelayedExtraStreamEnforced(t *testing.T) {
419 server, client := dialTestPairWithConfig(t, Config{MaxIncomingStreams: 2})
420 // Land the violation long after the handshake has settled.
421 time.Sleep(200 * time.Millisecond)
422 openCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
423 defer cancel()
424 extra, err := client.conn.OpenStreamSync(openCtx)
425 require.NoError(t, err, "second stream is admissible under the explicit limit")
426 defer func() { _ = extra.Close() }()
427 // QUIC has no stream-open frame: the peer only learns about the stream
428 // when a frame referencing it arrives.
429 _, err = extra.Write([]byte("violation"))
430 require.NoError(t, err)
431
432 recvErr := make(chan error, 1)
433 go func() {
434 _, err := server.Recv()
435 recvErr <- err
436 }()
437 select {
438 case err := <-recvErr:
439 require.Error(t, err, "delayed extra stream must fail the live transport")
440 case <-time.After(5 * time.Second):
441 t.Fatal("delayed extra stream was not enforced beyond the startup window")
442 }
443}
444
445// TestQUICDialHasNoStartupGraceLatency proves connection setup does not pay a
446// fixed per-connection stream-contract grace window. Each such window cost a
447// flat 100ms; ten sequential loopback dials must now finish in a fraction of
448// that accumulated cost, even on a loaded race-enabled machine. The bound is
449// deliberately loose (well under the ~1s the removed window alone would add)
450// so it measures the regression without being a tight timing assertion.
451func TestQUICDialHasNoStartupGraceLatency(t *testing.T) {
452 cert, fingerprint, err := GenerateEphemeralCert()
453 require.NoError(t, err)
454 listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 16)
455 require.NoError(t, err)
456 accepted := make(chan wire.Transport, 16)
457 acceptorDone := make(chan struct{})
458 go func() {
459 defer close(acceptorDone)
460 for {
461 transport, err := listener.Accept()
462 if err != nil {
463 return
464 }
465 accepted <- transport
466 }
467 }()
468
469 const dials = 10
470 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
471 defer cancel()
472 start := time.Now()
473 for range dials {
474 client, err := DialConfig(listener.Addr(), "vev-bootstrap", fingerprint, Config{}, 5*time.Second).Dial(ctx)
475 require.NoError(t, err)
476 require.NoError(t, client.Close())
477 }
478 elapsed := time.Since(start)
479 require.Less(t, elapsed, 700*time.Millisecond,
480 "sequential dials must not pay a per-connection startup grace window")
481
482 require.NoError(t, listener.Close())
483 <-acceptorDone
484 close(accepted)
485 for transport := range accepted {
486 _ = transport.Close()
487 }
488}
489
490// readLinkEvent reads one link event or fails the test.
491func readLinkEvent(t *testing.T, events <-chan ports.LinkEvent) (ports.LinkEvent, bool) {
492 t.Helper()
493 select {
494 case event, ok := <-events:
495 return event, ok
496 case <-time.After(5 * time.Second):
497 t.Fatal("timed out waiting for a link event")
498 return ports.LinkEvent{}, false
499 }
500}
501
502// TestQUICLinkEventsReportTerminalOffline proves the LinkEvents contract is
503// honest: the adapter publishes the terminal Offline transition and closes
504// the event stream, locally on Close and remotely when the peer ends the
505// connection, without leaking a QUIC library type in LinkEvent.Err.
506func TestQUICLinkEventsReportTerminalOffline(t *testing.T) {
507 server, client := dialTestPairWithConfig(t, Config{})
508 require.Equal(t, ports.LinkStateConnected, server.LinkState())
509 require.Equal(t, ports.LinkStateConnected, client.LinkState())
510
511 require.NoError(t, server.Close())
512 require.Equal(t, ports.LinkStateOffline, server.LinkState())
513 event, ok := readLinkEvent(t, server.LinkEvents())
514 require.True(t, ok)
515 require.Equal(t, ports.LinkStateOffline, event.State)
516 require.NoError(t, event.Err, "a local Close is not a failure")
517 _, ok = readLinkEvent(t, server.LinkEvents())
518 require.False(t, ok, "local event stream must close after the terminal event")
519
520 require.Eventually(t, func() bool {
521 return client.LinkState() == ports.LinkStateOffline
522 }, 5*time.Second, 10*time.Millisecond, "peer must observe the terminal Offline state")
523 event, ok = readLinkEvent(t, client.LinkEvents())
524 require.True(t, ok)
525 require.Equal(t, ports.LinkStateOffline, event.State)
526 require.ErrorIs(t, event.Err, io.EOF, "peer failure must map to a boundary-safe error")
527 _, ok = readLinkEvent(t, client.LinkEvents())
528 require.False(t, ok, "peer event stream must close after the terminal event")
529}
530
531func (r *markRecorder) snapshot() []ports.RuntimeMark {
532 r.mu.Lock()
533 defer r.mu.Unlock()
534 return append([]ports.RuntimeMark(nil), r.marks...)
535}
536
537// assertBalancedQUICMarks proves every started adapter operation also closed:
538// the transport must never leak a start mark when Close (or a failure) ends an
539// operation.
540func assertBalancedQUICMarks(t *testing.T, marks []ports.RuntimeMark) {
541 t.Helper()
542 starts := make(map[uint64]ports.RuntimeMarkKind)
543 for _, mark := range marks {
544 switch mark.Kind {
545 case ports.RuntimeAdapterSendStart, ports.RuntimeAdapterReceiveStart:
546 _, duplicate := starts[mark.Sequence]
547 require.False(t, duplicate, "duplicate start mark for one operation")
548 starts[mark.Sequence] = mark.Kind
549 case ports.RuntimeAdapterSendEnd, ports.RuntimeAdapterReceiveEnd:
550 start, ok := starts[mark.Sequence]
551 require.True(t, ok, "end mark without a start mark")
552 want := ports.RuntimeAdapterSendEnd
553 if start == ports.RuntimeAdapterReceiveStart {
554 want = ports.RuntimeAdapterReceiveEnd
555 }
556 require.Equal(t, want, mark.Kind)
557 delete(starts, mark.Sequence)
558 }
559 }
560 require.Empty(t, starts, "start marks without an end mark")
561}
562
563// dialObservedTestPair dials a listener configured with observer, so the
564// accepted (accept-side) transport carries runtime marks.
565func dialObservedTestPair(t *testing.T, observer ports.SerializedRuntimeObserver) (wire.Transport, wire.Transport) {
566 t.Helper()
567 cert, fingerprint, err := GenerateEphemeralCert()
568 require.NoError(t, err)
569 listener, err := ListenConfig("127.0.0.1:0", cert, Config{}, 4, WithListenerRuntimeObserver(observer))
570 require.NoError(t, err)
571 t.Cleanup(func() { _ = listener.Close() })
572
573 accepted := make(chan wire.Transport, 1)
574 go func() {
575 if transport, err := listener.Accept(); err == nil {
576 accepted <- transport
577 }
578 }()
579
580 client, err := DialConfig(listener.Addr(), "vev-bootstrap", fingerprint, Config{}, 10*time.Second).Dial(context.Background())
581 require.NoError(t, err)
582 t.Cleanup(func() { _ = client.Close() })
583
584 select {
585 case server := <-accepted:
586 t.Cleanup(func() { _ = server.Close() })
587 return client, server
588 case <-time.After(10 * time.Second):
589 t.Fatal("listener did not accept")
590 return nil, nil
591 }
592}
593
594// TestQUICAcceptObserverEmitsAdapterMarks proves the observer wired through
595// the listener (not just DialConfig) reaches the accepted transport.
596func TestQUICAcceptObserverEmitsAdapterMarks(t *testing.T) {
597 recorder := &markRecorder{}
598 client, server := dialObservedTestPair(t, recorder)
599
600 require.NoError(t, client.Send(wire.Envelope{Payload: []byte("observed")}))
601 got, err := server.Recv()
602 require.NoError(t, err)
603 require.Equal(t, []byte("observed"), got.Payload)
604 require.NoError(t, server.Send(wire.Envelope{Payload: []byte("ack")}))
605 _, err = client.Recv()
606 require.NoError(t, err)
607
608 marks := recorder.snapshot()
609 require.NotEmpty(t, marks, "accept side must observe carriage marks")
610 for _, mark := range marks {
611 require.Equal(t, "quic", mark.Component)
612 }
613 assertBalancedQUICMarks(t, marks)
614}
615
616// TestQUICObserverMarksBalanceAcrossClose proves Close gates new observed
617// operations and waits for an in-flight blocked Recv to close its mark pair.
618func TestQUICObserverMarksBalanceAcrossClose(t *testing.T) {
619 recorder := &markRecorder{}
620 client, server := dialObservedTestPair(t, recorder)
621
622 require.NoError(t, client.Send(wire.Envelope{Payload: []byte("before close")}))
623 _, err := server.Recv()
624 require.NoError(t, err)
625
626 blocked := make(chan struct{})
627 go func() {
628 _, _ = server.Recv()
629 close(blocked)
630 }()
631 require.NoError(t, server.Close())
632 <-blocked
633
634 before := len(recorder.snapshot())
635 // A Send after Close fails closed without opening a new observed operation.
636 _ = server.Send(wire.Envelope{Payload: []byte("late")})
637 marks := recorder.snapshot()
638 require.Equal(t, before, len(marks), "operations after Close must not be observed")
639 assertBalancedQUICMarks(t, marks)
640}