proxy_test.go RawHistoryFolder

1package quicnettest
2
3import (
4	"encoding/binary"
5	"net"
6	"reflect"
7	"sync"
8	"testing"
9	"time"
10)
11
12const awaitTimeout = 3 * time.Second
13
14// scriptedRNG returns pre-programmed values so tests can force impairment
15// decisions without depending on a seed's random stream.
16type scriptedRNG struct {
17	mu     sync.Mutex
18	values []int
19	next   int
20}
21
22func (s *scriptedRNG) Intn(n int) int {
23	s.mu.Lock()
24	defer s.mu.Unlock()
25	if len(s.values) == 0 || n <= 0 {
26		return 0
27	}
28	value := s.values[s.next%len(s.values)]
29	s.next++
30	return value % n
31}
32
33type datagram struct {
34	payload []byte
35	from    *net.UDPAddr
36}
37
38func startSinkServer(t *testing.T) (*net.UDPAddr, <-chan datagram) {
39	t.Helper()
40	conn, packets := serveUDP(t, false)
41	return conn.LocalAddr().(*net.UDPAddr), packets
42}
43
44func startEchoServer(t *testing.T) (*net.UDPAddr, <-chan datagram) {
45	t.Helper()
46	conn, packets := serveUDP(t, true)
47	return conn.LocalAddr().(*net.UDPAddr), packets
48}
49
50func serveUDP(t *testing.T, echo bool) (*net.UDPConn, <-chan datagram) {
51	t.Helper()
52	conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
53	if err != nil {
54		t.Fatalf("listen udp server: %v", err)
55	}
56	t.Cleanup(func() { _ = conn.Close() })
57	// Flows burst hundreds of MTU-sized datagrams. The default receive buffer
58	// (about 200 KiB) holds too few of them when a loaded machine delays the
59	// reader, and the kernel then drops datagrams the test is still counting.
60	if err := conn.SetReadBuffer(4 << 20); err != nil {
61		t.Fatalf("size udp server buffer: %v", err)
62	}
63	packets := make(chan datagram, 4096)
64	go func() {
65		buf := make([]byte, 2048)
66		for {
67			n, from, err := conn.ReadFromUDP(buf)
68			if err != nil {
69				return
70			}
71			payload := append([]byte(nil), buf[:n]...)
72			packets <- datagram{payload: payload, from: from}
73			if echo {
74				if _, err := conn.WriteToUDP(payload, from); err != nil {
75					return
76				}
77			}
78		}
79	}()
80	return conn, packets
81}
82
83func newUDPClient(t *testing.T) *net.UDPConn {
84	t.Helper()
85	conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
86	if err != nil {
87		t.Fatalf("listen udp client: %v", err)
88	}
89	t.Cleanup(func() { _ = conn.Close() })
90	return conn
91}
92
93func newTestProxy(t *testing.T, cfg Config) (*Proxy, *ManualClock, <-chan Event) {
94	t.Helper()
95	clock := NewManualClock(time.Unix(1000, 0))
96	events := make(chan Event, 4096)
97	cfg.Clock = clock
98	cfg.OnPacket = func(event Event) { events <- event }
99	proxy, err := New(cfg)
100	if err != nil {
101		t.Fatalf("new proxy: %v", err)
102	}
103	t.Cleanup(func() { _ = proxy.Close() })
104	return proxy, clock, events
105}
106
107func send(t *testing.T, conn *net.UDPConn, dst *net.UDPAddr, payload []byte) {
108	t.Helper()
109	if _, err := conn.WriteToUDP(payload, dst); err != nil {
110		t.Fatalf("send %d-byte packet: %v", len(payload), err)
111	}
112}
113
114// sendPackets sends count datagrams in bounded bursts and returns the events
115// observed for them, so the client never outruns the proxy's receive buffer.
116func sendPackets(t *testing.T, client *net.UDPConn, dst *net.UDPAddr, events <-chan Event, count, size int) []Event {
117	t.Helper()
118	const burst = 20
119	payload := make([]byte, size)
120	observed := make([]Event, 0, count)
121	for sent := 0; sent < count; {
122		stop := sent + burst
123		if stop > count {
124			stop = count
125		}
126		for i := sent; i < stop; i++ {
127			binary.BigEndian.PutUint32(payload, uint32(i))
128			send(t, client, dst, payload)
129		}
130		for i := sent; i < stop; i++ {
131			observed = append(observed, awaitEvent(t, events))
132		}
133		sent = stop
134	}
135	return observed
136}
137
138func readPacket(t *testing.T, conn *net.UDPConn) []byte {
139	t.Helper()
140	if err := conn.SetReadDeadline(time.Now().Add(awaitTimeout)); err != nil {
141		t.Fatalf("set read deadline: %v", err)
142	}
143	buf := make([]byte, 2048)
144	n, _, err := conn.ReadFromUDP(buf)
145	if err != nil {
146		t.Fatalf("read reply: %v", err)
147	}
148	return append([]byte(nil), buf[:n]...)
149}
150
151func awaitPacket(t *testing.T, packets <-chan datagram) datagram {
152	t.Helper()
153	select {
154	case packet := <-packets:
155		return packet
156	case <-time.After(awaitTimeout):
157		t.Fatal("timed out waiting for a server receipt")
158	}
159	return datagram{}
160}
161
162func awaitEvent(t *testing.T, events <-chan Event) Event {
163	t.Helper()
164	select {
165	case event := <-events:
166		return event
167	case <-time.After(awaitTimeout):
168		t.Fatal("timed out waiting for an impairment event")
169	}
170	return Event{}
171}
172
173func expectNoDatagram(t *testing.T, packets <-chan datagram) {
174	t.Helper()
175	select {
176	case packet := <-packets:
177		t.Fatalf("unexpected server receipt %q", packet.payload)
178	default:
179	}
180}
181
182func TestNewRejectsInvalidConfig(t *testing.T) {
183	if _, err := New(Config{}); err == nil {
184		t.Fatal("New accepted a config without ServerAddr")
185	}
186	server := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 9}
187	if _, err := New(Config{ServerAddr: server, ToServer: LinkConfig{LossPercent: 101}}); err == nil {
188		t.Fatal("New accepted LossPercent 101")
189	}
190	if _, err := New(Config{ServerAddr: server, ToClient: LinkConfig{Blackouts: []Blackout{{Start: time.Second, End: time.Second}}}}); err == nil {
191		t.Fatal("New accepted an empty blackout window")
192	}
193}
194
195func TestProxyForwardsTrafficBothDirections(t *testing.T) {
196	serverAddr, serverPackets := startEchoServer(t)
197	proxy, _, events := newTestProxy(t, Config{ServerAddr: serverAddr, Seed: 1})
198	client := newUDPClient(t)
199
200	send(t, client, proxy.Addr(), []byte("hello quic"))
201
202	up := awaitEvent(t, events)
203	if up.Direction != ToServer {
204		t.Fatalf("first event direction = %s, want to-server", up.Direction)
205	}
206	if up.Decision.Drop {
207		t.Fatalf("to-server decision = %+v, want forward", up.Decision)
208	}
209	if got := string(awaitPacket(t, serverPackets).payload); got != "hello quic" {
210		t.Fatalf("server received %q, want %q", got, "hello quic")
211	}
212
213	down := awaitEvent(t, events)
214	if down.Direction != ToClient {
215		t.Fatalf("second event direction = %s, want to-client", down.Direction)
216	}
217	if got := string(readPacket(t, client)); got != "hello quic" {
218		t.Fatalf("client received %q, want %q", got, "hello quic")
219	}
220
221	stats := proxy.Stats()
222	if stats.ToServer.Received != 1 || stats.ToServer.Sent != 1 {
223		t.Fatalf("to-server stats = %s, want one received and sent packet", stats.ToServer)
224	}
225	if stats.ToClient.Received != 1 || stats.ToClient.Sent != 1 {
226		t.Fatalf("to-client stats = %s, want one received and sent packet", stats.ToClient)
227	}
228	if stats.StrayPackets != 0 || stats.Rebinds != 0 {
229		t.Fatalf("unexpected stray/rebind counters: %s", stats)
230	}
231}
232
233func TestProxySeededLossIsReproducible(t *testing.T) {
234	const packets = 100
235	run := func(t *testing.T) (Stats, []Event) {
236		t.Helper()
237		serverAddr, serverPackets := startSinkServer(t)
238		proxy, clock, events := newTestProxy(t, Config{
239			ServerAddr: serverAddr,
240			Seed:       42,
241			ToServer:   LinkConfig{BaseLatency: time.Millisecond, LossPercent: 25},
242		})
243		client := newUDPClient(t)
244		observed := sendPackets(t, client, proxy.Addr(), events, packets, 64)
245
246		expected := 0
247		for _, event := range observed {
248			if !event.Decision.Drop {
249				expected++
250			}
251		}
252		clock.Advance(time.Millisecond)
253		for i := 0; i < expected; i++ {
254			awaitPacket(t, serverPackets)
255		}
256		stats := proxy.Stats()
257		if stats.ToServer.Received != packets {
258			t.Fatalf("received = %d, want %d", stats.ToServer.Received, packets)
259		}
260		return stats, observed
261	}
262
263	firstStats, firstEvents := run(t)
264	secondStats, secondEvents := run(t)
265	if firstStats.ToServer != secondStats.ToServer {
266		t.Fatalf("same seed produced different stats:\nfirst  = %s\nsecond = %s", firstStats.ToServer, secondStats.ToServer)
267	}
268	if !reflect.DeepEqual(firstEvents, secondEvents) {
269		t.Fatal("same seed produced different impairment decisions")
270	}
271	if firstStats.ToServer.LossDrops == 0 || firstStats.ToServer.LossDrops >= packets {
272		t.Fatalf("loss drops = %d, want a partial loss sample", firstStats.ToServer.LossDrops)
273	}
274	if want := uint64(packets) - firstStats.ToServer.LossDrops; firstStats.ToServer.Sent != want {
275		t.Fatalf("sent = %d, want %d", firstStats.ToServer.Sent, want)
276	}
277}
278
279func TestProxyDuplicationRepeatsPackets(t *testing.T) {
280	const packets = 3
281	serverAddr, serverPackets := startSinkServer(t)
282	proxy, _, events := newTestProxy(t, Config{
283		ServerAddr: serverAddr,
284		Seed:       3,
285		ToServer:   LinkConfig{DuplicatePercent: 100},
286	})
287	client := newUDPClient(t)
288
289	observed := sendPackets(t, client, proxy.Addr(), events, packets, 32)
290	for i, event := range observed {
291		if event.Decision.Duplicates != 1 {
292			t.Fatalf("packet %d decision = %+v, want one duplicate", i, event.Decision)
293		}
294	}
295	for i := 0; i < packets*2; i++ {
296		awaitPacket(t, serverPackets)
297	}
298	stats := proxy.Stats()
299	if stats.ToServer.Duplicates != packets {
300		t.Fatalf("duplicates = %d, want %d", stats.ToServer.Duplicates, packets)
301	}
302	if stats.ToServer.Sent != packets*2 {
303		t.Fatalf("sent = %d, want %d", stats.ToServer.Sent, packets*2)
304	}
305}
306
307func TestProxyReorderingDeliversOutOfOrder(t *testing.T) {
308	serverAddr, serverPackets := startSinkServer(t)
309	proxy, clock, events := newTestProxy(t, Config{
310		ServerAddr: serverAddr,
311		RNG:        &scriptedRNG{values: []int{0, 99}},
312		ToServer:   LinkConfig{ReorderPercent: 50, ReorderDelay: 5 * time.Millisecond},
313	})
314	client := newUDPClient(t)
315
316	send(t, client, proxy.Addr(), []byte("first"))
317	send(t, client, proxy.Addr(), []byte("second"))
318
319	first := awaitEvent(t, events)
320	if !first.Decision.Reordered {
321		t.Fatalf("first decision = %+v, want reordered", first.Decision)
322	}
323	second := awaitEvent(t, events)
324	if second.Decision.Reordered {
325		t.Fatalf("second decision = %+v, want not reordered", second.Decision)
326	}
327	if !second.Due.Before(first.Due) {
328		t.Fatalf("second due %s is not before first due %s", second.Due, first.Due)
329	}
330
331	clock.Advance(5 * time.Millisecond)
332	if got := string(awaitPacket(t, serverPackets).payload); got != "second" {
333		t.Fatalf("first server receipt = %q, want %q", got, "second")
334	}
335	if got := string(awaitPacket(t, serverPackets).payload); got != "first" {
336		t.Fatalf("second server receipt = %q, want %q", got, "first")
337	}
338	if stats := proxy.Stats(); stats.ToServer.Reordered != 1 {
339		t.Fatalf("reordered = %d, want 1", stats.ToServer.Reordered)
340	}
341}
342
343func TestProxyLatencyHoldsPacketsUntilClockAdvances(t *testing.T) {
344	serverAddr, serverPackets := startSinkServer(t)
345	proxy, clock, events := newTestProxy(t, Config{
346		ServerAddr: serverAddr,
347		Seed:       4,
348		ToServer:   LinkConfig{BaseLatency: 3 * time.Millisecond},
349	})
350	client := newUDPClient(t)
351
352	send(t, client, proxy.Addr(), []byte("held"))
353	awaitEvent(t, events)
354	expectNoDatagram(t, serverPackets)
355
356	clock.Advance(2 * time.Millisecond)
357	expectNoDatagram(t, serverPackets)
358
359	clock.Advance(time.Millisecond)
360	if got := string(awaitPacket(t, serverPackets).payload); got != "held" {
361		t.Fatalf("server received %q, want %q", got, "held")
362	}
363	if stats := proxy.Stats(); stats.ToServer.Sent != 1 {
364		t.Fatalf("sent = %d, want 1", stats.ToServer.Sent)
365	}
366}
367
368func TestProxyBlackoutWindowDropsDuringInterval(t *testing.T) {
369	serverAddr, serverPackets := startSinkServer(t)
370	proxy, clock, events := newTestProxy(t, Config{
371		ServerAddr: serverAddr,
372		Seed:       5,
373		ToServer: LinkConfig{Blackouts: []Blackout{{
374			Start: 5 * time.Millisecond,
375			End:   10 * time.Millisecond,
376		}}},
377	})
378	client := newUDPClient(t)
379
380	send(t, client, proxy.Addr(), []byte("before"))
381	if event := awaitEvent(t, events); event.Decision.Drop {
382		t.Fatalf("packet before blackout dropped: %+v", event.Decision)
383	}
384	if got := string(awaitPacket(t, serverPackets).payload); got != "before" {
385		t.Fatalf("server received %q, want %q", got, "before")
386	}
387
388	clock.Advance(5 * time.Millisecond)
389	send(t, client, proxy.Addr(), []byte("during"))
390	during := awaitEvent(t, events)
391	if !during.Decision.Drop || !during.Decision.Blackout {
392		t.Fatalf("during decision = %+v, want a blackout drop", during.Decision)
393	}
394
395	clock.Advance(6 * time.Millisecond)
396	send(t, client, proxy.Addr(), []byte("after"))
397	if event := awaitEvent(t, events); event.Decision.Drop {
398		t.Fatalf("packet after blackout dropped: %+v", event.Decision)
399	}
400	if got := string(awaitPacket(t, serverPackets).payload); got != "after" {
401		t.Fatalf("server received %q, want %q", got, "after")
402	}
403
404	stats := proxy.Stats()
405	if stats.ToServer.BlackoutDrops != 1 {
406		t.Fatalf("blackout drops = %d, want 1", stats.ToServer.BlackoutDrops)
407	}
408	if stats.ToServer.Received != 3 || stats.ToServer.Sent != 2 {
409		t.Fatalf("to-server stats = %s, want 3 received and 2 sent", stats.ToServer)
410	}
411}
412
413func TestProxyManualBlackoutToggles(t *testing.T) {
414	serverAddr, serverPackets := startSinkServer(t)
415	proxy, _, events := newTestProxy(t, Config{ServerAddr: serverAddr, Seed: 6})
416	client := newUDPClient(t)
417
418	proxy.SetBlackout(true)
419	send(t, client, proxy.Addr(), []byte("blacked"))
420	if event := awaitEvent(t, events); !event.Decision.Blackout {
421		t.Fatalf("decision = %+v, want a blackout drop", event.Decision)
422	}
423	expectNoDatagram(t, serverPackets)
424
425	proxy.SetBlackout(false)
426	send(t, client, proxy.Addr(), []byte("cleared"))
427	if event := awaitEvent(t, events); event.Decision.Drop {
428		t.Fatalf("decision = %+v, want forward", event.Decision)
429	}
430	if got := string(awaitPacket(t, serverPackets).payload); got != "cleared" {
431		t.Fatalf("server received %q, want %q", got, "cleared")
432	}
433}
434
435func TestProxyQueueCapacityBoundsInFlightPackets(t *testing.T) {
436	serverAddr, serverPackets := startSinkServer(t)
437	proxy, clock, events := newTestProxy(t, Config{
438		ServerAddr:    serverAddr,
439		Seed:          11,
440		QueueCapacity: 2,
441		ToServer:      LinkConfig{BaseLatency: time.Second},
442	})
443	client := newUDPClient(t)
444
445	observed := sendPackets(t, client, proxy.Addr(), events, 10, 32)
446	for _, event := range observed {
447		if event.Decision.Drop {
448			t.Fatalf("unexpected impairment drop: %+v", event.Decision)
449		}
450	}
451	stats := proxy.Stats()
452	if stats.ToServer.OverflowDrops != 8 {
453		t.Fatalf("overflow drops = %d, want 8", stats.ToServer.OverflowDrops)
454	}
455	if stats.ToServer.Sent != 0 {
456		t.Fatalf("sent = %d, want 0 before the latency elapses", stats.ToServer.Sent)
457	}
458	expectNoDatagram(t, serverPackets)
459
460	clock.Advance(time.Second)
461	for i := 0; i < 2; i++ {
462		awaitPacket(t, serverPackets)
463	}
464	if got := proxy.Stats().ToServer.Sent; got != 2 {
465		t.Fatalf("sent = %d, want 2", got)
466	}
467}
468
469func TestProxyDropsOversizeDatagrams(t *testing.T) {
470	serverAddr, serverPackets := startSinkServer(t)
471	proxy, _, events := newTestProxy(t, Config{
472		ServerAddr:    serverAddr,
473		Seed:          8,
474		MaxPacketSize: 64,
475	})
476	client := newUDPClient(t)
477
478	send(t, client, proxy.Addr(), make([]byte, 65))
479	event := awaitEvent(t, events)
480	if !event.Decision.Drop || !event.Decision.Oversize {
481		t.Fatalf("decision = %+v, want an oversize drop", event.Decision)
482	}
483	expectNoDatagram(t, serverPackets)
484
485	stats := proxy.Stats()
486	if stats.ToServer.OversizeDrops != 1 || stats.ToServer.Received != 0 {
487		t.Fatalf("to-server stats = %s, want one oversize drop and no receipt", stats.ToServer)
488	}
489}
490
491func TestProxyRebindChangesUpstreamSourceAddress(t *testing.T) {
492	serverAddr, serverPackets := startSinkServer(t)
493	proxy, _, events := newTestProxy(t, Config{
494		ServerAddr:            serverAddr,
495		Seed:                  13,
496		NATRebindAfterPackets: 1,
497	})
498	client := newUDPClient(t)
499
500	send(t, client, proxy.Addr(), []byte("one"))
501	awaitEvent(t, events)
502	first := awaitPacket(t, serverPackets)
503
504	send(t, client, proxy.Addr(), []byte("two"))
505	awaitEvent(t, events)
506	second := awaitPacket(t, serverPackets)
507
508	if first.from.Port == second.from.Port {
509		t.Fatalf("upstream source port stayed %d across a NAT rebind", first.from.Port)
510	}
511	if got := proxy.Stats().Rebinds; got < 1 {
512		t.Fatalf("rebinds = %d, want at least 1", got)
513	}
514}
515
516func TestProxyExplicitRebindKeepsRoundTripWorking(t *testing.T) {
517	serverAddr, serverPackets := startEchoServer(t)
518	proxy, _, events := newTestProxy(t, Config{ServerAddr: serverAddr, Seed: 17})
519	client := newUDPClient(t)
520
521	send(t, client, proxy.Addr(), []byte("one"))
522	awaitEvent(t, events)
523	awaitEvent(t, events)
524	if got := string(readPacket(t, client)); got != "one" {
525		t.Fatalf("client received %q, want %q", got, "one")
526	}
527	before := awaitPacket(t, serverPackets)
528
529	if err := proxy.Rebind(); err != nil {
530		t.Fatalf("rebind: %v", err)
531	}
532
533	send(t, client, proxy.Addr(), []byte("two"))
534	awaitEvent(t, events)
535	awaitEvent(t, events)
536	if got := string(readPacket(t, client)); got != "two" {
537		t.Fatalf("client received %q, want %q", got, "two")
538	}
539	after := awaitPacket(t, serverPackets)
540
541	if before.from.Port == after.from.Port {
542		t.Fatalf("upstream source port stayed %d after Rebind", before.from.Port)
543	}
544	stats := proxy.Stats()
545	if stats.Rebinds != 1 || stats.StrayPackets != 0 {
546		t.Fatalf("unexpected stats after rebind: %s", stats)
547	}
548}
549
550func TestProxyCloseIsIdempotent(t *testing.T) {
551	serverAddr, _ := startSinkServer(t)
552	proxy, _, _ := newTestProxy(t, Config{ServerAddr: serverAddr})
553
554	if err := proxy.Close(); err != nil {
555		t.Fatalf("first Close: %v", err)
556	}
557	if err := proxy.Close(); err != nil {
558		t.Fatalf("second Close: %v", err)
559	}
560	if err := proxy.Rebind(); err != ErrClosed {
561		t.Fatalf("Rebind after Close = %v, want ErrClosed", err)
562	}
563}
564
565// TestProxyQUICLikeFlowUnderImpairment drives a QUIC-like packet flow (1.2 KiB
566// MTU-sized packets in bursts, with a blackout window in the middle) through a
567// fully impaired link and checks that every packet is accounted for exactly
568// once: received + duplicates == sent + drops.
569func TestProxyQUICLikeFlowUnderImpairment(t *testing.T) {
570	const (
571		phaseA     = 120
572		phaseB     = 30
573		phaseC     = 150
574		packetSize = 1200
575	)
576	serverAddr, serverPackets := startSinkServer(t)
577	proxy, clock, events := newTestProxy(t, Config{
578		ServerAddr:    serverAddr,
579		Seed:          7,
580		QueueCapacity: 1024,
581		ToServer: LinkConfig{
582			BaseLatency:      2 * time.Millisecond,
583			Jitter:           time.Millisecond,
584			LossPercent:      10,
585			DuplicatePercent: 5,
586			DuplicateDelay:   time.Millisecond,
587			ReorderPercent:   5,
588			ReorderDelay:     3 * time.Millisecond,
589			Blackouts:        []Blackout{{Start: 10 * time.Millisecond, End: 20 * time.Millisecond}},
590		},
591	})
592	client := newUDPClient(t)
593
594	observed := sendPackets(t, client, proxy.Addr(), events, phaseA, packetSize)
595	clock.Advance(10 * time.Millisecond)
596
597	// Every packet inside the blackout window must be dropped.
598	blacked := sendPackets(t, client, proxy.Addr(), events, phaseB, packetSize)
599	for _, event := range blacked {
600		if !event.Decision.Blackout {
601			t.Fatalf("blackout phase decision = %+v, want a blackout drop", event.Decision)
602		}
603	}
604	clock.Advance(10 * time.Millisecond)
605
606	observed = append(observed, blacked...)
607	observed = append(observed, sendPackets(t, client, proxy.Addr(), events, phaseC, packetSize)...)
608	clock.Advance(20 * time.Millisecond)
609
610	var expected, dropped, duplicates, reordered uint64
611	for _, event := range observed {
612		if event.Decision.Drop {
613			dropped++
614			continue
615		}
616		expected += 1 + uint64(event.Decision.Duplicates)
617		duplicates += uint64(event.Decision.Duplicates)
618		if event.Decision.Reordered {
619			reordered++
620		}
621	}
622	for i := uint64(0); i < expected; i++ {
623		awaitPacket(t, serverPackets)
624	}
625
626	stats := proxy.Stats()
627	if stats.ToServer.Received != phaseA+phaseB+phaseC {
628		t.Fatalf("received = %d, want %d", stats.ToServer.Received, phaseA+phaseB+phaseC)
629	}
630	if stats.ToServer.BlackoutDrops != phaseB {
631		t.Fatalf("blackout drops = %d, want %d", stats.ToServer.BlackoutDrops, phaseB)
632	}
633	if stats.ToServer.OverflowDrops != 0 || stats.ToServer.OversizeDrops != 0 {
634		t.Fatalf("unexpected overflow/oversize: %s", stats.ToServer)
635	}
636	if stats.ToServer.Duplicates != duplicates {
637		t.Fatalf("duplicates = %d, want %d", stats.ToServer.Duplicates, duplicates)
638	}
639	if stats.ToServer.Reordered != reordered || reordered == 0 {
640		t.Fatalf("reordered = %d (want %d and non-zero)", stats.ToServer.Reordered, reordered)
641	}
642	if duplicates == 0 {
643		t.Fatal("flow produced no duplicates, so duplication was not exercised")
644	}
645	if stats.ToServer.LossDrops == 0 {
646		t.Fatal("flow produced no loss drops, so loss was not exercised")
647	}
648	if drops := stats.ToServer.LossDrops + stats.ToServer.BlackoutDrops + stats.ToServer.OversizeDrops + stats.ToServer.OverflowDrops; drops != dropped {
649		t.Fatalf("drops = %d, want %d", drops, dropped)
650	}
651	if stats.ToServer.Sent != expected {
652		t.Fatalf("sent = %d, want %d", stats.ToServer.Sent, expected)
653	}
654	// Oversize datagrams are rejected before Received is incremented and
655	// therefore don't participate in the accepted-packet identity.
656	total := stats.ToServer.LossDrops + stats.ToServer.BlackoutDrops + stats.ToServer.OverflowDrops
657	if got := stats.ToServer.Sent + total; got != stats.ToServer.Received+stats.ToServer.Duplicates {
658		t.Fatalf("accounting invariant broken: received(%d) + duplicates(%d) != sent(%d) + drops(%d)",
659			stats.ToServer.Received, stats.ToServer.Duplicates, stats.ToServer.Sent, total)
660	}
661	if got := stats.String(); got == "" {
662		t.Fatal("Stats.String returned empty")
663	}
664}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close