detached 5b6c186

test(dgram): split transport coverage by behavior

commit
5b6c1865abe362704ed769341d9f7c4cbee987b1
author
brice · · 2026-07-14 20:42 UTC
parent
58fcde1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +2537 -2479
A internal/adapters/dgram/congestion_test.go +61 -0
@@ -0,0 +1,61 @@
1+package dgram
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestCongestionControllerBoundsAndIntegerGrowth(t *testing.T) {
9+ const mtu = 1200
10+ c := newCongestionController(mtu)
11+ if got, want := c.cwndBytes, initialCongestionPackets*mtu; got != want {
12+ t.Fatalf("initial cwnd=%d, want %d", got, want)
13+ }
14+ if got, want := c.burstBytes(), dataBurstPackets*mtu; got != want {
15+ t.Fatalf("initial burst=%d, want %d", got, want)
16+ }
17+ if got, want := c.bytesPerSecond(), initialCongestionPackets*mtu*int(time.Second/initialPacingRTT); got != want {
18+ t.Fatalf("initial pacing rate=%d, want %d", got, want)
19+ }
20+
21+ c.onLoss()
22+ if got, want := c.cwndBytes, initialCongestionPackets*mtu/2; got != want {
23+ t.Fatalf("cwnd after first loss=%d, want %d", got, want)
24+ }
25+ c.cwndBytes = 3 * mtu
26+ c.onLoss()
27+ if got, want := c.cwndBytes, minimumCongestionPackets*mtu; got != want {
28+ t.Fatalf("minimum cwnd=%d, want %d", got, want)
29+ }
30+
31+ c.cwndBytes = 10 * mtu
32+ c.onACK(1)
33+ if got, want := c.cwndBytes, 10*mtu+1; got != want {
34+ t.Fatalf("minimum additive increase cwnd=%d, want %d", got, want)
35+ }
36+ c.onACK(10 * mtu)
37+ if got, want := c.cwndBytes, 10*mtu+1+(mtu*10*mtu)/(10*mtu+1); got != want {
38+ t.Fatalf("integer additive increase cwnd=%d, want %d", got, want)
39+ }
40+ c.cwndBytes = maximumCongestionBytes
41+ c.onACK(maximumCongestionBytes)
42+ if got := c.cwndBytes; got != maximumCongestionBytes {
43+ t.Fatalf("maximum cwnd=%d, want %d", got, maximumCongestionBytes)
44+ }
45+
46+ c.onRTT(500 * time.Microsecond)
47+ if got, want := c.bytesPerSecond(), maximumCongestionBytes*1000; got != want {
48+ t.Fatalf("sub-millisecond pacing rate=%d, want %d", got, want)
49+ }
50+}
51+
52+func BenchmarkCongestionControllerACK(b *testing.B) {
53+ c := newCongestionController(defaultMTU)
54+ b.ReportAllocs()
55+ for b.Loop() {
56+ c.onACK(defaultMTU)
57+ if c.cwndBytes == c.maxBytes {
58+ c = newCongestionController(defaultMTU)
59+ }
60+ }
61+}
A internal/adapters/dgram/pacer_test.go +98 -0
@@ -0,0 +1,98 @@
1+package dgram
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+)
8+
9+func TestBytePacerUsesBurstThenFakeClockRate(t *testing.T) {
10+ const mtu = 1200
11+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
12+ c := newCongestionController(mtu)
13+ p := bytePacer{clk: clk}
14+ done := make(chan struct{})
15+ limits := func() (int, int) { return c.bytesPerSecond(), c.burstBytes() }
16+
17+ for range dataBurstPackets {
18+ if err := p.wait(done, mtu, limits); err != nil {
19+ t.Fatal(err)
20+ }
21+ }
22+ wait := make(chan error, 1)
23+ go func() { wait <- p.wait(done, mtu, limits) }()
24+ awaitSignal(t, clk.timerCreated, "byte pacer timer")
25+ clk.advance(24 * time.Millisecond)
26+ select {
27+ case err := <-wait:
28+ t.Fatalf("third MTU released early: %v", err)
29+ default:
30+ }
31+ clk.advance(time.Millisecond)
32+ if err := awaitResult(t, wait, "third paced MTU"); err != nil {
33+ t.Fatal(err)
34+ }
35+
36+ c.onLoss()
37+ next := make(chan error, 1)
38+ go func() { next <- p.wait(done, mtu, limits) }()
39+ awaitSignal(t, clk.timerCreated, "reduced-cwnd byte pacer timer")
40+ clk.advance(49 * time.Millisecond)
41+ select {
42+ case err := <-next:
43+ t.Fatalf("reduced cwnd did not lengthen wait: %v", err)
44+ default:
45+ }
46+ clk.advance(time.Millisecond)
47+ if err := awaitResult(t, next, "reduced-cwnd paced MTU"); err != nil {
48+ t.Fatal(err)
49+ }
50+}
51+
52+func TestBytePacerMaximumRateRefillsAfterMultiSecondFakeClockJump(t *testing.T) {
53+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
54+ p := bytePacer{clk: clk}
55+ done := make(chan struct{})
56+ burst := int(^uint(0) >> 1)
57+ limits := func() (int, int) { return burst, burst }
58+
59+ if err := p.wait(done, burst, limits); err != nil {
60+ t.Fatal(err)
61+ }
62+ clk.advance(3 * time.Second)
63+ if err := p.wait(done, burst, limits); err != nil {
64+ t.Fatal(err)
65+ }
66+
67+ wait := make(chan error, 1)
68+ go func() { wait <- p.wait(done, burst, limits) }()
69+ awaitSignal(t, clk.timerCreated, "maximum-rate byte pacer timer")
70+ clk.advance(time.Second - time.Nanosecond)
71+ select {
72+ case err := <-wait:
73+ t.Fatalf("maximum-rate burst released early: %v", err)
74+ default:
75+ }
76+ clk.advance(time.Nanosecond)
77+ if err := awaitResult(t, wait, "maximum-rate burst"); err != nil {
78+ t.Fatal(err)
79+ }
80+}
81+
82+func TestBytePacerCancellation(t *testing.T) {
83+ const mtu = 1200
84+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
85+ p := bytePacer{clk: clk}
86+ done := make(chan struct{})
87+ limits := func() (int, int) { return mtu, mtu }
88+ if err := p.wait(done, mtu, limits); err != nil {
89+ t.Fatal(err)
90+ }
91+ wait := make(chan error, 1)
92+ go func() { wait <- p.wait(done, mtu, limits) }()
93+ awaitSignal(t, clk.timerCreated, "cancelled byte pacer timer")
94+ close(done)
95+ if err := awaitResult(t, wait, "byte pacer cancellation"); !errors.Is(err, errPacerClosed) {
96+ t.Fatalf("cancellation error=%v, want %v", err, errPacerClosed)
97+ }
98+}
A internal/adapters/dgram/transport_health_test.go +731 -0
@@ -0,0 +1,731 @@
1+package dgram
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "net"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/ports"
13+ pdgram "github.com/bnema/vev/pkg/dgram"
14+)
15+
16+func TestPortHopPreservesPendingReliableMessages(t *testing.T) {
17+ aPC, bPC := newPair()
18+ var hopped atomic.Bool
19+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
20+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
21+ ResendAfter: 20 * time.Millisecond,
22+ ProbeAfter: time.Millisecond,
23+ DeadAfter: time.Hour,
24+ RebindPacketConn: func(old net.PacketConn) (net.PacketConn, error) {
25+ newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
26+ bPC.peers["a2"] = newPC
27+ hopped.Store(true)
28+ return newPC, nil
29+ },
30+ })
31+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
32+ defer func() { _ = a.Close() }()
33+ defer func() { _ = b.Close() }()
34+
35+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("survive")}); err != nil {
36+ t.Fatal(err)
37+ }
38+ a.mu.Lock()
39+ a.health.lastPacket = time.Now().Add(-time.Second)
40+ a.mu.Unlock()
41+ a.checkSilence()
42+ if !hopped.Load() {
43+ t.Fatal("expected packet conn rebind")
44+ }
45+ aPC.drop = nil
46+ got := recvWithin(t, b, time.Second)
47+ if got.Type != ports.MsgInput || string(got.Payload) != "survive" {
48+ t.Fatalf("got=%+v, want pending reliable message", got)
49+ }
50+ select {
51+ case <-a.done:
52+ t.Fatal("transport closed during port hop")
53+ default:
54+ }
55+}
56+
57+func TestPortHopRetriesAfterRebindFailure(t *testing.T) {
58+ aPC, bPC := newPair()
59+ var attempts atomic.Int32
60+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
61+ ResendAfter: time.Hour,
62+ Heartbeat: time.Hour,
63+ ProbeAfter: time.Millisecond,
64+ DeadAfter: time.Hour,
65+ RebindPacketConn: func(old net.PacketConn) (net.PacketConn, error) {
66+ if attempts.Add(1) == 1 {
67+ return nil, errors.New("temporary rebind failure")
68+ }
69+ newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
70+ bPC.peers["a2"] = newPC
71+ return newPC, nil
72+ },
73+ })
74+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
75+ defer func() { _ = a.Close() }()
76+ defer func() { _ = b.Close() }()
77+
78+ a.mu.Lock()
79+ a.health.lastPacket = time.Now().Add(-time.Second)
80+ a.mu.Unlock()
81+ a.checkSilence()
82+ a.mu.Lock()
83+ firstPC := a.pc
84+ a.mu.Unlock()
85+ if attempts.Load() != 1 || firstPC != aPC {
86+ t.Fatalf("first failed hop attempts=%d pc=%v, want one attempt and original pc", attempts.Load(), firstPC.LocalAddr())
87+ }
88+
89+ a.checkSilence()
90+ a.mu.Lock()
91+ secondPC := a.pc
92+ a.mu.Unlock()
93+ if attempts.Load() != 2 || secondPC == aPC {
94+ t.Fatalf("second hop attempts=%d pc=%v, want retry with new pc", attempts.Load(), secondPC.LocalAddr())
95+ }
96+}
97+
98+func TestServerWithoutRebindDoesNotHopPorts(t *testing.T) {
99+ aPC, bPC := newPair()
100+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ProbeAfter: time.Millisecond, DeadAfter: time.Hour})
101+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
102+ defer func() { _ = a.Close() }()
103+ defer func() { _ = b.Close() }()
104+ a.mu.Lock()
105+ a.health.lastPacket = time.Now().Add(-time.Second)
106+ a.mu.Unlock()
107+ a.checkSilence()
108+ if a.pc != aPC {
109+ t.Fatal("server/proxy transport hopped without rebind hook")
110+ }
111+}
112+
113+func TestProbeTimeoutAndSuccess(t *testing.T) {
114+ aPC, bPC := newPair()
115+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
116+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
117+ defer func() { _ = a.Close() }()
118+ defer func() { _ = b.Close() }()
119+
120+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
121+ if err := a.Probe(ctx); err != nil {
122+ cancel()
123+ t.Fatalf("probe success: %v", err)
124+ }
125+ cancel()
126+
127+ bPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "a" }
128+ ctx, cancel = context.WithTimeout(context.Background(), 50*time.Millisecond)
129+ defer cancel()
130+ if err := a.Probe(ctx); !errors.Is(err, context.DeadlineExceeded) {
131+ t.Fatalf("probe timeout err=%v, want context deadline", err)
132+ }
133+}
134+
135+func TestRecvUnblocksWhenPeerDeadAfter(t *testing.T) {
136+ aPC, bPC := newPair()
137+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: 25 * time.Millisecond, DeadAfter: 75 * time.Millisecond})
138+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
139+ defer func() { _ = a.Close() }()
140+ defer func() { _ = b.Close() }()
141+ a.mu.Lock()
142+ a.health.lastPacket = time.Now().Add(-time.Second)
143+ a.mu.Unlock()
144+
145+ _, err := recvErrWithin(t, a, time.Second)
146+ if !errors.Is(err, ErrLinkDead) {
147+ t.Fatalf("Recv err=%v, want ErrLinkDead", err)
148+ }
149+}
150+
151+func TestPendingAttemptsResetWhenLinkRecovers(t *testing.T) {
152+ tr, _ := newResendTestTransport(t, 10)
153+ tr.linkState = ports.LinkStateDegraded
154+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stale")}, attempts: 4}
155+ tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stale")}, attempts: 7}
156+
157+ tr.setLinkState(ports.LinkStateConnected, nil)
158+
159+ for seq, p := range tr.pending {
160+ if p.attempts != 0 {
161+ t.Fatalf("pending[%d] attempts=%d, want reset on connected recovery", seq, p.attempts)
162+ }
163+ }
164+}
165+
166+func TestProbeReplyDoesNotBlockOnWriteMu(t *testing.T) {
167+ aPC, bPC := newPair()
168+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: time.Hour, Heartbeat: time.Hour})
169+ if err != nil {
170+ t.Fatal(err)
171+ }
172+ defer func() { _ = tr.Close() }()
173+ defer func() { _ = bPC.Close() }()
174+
175+ tr.writeMu.Lock()
176+ done := make(chan struct{})
177+ go func() {
178+ for id := uint64(1); id <= probeReplyBufferSize*4; id++ {
179+ tr.handleRecord(probeRecord(recProbe, id))
180+ }
181+ close(done)
182+ }()
183+
184+ select {
185+ case <-done:
186+ case <-time.After(time.Second):
187+ tr.writeMu.Unlock()
188+ <-done
189+ t.Fatal("probe reply burst blocked behind writeMu")
190+ }
191+ tr.writeMu.Unlock()
192+}
193+
194+func TestPendingReliableQueueReturnsWhenLinkDegrades(t *testing.T) {
195+ aPC, bPC := newPair()
196+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
197+ clk := newManualClock(time.Now())
198+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1, MaxPendingWait: time.Hour, Clock: clk})
199+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
200+ defer func() { _ = a.Close() }()
201+ defer func() { _ = b.Close() }()
202+
203+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
204+ t.Fatal(err)
205+ }
206+ done := make(chan error, 2)
207+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
208+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("third")}) }()
209+ waitForManualTimers(t, clk, 2)
210+ a.setLinkState(ports.LinkStateDegraded, nil)
211+ for i := range 2 {
212+ select {
213+ case err := <-done:
214+ if !errors.Is(err, ErrPendingFull) {
215+ t.Fatalf("send %d err=%v, want ErrPendingFull", i, err)
216+ }
217+ case <-time.After(time.Second):
218+ t.Fatalf("send %d did not unblock after link degraded", i)
219+ }
220+ }
221+}
222+
223+func TestLinkStateTransitionsFromConfigurableSilence(t *testing.T) {
224+ aPC, bPC := newPair()
225+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{DegradedAfter: time.Second, ProbeAfter: 2 * time.Second, OfflineAfter: 3 * time.Second, DeadAfter: 4 * time.Second})
226+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
227+ defer func() { _ = a.Close() }()
228+ defer func() { _ = b.Close() }()
229+
230+ tests := []struct {
231+ name string
232+ age time.Duration
233+ want ports.LinkState
234+ }{
235+ {"degraded", time.Second, ports.LinkStateDegraded},
236+ {"probing", 2 * time.Second, ports.LinkStateProbing},
237+ {"offline", 3 * time.Second, ports.LinkStateOffline},
238+ }
239+ for _, tt := range tests {
240+ t.Run(tt.name, func(t *testing.T) {
241+ a.mu.Lock()
242+ staleAt := time.Now().Add(-tt.age)
243+ a.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
244+ a.mu.Unlock()
245+ a.checkSilence()
246+ if got := a.LinkState(); got != tt.want {
247+ t.Fatalf("LinkState()=%v, want %v", got, tt.want)
248+ }
249+ select {
250+ case ev := <-a.LinkEvents():
251+ if ev.State != tt.want {
252+ t.Fatalf("event state=%v, want %v", ev.State, tt.want)
253+ }
254+ default:
255+ t.Fatalf("missing link event for %v", tt.want)
256+ }
257+ })
258+ }
259+}
260+
261+func TestLinkHealthSeparatesPacketContactFromUsefulProgress(t *testing.T) {
262+ now := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
263+ threshold := func(seconds int) time.Duration { return time.Duration(seconds) * time.Second }
264+ tests := []struct {
265+ name string
266+ packetAge time.Duration
267+ recordAge time.Duration
268+ ackAge time.Duration
269+ hasPending bool
270+ wantState ports.LinkState
271+ wantHop bool
272+ wantDead bool
273+ }{
274+ {name: "fresh contact without useful progress degrades", packetAge: 0, recordAge: threshold(10), ackAge: threshold(10), wantState: ports.LinkStateDegraded},
275+ {name: "pending round trip stall probes and hops", packetAge: 0, recordAge: threshold(20), ackAge: threshold(20), hasPending: true, wantState: ports.LinkStateProbing, wantHop: true},
276+ {name: "fragment-only contact never becomes offline", packetAge: 0, recordAge: threshold(30), ackAge: threshold(30), wantState: ports.LinkStateDegraded},
277+ {name: "packet silence probes and hops", packetAge: threshold(20), recordAge: threshold(20), ackAge: threshold(20), wantState: ports.LinkStateProbing, wantHop: true},
278+ {name: "packet silence becomes offline", packetAge: threshold(30), recordAge: threshold(30), ackAge: threshold(30), wantState: ports.LinkStateOffline},
279+ {name: "packet silence becomes dead", packetAge: threshold(60), recordAge: threshold(60), ackAge: threshold(60), wantState: ports.LinkStateDead, wantDead: true},
280+ {name: "complete record cannot mask pending stall", packetAge: 0, recordAge: 0, ackAge: threshold(30), hasPending: true, wantState: ports.LinkStateProbing, wantHop: true},
281+ {name: "complete record restores connected without pending data", packetAge: 0, recordAge: 0, ackAge: threshold(30), wantState: ports.LinkStateConnected},
282+ {name: "ack progress restores connected", packetAge: 0, recordAge: threshold(30), ackAge: 0, hasPending: true, wantState: ports.LinkStateConnected},
283+ }
284+
285+ for _, tt := range tests {
286+ t.Run(tt.name, func(t *testing.T) {
287+ health := healthTracker{
288+ lastPacket: now.Add(-tt.packetAge),
289+ lastRecord: now.Add(-tt.recordAge),
290+ lastProgress: now.Add(-tt.ackAge),
291+ }
292+ state, hop, dead := health.decide(now, tt.hasPending, threshold(10), threshold(20), threshold(30), threshold(60))
293+ if state != tt.wantState || hop != tt.wantHop || dead != tt.wantDead {
294+ t.Fatalf("decide()=(%v,%v,%v), want (%v,%v,%v)", state, hop, dead, tt.wantState, tt.wantHop, tt.wantDead)
295+ }
296+ })
297+ }
298+}
299+
300+func TestLinkHealthPendingStallWithFreshContactProbesOnceWithoutDying(t *testing.T) {
301+ aPC, bPC := newPair()
302+ var hops atomic.Int32
303+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
304+ ResendAfter: time.Hour, Heartbeat: time.Hour, ProbeAfter: 20 * time.Second, DeadAfter: 60 * time.Second,
305+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
306+ hops.Add(1)
307+ return &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}, nil
308+ },
309+ })
310+ defer func() { _ = a.Close() }()
311+ defer func() { _ = bPC.Close() }()
312+
313+ now := time.Now()
314+ a.mu.Lock()
315+ a.health = healthTracker{lastPacket: now, lastRecord: now.Add(-time.Minute), lastProgress: now.Add(-time.Minute)}
316+ a.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, enqueued: now.Add(-time.Minute), attempts: 3}
317+ a.mu.Unlock()
318+
319+ a.checkSilence()
320+ a.checkSilence()
321+ if got := a.LinkState(); got != ports.LinkStateProbing {
322+ t.Fatalf("state=%v, want probing", got)
323+ }
324+ if got := hops.Load(); got != 1 {
325+ t.Fatalf("socket hops=%d, want one", got)
326+ }
327+ select {
328+ case <-a.done:
329+ t.Fatal("fresh authenticated contact must prevent pending-stall death")
330+ default:
331+ }
332+}
333+
334+func TestLinkHealthHeartbeatControlCannotMaskPendingACKStall(t *testing.T) {
335+ aPC, bPC := newPair()
336+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
337+ var hops atomic.Int32
338+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
339+ Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
340+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
341+ attempt := hops.Add(1)
342+ return &fakePC{addr: testAddr(fmt.Sprintf("a%d", attempt+1)), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}, nil
343+ },
344+ })
345+ if err != nil {
346+ t.Fatal(err)
347+ }
348+ defer func() { _ = tr.Close() }()
349+ defer func() { _ = bPC.Close() }()
350+ if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("unacked")}); err != nil {
351+ t.Fatal(err)
352+ }
353+
354+ codec, err := pdgram.NewCodec(key())
355+ if err != nil {
356+ t.Fatal(err)
357+ }
358+ authenticated := make(chan struct{}, 32)
359+ tr.mu.Lock()
360+ tr.afterAuthenticatedPacket = func() { authenticated <- struct{}{} }
361+ tr.mu.Unlock()
362+ var counter uint64
363+ sendHeartbeatPong := func() {
364+ t.Helper()
365+ counter++
366+ frag := pdgram.Fragment{Seq: counter, Index: 0, Count: 1, Data: probeRecord(recPong, counter)}
367+ raw, marshalErr := pdgram.MarshalFragment(frag)
368+ if marshalErr != nil {
369+ t.Fatal(marshalErr)
370+ }
371+ tr.mu.Lock()
372+ actual := tr.pc
373+ pc, ok := actual.(*fakePC)
374+ tr.mu.Unlock()
375+ if !ok {
376+ t.Fatalf("transport packet conn = %T, want *fakePC", actual)
377+ }
378+ pc.in <- packet{b: codec.Seal(2, counter, raw, nil), addr: testAddr("b")}
379+ select {
380+ case <-authenticated:
381+ case <-time.After(time.Second):
382+ t.Fatal("heartbeat pong was not authenticated")
383+ }
384+ }
385+
386+ for elapsed := 3 * time.Second; elapsed <= 66*time.Second; elapsed += 3 * time.Second {
387+ clk.advance(3 * time.Second)
388+ sendHeartbeatPong()
389+ eventually(t, time.Second, func() bool {
390+ tr.mu.Lock()
391+ defer tr.mu.Unlock()
392+ return tr.health.lastRecord.Equal(clk.Now())
393+ })
394+ if elapsed >= 20*time.Second {
395+ eventually(t, time.Second, func() bool {
396+ tr.mu.Lock()
397+ defer tr.mu.Unlock()
398+ return tr.pc != aPC
399+ })
400+ }
401+ }
402+ if got := tr.LinkState(); got != ports.LinkStateProbing {
403+ t.Fatalf("state=%v, want probing despite fresh heartbeat control traffic", got)
404+ }
405+ if got := hops.Load(); got != 1 {
406+ t.Fatalf("socket hops=%d, want one pending round-trip recovery hop", got)
407+ }
408+ select {
409+ case <-tr.done:
410+ t.Fatal("fresh authenticated control contact must prevent offline/dead closure")
411+ default:
412+ }
413+}
414+
415+func TestLinkHealthOnlyAckProgressThatRemovesPendingRestoresConnected(t *testing.T) {
416+ tr, _ := newResendTestTransport(t, 10)
417+ now := tr.clock.Now()
418+ tr.linkState = ports.LinkStateDegraded
419+ tr.health = healthTracker{lastPacket: now, lastRecord: now.Add(-time.Minute), lastProgress: now.Add(-time.Minute)}
420+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: now, last: now}
421+
422+ tr.handleRecord(ackRecord(1))
423+ if got := tr.LinkState(); got != ports.LinkStateConnected {
424+ t.Fatalf("state after advancing ACK=%v, want connected", got)
425+ }
426+ tr.mu.Lock()
427+ if tr.health.lastProgress.Before(now) {
428+ tr.mu.Unlock()
429+ t.Fatalf("last progress=%v, want at or after %v", tr.health.lastProgress, now)
430+ }
431+ tr.mu.Unlock()
432+
433+ tr.setLinkState(ports.LinkStateDegraded, nil)
434+ tr.handleRecord(ackRecord(1))
435+ if got := tr.LinkState(); got != ports.LinkStateDegraded {
436+ t.Fatalf("duplicate ACK state=%v, want degraded", got)
437+ }
438+}
439+
440+func TestLinkHealthFreshProgressInvalidatesStaleDecision(t *testing.T) {
441+ tests := []struct {
442+ name string
443+ staleAge time.Duration
444+ freshen func(*Transport)
445+ wantState ports.LinkState
446+ }{
447+ {
448+ name: "fresh packet contact prevents stale dead close",
449+ staleAge: time.Minute,
450+ freshen: func(tr *Transport) {
451+ tr.mu.Lock()
452+ now := tr.clock.Now()
453+ tr.health.authenticatedPacket(now)
454+ tr.lastAuthenticatedPacket = now
455+ tr.mu.Unlock()
456+ },
457+ wantState: ports.LinkStateDegraded,
458+ },
459+ {
460+ name: "complete record prevents stale probing state and hop",
461+ staleAge: 20 * time.Second,
462+ freshen: func(tr *Transport) {
463+ tr.mu.Lock()
464+ now := tr.clock.Now()
465+ tr.health.authenticatedPacket(now)
466+ tr.health.completeRecord(now)
467+ tr.lastAuthenticatedPacket = now
468+ tr.lastCompleteRecord = now
469+ tr.mu.Unlock()
470+ tr.setLinkState(ports.LinkStateConnected, nil)
471+ },
472+ wantState: ports.LinkStateConnected,
473+ },
474+ }
475+
476+ for _, tt := range tests {
477+ t.Run(tt.name, func(t *testing.T) {
478+ aPC, bPC := newPair()
479+ var hops atomic.Int32
480+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
481+ ResendAfter: time.Hour,
482+ Heartbeat: time.Hour,
483+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
484+ hops.Add(1)
485+ return &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}, nil
486+ },
487+ })
488+ if err != nil {
489+ t.Fatal(err)
490+ }
491+ defer func() { _ = tr.Close() }()
492+ defer func() { _ = bPC.Close() }()
493+
494+ now := tr.clock.Now()
495+ tr.mu.Lock()
496+ staleAt := now.Add(-tt.staleAge)
497+ tr.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
498+ decisionReady := make(chan struct{})
499+ resume := make(chan struct{})
500+ tr.afterHealthDecision = func() {
501+ close(decisionReady)
502+ <-resume
503+ }
504+ tr.mu.Unlock()
505+
506+ done := make(chan struct{})
507+ go func() {
508+ tr.checkSilence()
509+ close(done)
510+ }()
511+ select {
512+ case <-decisionReady:
513+ case <-time.After(time.Second):
514+ t.Fatal("health decision hook was not reached")
515+ }
516+ tt.freshen(tr)
517+ close(resume)
518+ select {
519+ case <-done:
520+ case <-time.After(time.Second):
521+ t.Fatal("health check did not finish")
522+ }
523+
524+ if got := tr.LinkState(); got != tt.wantState {
525+ t.Fatalf("state=%v, want %v after fresh health evidence", got, tt.wantState)
526+ }
527+ if got := hops.Load(); got != 0 {
528+ t.Fatalf("socket hops=%d, want none after recovery", got)
529+ }
530+ for len(tr.linkEvents) > 0 {
531+ ev := <-tr.linkEvents
532+ if ev.State == ports.LinkStateDead || ev.State == ports.LinkStateProbing {
533+ t.Fatalf("stale link event emitted after recovery: %v", ev.State)
534+ }
535+ }
536+ select {
537+ case <-tr.done:
538+ t.Fatal("transport closed from stale health decision")
539+ default:
540+ }
541+ })
542+ }
543+}
544+
545+func TestLinkHealthStaleRebindRestoresFutureHopAllowance(t *testing.T) {
546+ aPC, bPC := newPair()
547+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
548+ rebindStarted := make(chan struct{})
549+ resumeRebind := make(chan struct{})
550+ var rebinds atomic.Int32
551+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
552+ Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
553+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
554+ attempt := rebinds.Add(1)
555+ pc := &fakePC{addr: testAddr(fmt.Sprintf("a%d", attempt+1)), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
556+ if attempt == 1 {
557+ close(rebindStarted)
558+ <-resumeRebind
559+ }
560+ return pc, nil
561+ },
562+ })
563+ if err != nil {
564+ t.Fatal(err)
565+ }
566+ defer func() { _ = tr.Close() }()
567+ defer func() { _ = bPC.Close() }()
568+
569+ tr.mu.Lock()
570+ staleAt := clk.Now().Add(-20 * time.Second)
571+ tr.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
572+ tr.mu.Unlock()
573+ firstDone := make(chan struct{})
574+ go func() {
575+ tr.checkSilence()
576+ close(firstDone)
577+ }()
578+ select {
579+ case <-rebindStarted:
580+ case <-time.After(time.Second):
581+ t.Fatal("first rebind did not start")
582+ }
583+
584+ tr.mu.Lock()
585+ now := clk.Now()
586+ tr.health.authenticatedPacket(now)
587+ tr.lastAuthenticatedPacket = now
588+ tr.mu.Unlock()
589+ close(resumeRebind)
590+ select {
591+ case <-firstDone:
592+ case <-time.After(time.Second):
593+ t.Fatal("stale rebind did not finish")
594+ }
595+ tr.mu.Lock()
596+ latched := tr.hoppedOffline
597+ pcAfterStale := tr.pc
598+ tr.mu.Unlock()
599+ if latched {
600+ t.Fatal("stale rebind consumed the future hop allowance")
601+ }
602+ if pcAfterStale != aPC {
603+ t.Fatal("stale rebind replaced the active packet conn")
604+ }
605+
606+ clk.advance(20 * time.Second)
607+ tr.checkSilence()
608+ tr.mu.Lock()
609+ pcAfterRetry := tr.pc
610+ tr.mu.Unlock()
611+ if got := rebinds.Load(); got != 2 {
612+ t.Fatalf("rebind attempts=%d, want a later eligible retry", got)
613+ }
614+ if pcAfterRetry == aPC {
615+ t.Fatal("later stale interval did not hop the packet conn")
616+ }
617+}
618+
619+func TestLinkHealthStateEventsCannotPublishAfterNewerRecovery(t *testing.T) {
620+ aPC, bPC := newPair()
621+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: time.Hour, Heartbeat: time.Hour})
622+ if err != nil {
623+ t.Fatal(err)
624+ }
625+ defer func() { _ = tr.Close() }()
626+ defer func() { _ = bPC.Close() }()
627+
628+ probingCommitted := make(chan struct{})
629+ resumeProbing := make(chan struct{})
630+ tr.mu.Lock()
631+ tr.linkState = ports.LinkStateDegraded
632+ tr.afterLinkStateCommit = func(state ports.LinkState) {
633+ if state != ports.LinkStateProbing {
634+ return
635+ }
636+ close(probingCommitted)
637+ <-resumeProbing
638+ }
639+ tr.mu.Unlock()
640+
641+ probingDone := make(chan struct{})
642+ go func() {
643+ tr.setLinkState(ports.LinkStateProbing, nil)
644+ close(probingDone)
645+ }()
646+ select {
647+ case <-probingCommitted:
648+ case <-time.After(time.Second):
649+ t.Fatal("probing commit hook was not reached")
650+ }
651+ tr.setLinkState(ports.LinkStateConnected, nil)
652+ close(resumeProbing)
653+ select {
654+ case <-probingDone:
655+ case <-time.After(time.Second):
656+ t.Fatal("probing publisher did not finish")
657+ }
658+
659+ if got := tr.LinkState(); got != ports.LinkStateConnected {
660+ t.Fatalf("state=%v, want connected", got)
661+ }
662+ select {
663+ case ev := <-tr.LinkEvents():
664+ if ev.State != ports.LinkStateConnected {
665+ t.Fatalf("first event=%v, want connected", ev.State)
666+ }
667+ default:
668+ t.Fatal("missing connected recovery event")
669+ }
670+ select {
671+ case ev := <-tr.LinkEvents():
672+ t.Fatalf("stale event published after recovery: %v", ev.State)
673+ default:
674+ }
675+}
676+
677+func TestHopFiresAtProbingNotDegraded(t *testing.T) {
678+ aPC, bPC := newPair()
679+ var hopped atomic.Bool
680+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
681+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
682+ DegradedAfter: time.Second, ProbeAfter: 2 * time.Second, OfflineAfter: 3 * time.Second, DeadAfter: time.Hour,
683+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
684+ newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
685+ bPC.peers["a2"] = newPC
686+ hopped.Store(true)
687+ return newPC, nil
688+ },
689+ })
690+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
691+ defer func() { _ = a.Close() }()
692+ defer func() { _ = b.Close() }()
693+
694+ a.mu.Lock()
695+ staleAt := time.Now().Add(-time.Second)
696+ a.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
697+ a.mu.Unlock()
698+ a.checkSilence()
699+ if hopped.Load() {
700+ t.Fatal("socket hopped at degraded silence")
701+ }
702+
703+ a.mu.Lock()
704+ a.health.lastPacket = time.Now().Add(-2 * time.Second)
705+ a.mu.Unlock()
706+ a.checkSilence()
707+ if !hopped.Load() {
708+ t.Fatal("socket did not hop at probing silence")
709+ }
710+}
711+
712+func TestShortSilenceDoesNotKillTransport(t *testing.T) {
713+ aPC, bPC := newPair()
714+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{DegradedAfter: 50 * time.Millisecond, DeadAfter: time.Hour})
715+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
716+ defer func() { _ = a.Close() }()
717+ defer func() { _ = b.Close() }()
718+ a.mu.Lock()
719+ staleAt := time.Now().Add(-time.Second)
720+ a.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
721+ a.mu.Unlock()
722+ a.checkSilence()
723+ if got := a.LinkState(); got != ports.LinkStateDegraded {
724+ t.Fatalf("LinkState()=%v, want degraded", got)
725+ }
726+ select {
727+ case <-a.done:
728+ t.Fatal("transport closed before DeadAfter")
729+ default:
730+ }
731+}
A internal/adapters/dgram/transport_receive_test.go +324 -0
@@ -0,0 +1,324 @@
1+package dgram
2+
3+import (
4+ "bytes"
5+ "net"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ pdgram "github.com/bnema/vev/pkg/dgram"
12+)
13+
14+func TestReliableDuplicateAckedButNotDeliveredTwice(t *testing.T) {
15+ aPC, bPC := newPair()
16+ var droppedAck atomic.Bool
17+ bPC.drop = func(_ []byte, addr net.Addr) bool {
18+ return addr.String() == "a" && droppedAck.CompareAndSwap(false, true)
19+ }
20+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ defer func() {
29+ if err := a.Close(); err != nil {
30+ t.Errorf("close a: %v", err)
31+ }
32+ }()
33+ defer func() {
34+ if err := b.Close(); err != nil {
35+ t.Errorf("close b: %v", err)
36+ }
37+ }()
38+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil {
39+ t.Fatal(err)
40+ }
41+ got := recvWithin(t, b, time.Second)
42+ if got.Type != ports.MsgInput || string(got.Payload) != "typed" {
43+ t.Fatalf("got=%+v", got)
44+ }
45+ eventually(t, time.Second, droppedAck.Load)
46+ if got, ok := recvMaybe(b, 3*defaultResend); ok {
47+ t.Fatalf("duplicate reliable frame delivered: %+v", got)
48+ }
49+}
50+
51+func TestAuthenticatedPeerChangeUpdatesPeerAndEmitsConnected(t *testing.T) {
52+ aPC, bPC := newPair()
53+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ defer func() {
58+ if err := a.Close(); err != nil {
59+ t.Errorf("close a: %v", err)
60+ }
61+ }()
62+ evilPC := &fakePC{addr: testAddr("evil"), in: make(chan packet, 100), peers: map[string]*fakePC{}}
63+ aPC.peers["evil"] = evilPC
64+ _ = bPC
65+ old := a.Peer().String()
66+ a.setLinkState(ports.LinkStateOffline, nil)
67+ <-a.LinkEvents()
68+ aPC.in <- packet{[]byte("not authenticated"), testAddr("evil")}
69+ if a.Peer().String() != old {
70+ t.Fatalf("unauthenticated packet changed peer to %v", a.Peer())
71+ }
72+ c, _ := pdgram.NewCodec(key())
73+ rec := encodeData(7, true, ports.Frame{Type: ports.MsgPing})
74+ frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU)
75+ raw, _ := pdgram.MarshalFragment(frags[0])
76+ aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")}
77+ waitPeer(t, a, "evil")
78+ select {
79+ case ev := <-a.LinkEvents():
80+ if ev.State != ports.LinkStateConnected {
81+ t.Fatalf("event state=%v, want connected", ev.State)
82+ }
83+ case <-time.After(time.Second):
84+ t.Fatal("missing connected event after authenticated rehome")
85+ }
86+ select {
87+ case pkt := <-evilPC.in:
88+ if pkt.addr.String() != "a" {
89+ t.Fatalf("ack source=%v, want a", pkt.addr)
90+ }
91+ case <-time.After(time.Second):
92+ t.Fatal("missing ACK to authenticated new peer")
93+ }
94+}
95+
96+func TestAuthenticatedFragmentSeparatesContactProgressAndPeerEligibility(t *testing.T) {
97+ aPC, bPC := newPair()
98+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
99+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
100+ Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
101+ })
102+ if err != nil {
103+ t.Fatal(err)
104+ }
105+ defer func() { _ = a.Close() }()
106+ defer func() { _ = bPC.Close() }()
107+
108+ a.setLinkState(ports.LinkStateDegraded, nil)
109+ <-a.LinkEvents()
110+ codec, err := pdgram.NewCodec(key())
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ authenticated := make(chan struct{}, 8)
115+ malformed := make(chan struct{}, 8)
116+ a.mu.Lock()
117+ a.afterAuthenticatedPacket = func() { authenticated <- struct{}{} }
118+ a.afterMalformedFragment = func() { malformed <- struct{}{} }
119+ initialPacket := a.health.lastPacket
120+ initialRecord := a.health.lastRecord
121+ a.mu.Unlock()
122+
123+ sealFragment := func(counter uint64, frag pdgram.Fragment) []byte {
124+ t.Helper()
125+ raw, marshalErr := pdgram.MarshalFragment(frag)
126+ if marshalErr != nil {
127+ t.Fatal(marshalErr)
128+ }
129+ return codec.Seal(2, counter, raw, nil)
130+ }
131+ waitSignal := func(ch <-chan struct{}, what string) {
132+ t.Helper()
133+ select {
134+ case <-ch:
135+ case <-time.After(time.Second):
136+ t.Fatalf("timeout waiting for %s", what)
137+ }
138+ }
139+ assertHealth := func(wantPacket, wantRecord time.Time, wantPeer string, wantState ports.LinkState) {
140+ t.Helper()
141+ a.mu.Lock()
142+ gotPacket := a.health.lastPacket
143+ gotRecord := a.health.lastRecord
144+ a.mu.Unlock()
145+ if !gotPacket.Equal(wantPacket) || !gotRecord.Equal(wantRecord) {
146+ t.Fatalf("health packet/record=(%v,%v), want (%v,%v)", gotPacket, gotRecord, wantPacket, wantRecord)
147+ }
148+ if got := a.Peer().String(); got != wantPeer {
149+ t.Fatalf("peer=%s, want %s", got, wantPeer)
150+ }
151+ if got := a.LinkState(); got != wantState {
152+ t.Fatalf("state=%v, want %v", got, wantState)
153+ }
154+ }
155+
156+ // Neither failed authentication nor an authenticated but invalid fragment
157+ // crosses the contact or roaming trust boundary.
158+ badAEAD := codec.Seal(2, 1, []byte("not a fragment"), nil)
159+ badAEAD[len(badAEAD)-1] ^= 0xff
160+ aPC.in <- packet{b: badAEAD, addr: testAddr("roam1")}
161+ aPC.in <- packet{b: codec.Seal(2, 2, []byte("not a fragment"), nil), addr: testAddr("roam1")}
162+ waitSignal(malformed, "malformed fragment")
163+ assertHealth(initialPacket, initialRecord, "b", ports.LinkStateDegraded)
164+
165+ clk.advance(time.Second)
166+ partial := pdgram.Fragment{Seq: 100, Index: 0, Count: 2, Data: []byte("half")}
167+ acceptedPacket := clk.Now()
168+ acceptedRaw := sealFragment(10, partial)
169+ aPC.in <- packet{b: acceptedRaw, addr: testAddr("roam1")}
170+ waitSignal(authenticated, "accepted incomplete fragment")
171+ assertHealth(acceptedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
172+
173+ // A replay and an Add-time fragment inconsistency cannot refresh contact or
174+ // migrate the peer. Reassembly diagnostics still update on the Add error.
175+ aPC.in <- packet{b: acceptedRaw, addr: testAddr("roam2")}
176+ aPC.in <- packet{b: codec.Seal(2, 11, []byte("still not a fragment"), nil), addr: testAddr("roam2")}
177+ waitSignal(malformed, "replay barrier")
178+ assertHealth(acceptedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
179+
180+ aPC.in <- packet{b: sealFragment(12, pdgram.Fragment{Seq: 100, Index: 1, Count: 3, Data: []byte("mismatch")}), addr: testAddr("roam2")}
181+ waitSignal(malformed, "reassembly rejection")
182+ assertHealth(acceptedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
183+
184+ // Fresh reordered packets refresh contact, but only the greatest accepted
185+ // AEAD counter is eligible to move the roaming peer. Contact alone must not
186+ // re-arm socket hopping while stream progress remains stalled.
187+ a.mu.Lock()
188+ a.hoppedOffline = true
189+ a.mu.Unlock()
190+ clk.advance(time.Second)
191+ reorderedPacket := clk.Now()
192+ aPC.in <- packet{b: sealFragment(9, pdgram.Fragment{Seq: 101, Index: 0, Count: 2, Data: []byte("older")}), addr: testAddr("roam2")}
193+ waitSignal(authenticated, "fresh reordered fragment")
194+ assertHealth(reorderedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
195+ a.mu.Lock()
196+ if !a.hoppedOffline {
197+ a.mu.Unlock()
198+ t.Fatal("fragment-only contact re-armed socket hopping")
199+ }
200+ a.mu.Unlock()
201+
202+ clk.advance(time.Second)
203+ newestPacket := clk.Now()
204+ aPC.in <- packet{b: sealFragment(13, pdgram.Fragment{Seq: 102, Index: 0, Count: 2, Data: []byte("newer")}), addr: testAddr("roam2")}
205+ waitSignal(authenticated, "new greatest fragment")
206+ assertHealth(newestPacket, initialRecord, "roam2", ports.LinkStateDegraded)
207+
208+ clk.advance(time.Second)
209+ completeAt := clk.Now()
210+ aPC.in <- packet{b: sealFragment(14, pdgram.Fragment{Seq: 103, Index: 0, Count: 1, Data: probeRecord(recPong, 999)}), addr: testAddr("roam2")}
211+ waitSignal(authenticated, "complete record")
212+ eventually(t, time.Second, func() bool { return a.LinkState() == ports.LinkStateConnected })
213+ assertHealth(completeAt, completeAt, "roam2", ports.LinkStateConnected)
214+}
215+
216+func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) {
217+ tr := &Transport{maxRecvBuffer: 1, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
218+ _, ack, queued := tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("future")})
219+ if !ack || !queued {
220+ t.Fatalf("future enqueue ack=%v queued=%v, want true/true", ack, queued)
221+ }
222+ _, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})
223+ if !ack || !queued {
224+ t.Fatalf("next enqueue ack=%v queued=%v, want true/true", ack, queued)
225+ }
226+ if _, ok := tr.recvBuf[2]; !ok {
227+ t.Fatal("acked future frame was dropped when next frame arrived")
228+ }
229+}
230+
231+func TestDataRecordWireEncoding(t *testing.T) {
232+ got := encodeData(5, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("x")})
233+ want := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(ports.MsgOutput), 0, 'x'}
234+ if !bytes.Equal(got, want) {
235+ t.Fatalf("encoded data=%v, want %v", got, want)
236+ }
237+ seq, reliable, frame, ok := decodeData(got)
238+ if !ok || seq != 5 || !reliable || frame.Type != ports.MsgOutput || string(frame.Payload) != "x" {
239+ t.Fatalf("decoded seq=%d reliable=%v frame=%+v ok=%v", seq, reliable, frame, ok)
240+ }
241+}
242+
243+func TestDataRecordWireDecodeRejectsUnknownFlags(t *testing.T) {
244+ withTrailingGarbage := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(ports.MsgOutput), 1, 'x'}
245+ if _, _, _, ok := decodeData(withTrailingGarbage); ok {
246+ t.Fatal("decodeData accepted unknown data-record flags with trailing garbage")
247+ }
248+}
249+
250+func TestReliableDeliveryPreservesInputSequenceOrder(t *testing.T) {
251+ aPC, bPC := newPair()
252+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
253+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
254+ defer func() { _ = a.Close() }()
255+ defer func() { _ = b.Close() }()
256+
257+ for i := range 100 {
258+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte{byte(i)}}); err != nil {
259+ t.Fatal(err)
260+ }
261+ }
262+ for i := range 100 {
263+ got := recvWithin(t, b, time.Second)
264+ if got.Type != ports.MsgInput || len(got.Payload) != 1 || got.Payload[0] != byte(i) {
265+ t.Fatalf("frame %d delivered out of order: %+v", i, got)
266+ }
267+ }
268+}
269+
270+func TestAckProcessingContinuesWhenConsumerBackpressured(t *testing.T) {
271+ aPC, bPC := newPair()
272+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
273+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
274+ defer func() { _ = a.Close() }()
275+ defer func() { _ = b.Close() }()
276+
277+ for i := range cap(b.in) + 20 {
278+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
279+ t.Fatal(err)
280+ }
281+ }
282+ deadline := time.Now().Add(time.Second)
283+ for time.Now().Before(deadline) {
284+ a.mu.Lock()
285+ pending := len(a.pending)
286+ a.mu.Unlock()
287+ if pending == 0 {
288+ return
289+ }
290+ time.Sleep(10 * time.Millisecond)
291+ }
292+ a.mu.Lock()
293+ pending := len(a.pending)
294+ a.mu.Unlock()
295+ t.Fatalf("acks blocked behind undrained consumer; pending=%d", pending)
296+}
297+
298+func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) {
299+ tr := &Transport{maxRecvBuffer: maxRecvBuffer, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
300+
301+ ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("third")})
302+ if !ack || !queued {
303+ t.Fatalf("out-of-order enqueue ack=%v queued=%v, want true/true", ack, queued)
304+ }
305+ if got, want := ackSeq, uint64(0); got != want {
306+ t.Fatalf("out-of-order ack=%d, want highest contiguous %d", got, want)
307+ }
308+
309+ ackSeq, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("first")})
310+ if !ack || !queued {
311+ t.Fatalf("first enqueue ack=%v queued=%v, want true/true", ack, queued)
312+ }
313+ if got, want := ackSeq, uint64(1); got != want {
314+ t.Fatalf("first contiguous ack=%d, want %d", got, want)
315+ }
316+
317+ ackSeq, ack, queued = tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")})
318+ if !ack || !queued {
319+ t.Fatalf("gap fill enqueue ack=%v queued=%v, want true/true", ack, queued)
320+ }
321+ if got, want := ackSeq, uint64(3); got != want {
322+ t.Fatalf("filled gap ack=%d, want %d", got, want)
323+ }
324+}
A internal/adapters/dgram/transport_retransmit_test.go +353 -0
@@ -0,0 +1,353 @@
1+package dgram
2+
3+import (
4+ "net"
5+ "sync/atomic"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+func TestResendSkipsQueuedOutputBeforeFirstWireAttempt(t *testing.T) {
13+ tr, writes := newResendTestTransport(t, 10)
14+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")}}
15+
16+ tr.resendPending()
17+
18+ if got := writes.Load(); got != 0 {
19+ t.Fatalf("writes=%d, want queued output excluded from resend", got)
20+ }
21+ if !tr.pending[1].first.IsZero() || !tr.pending[1].last.IsZero() {
22+ t.Fatal("resend scan marked queued output as sent")
23+ }
24+}
25+
26+func TestResendSkipsOutputDuringFirstWireAttempt(t *testing.T) {
27+ tr, writes := newResendTestTransport(t, 10)
28+ now := tr.clock.Now()
29+ tr.pending[1] = &pending{
30+ frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("sending")},
31+ first: now.Add(-time.Second),
32+ last: now.Add(-time.Second),
33+ initialInFlight: true,
34+ }
35+
36+ tr.resendPending()
37+ if got := writes.Load(); got != 0 {
38+ t.Fatalf("writes=%d, want first wire attempt excluded from resend", got)
39+ }
40+ tr.pending[1].initialInFlight = false
41+ tr.resendPending()
42+ if got := writes.Load(); got != 1 {
43+ t.Fatalf("writes=%d, want resend after first wire attempt completed", got)
44+ }
45+}
46+
47+func TestReliableInputRetransmitsUntilAck(t *testing.T) {
48+ aPC, bPC := newPair()
49+ var dropped atomic.Bool
50+ aPC.drop = func(_ []byte, addr net.Addr) bool {
51+ return addr.String() == "b" && dropped.CompareAndSwap(false, true)
52+ }
53+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ defer func() {
62+ if err := a.Close(); err != nil {
63+ t.Errorf("close a: %v", err)
64+ }
65+ }()
66+ defer func() {
67+ if err := b.Close(); err != nil {
68+ t.Errorf("close b: %v", err)
69+ }
70+ }()
71+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil {
72+ t.Fatal(err)
73+ }
74+ got := recvWithin(t, b, time.Second)
75+ if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !dropped.Load() {
76+ t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
77+ }
78+}
79+
80+func TestOutputRetransmitsUntilAck(t *testing.T) {
81+ aPC, bPC := newPair()
82+ var dropped atomic.Bool
83+ aPC.drop = func(_ []byte, addr net.Addr) bool {
84+ return addr.String() == "b" && dropped.CompareAndSwap(false, true)
85+ }
86+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
87+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
88+ defer func() {
89+ if err := a.Close(); err != nil {
90+ t.Errorf("close a: %v", err)
91+ }
92+ }()
93+ defer func() {
94+ if err := b.Close(); err != nil {
95+ t.Errorf("close b: %v", err)
96+ }
97+ }()
98+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state1")}); err != nil {
99+ t.Fatal(err)
100+ }
101+ got := recvWithin(t, b, time.Second)
102+ if got.Type != ports.MsgOutput || string(got.Payload) != "state1" || !dropped.Load() {
103+ t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
104+ }
105+}
106+
107+func TestImmediateLoopbackAckSamplesStampedFinalWrite(t *testing.T) {
108+ aPC, bPC := newPair()
109+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
110+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
111+ defer func() { _ = a.Close() }()
112+ defer func() { _ = b.Close() }()
113+ aPC.afterWrite = func() {
114+ deadline := time.Now().Add(time.Second)
115+ for time.Now().Before(deadline) {
116+ a.mu.Lock()
117+ _, pending := a.pending[1]
118+ a.mu.Unlock()
119+ if !pending {
120+ return
121+ }
122+ time.Sleep(time.Microsecond)
123+ }
124+ t.Error("ACK did not retire pending frame before WriteTo returned")
125+ }
126+
127+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state")}); err != nil {
128+ t.Fatal(err)
129+ }
130+ eventually(t, time.Second, func() bool {
131+ a.mu.Lock()
132+ defer a.mu.Unlock()
133+ return a.srtt > 0
134+ })
135+ a.mu.Lock()
136+ srtt, rto := a.srtt, a.rto
137+ a.mu.Unlock()
138+ if srtt <= 0 || srtt > time.Second {
139+ t.Fatalf("srtt=%v, want finite immediate-loopback sample", srtt)
140+ }
141+ if rto < a.resendAfter || rto > a.maxResendAfter {
142+ t.Fatalf("rto=%v outside [%v,%v]", rto, a.resendAfter, a.maxResendAfter)
143+ }
144+}
145+
146+func TestOutputDependencyChainRetransmitsDroppedPredecessors(t *testing.T) {
147+ aPC, bPC := newPair()
148+ var sendAttempts atomic.Int32
149+ aPC.drop = func(_ []byte, addr net.Addr) bool {
150+ return addr.String() == "b" && sendAttempts.Add(1) <= 2
151+ }
152+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
153+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
154+ defer func() { _ = a.Close() }()
155+ defer func() { _ = b.Close() }()
156+
157+ for _, payload := range []string{"state1", "state2", "state3"} {
158+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte(payload)}); err != nil {
159+ t.Fatal(err)
160+ }
161+ }
162+
163+ for _, want := range []string{"state1", "state2", "state3"} {
164+ got := recvWithin(t, b, time.Second)
165+ if got.Type != ports.MsgOutput || string(got.Payload) != want {
166+ t.Fatalf("got=%+v, want ordered prerequisite output %q", got, want)
167+ }
168+ }
169+ if sendAttempts.Load() < 3 {
170+ t.Fatalf("send attempts=%d, want first two output sends dropped before newest arrived", sendAttempts.Load())
171+ }
172+}
173+
174+func TestResendPendingCapsBurst(t *testing.T) {
175+ tr, writes := newResendTestTransport(t, 2)
176+ now := tr.clock.Now()
177+ for i := range 5 {
178+ tr.pending[uint64(i+1)] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}, last: now.Add(-time.Second)}
179+ }
180+
181+ tr.resendPending()
182+ if got := writes.Load(); got != 2 {
183+ t.Fatalf("resend writes=%d, want capped burst of 2", got)
184+ }
185+}
186+
187+func TestRetransmitBatchReducesCongestionWindowOnce(t *testing.T) {
188+ tr, writes := newResendTestTransport(t, 10)
189+ now := tr.clock.Now()
190+ for seq := uint64(1); seq <= 3; seq++ {
191+ tr.pending[seq] = &pending{
192+ frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(seq)}},
193+ last: now.Add(-time.Second),
194+ }
195+ }
196+ initialCwnd := tr.congestion.cwndBytes
197+
198+ tr.resendPending()
199+
200+ if got := writes.Load(); got != 3 {
201+ t.Fatalf("retransmit writes=%d, want 3", got)
202+ }
203+ if got, want := tr.congestion.cwndBytes, initialCwnd/2; got != want {
204+ t.Fatalf("cwnd after one retransmit batch=%d, want %d", got, want)
205+ }
206+}
207+
208+func TestACKBeforeQueuedRetransmitDoesNotReduceCongestionWindow(t *testing.T) {
209+ tr, writes := newResendTestTransport(t, 1)
210+ now := tr.clock.Now()
211+ tr.pending[1] = &pending{
212+ frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("acked")},
213+ first: now.Add(-time.Second),
214+ last: now.Add(-time.Second),
215+ wireBytes: tr.mtu,
216+ }
217+ initialCwnd := tr.congestion.cwndBytes
218+ tr.mu.Lock()
219+ batch, _ := tr.selectRetransmitsLocked(now)
220+ tr.mu.Unlock()
221+ if len(batch) != 1 {
222+ t.Fatalf("selected retransmits=%d, want 1", len(batch))
223+ }
224+ tr.handleRecord(ackRecord(1))
225+ cwndAfterACK := initialCwnd + max(1, tr.mtu*tr.mtu/initialCwnd)
226+ counterAfterACK := tr.ctr
227+
228+ tr.runRetransmitBatch(&bytePacer{clk: tr.clock}, batch)
229+
230+ if got := tr.ctr; got != counterAfterACK {
231+ t.Fatalf("packet counter after ACK=%d, want unchanged %d", got, counterAfterACK)
232+ }
233+ if got := writes.Load(); got != 0 {
234+ t.Fatalf("writes after ACK=%d, want 0", got)
235+ }
236+ if got := tr.congestion.cwndBytes; got != cwndAfterACK {
237+ t.Fatalf("cwnd after ACK race=%d, want ACK-only growth %d", got, cwndAfterACK)
238+ }
239+}
240+
241+func TestOversizedRetransmitHonorsPacketBudget(t *testing.T) {
242+ tr, writes := newResendTestTransport(t, 1)
243+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
244+ tr.clock = clk
245+ tr.done = make(chan struct{})
246+ tr.pending[1] = &pending{
247+ frame: ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 24*tr.mtu)},
248+ last: clk.now.Add(-time.Second),
249+ }
250+ done := make(chan struct{})
251+ go func() {
252+ tr.resendPending()
253+ close(done)
254+ }()
255+
256+ previous := int32(0)
257+ for tick := 0; tick < 128; tick++ {
258+ clk.advance(initialPacingRTT)
259+ time.Sleep(time.Millisecond)
260+ current := writes.Load()
261+ if delta := current - previous; delta > dataBurstPackets {
262+ t.Fatalf("tick %d retransmitted %d packets, burst %d", tick, delta, dataBurstPackets)
263+ }
264+ previous = current
265+ select {
266+ case <-done:
267+ return
268+ default:
269+ }
270+ }
271+ t.Fatal("oversized retransmit did not complete within pacing ticks")
272+}
273+
274+func TestResendPendingUsesExponentialBackoffAndMaxDelay(t *testing.T) {
275+ tr, writes := newResendTestTransport(t, 10)
276+ now := tr.clock.Now()
277+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("too-soon")}, last: now.Add(-150 * time.Millisecond), attempts: 1}
278+ tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("ready")}, last: now.Add(-250 * time.Millisecond), attempts: 1}
279+ tr.pending[3] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("capped")}, last: now.Add(-time.Second), attempts: 10}
280+
281+ tr.resendPending()
282+ if got := writes.Load(); got != 2 {
283+ t.Fatalf("resend writes=%d, want ready and capped frames only", got)
284+ }
285+ if attempts := tr.pending[1].attempts; attempts != 1 {
286+ t.Fatalf("too-soon attempts=%d, want unchanged", attempts)
287+ }
288+ if attempts := tr.pending[2].attempts; attempts != 2 {
289+ t.Fatalf("ready attempts=%d, want incremented", attempts)
290+ }
291+ if attempts := tr.pending[3].attempts; attempts != 11 {
292+ t.Fatalf("capped attempts=%d, want incremented", attempts)
293+ }
294+}
295+
296+func TestCumulativeAckReleasesAllEarlierPendingFrames(t *testing.T) {
297+ tr, _ := newResendTestTransport(t, 10)
298+ now := tr.clock.Now()
299+ for seq := uint64(1); seq <= 3; seq++ {
300+ tr.pending[seq] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(seq)}}, first: now, last: now}
301+ }
302+
303+ tr.handleRecord(ackRecord(3))
304+
305+ if got := len(tr.pending); got != 0 {
306+ t.Fatalf("pending after cumulative ack=%d, want 0", got)
307+ }
308+}
309+
310+func TestRTTEstimatorUpdatesRTOFromAckSample(t *testing.T) {
311+ clk := fixedClock{now: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)}
312+ tr, writes := newResendTestTransport(t, 10)
313+ tr.clock = clk
314+ tr.resendAfter = 100 * time.Millisecond
315+ tr.maxResendAfter = time.Second
316+ tr.rto = tr.resendAfter
317+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: clk.now.Add(-200 * time.Millisecond), last: clk.now.Add(-200 * time.Millisecond)}
318+
319+ tr.handleRecord(ackRecord(1))
320+
321+ if tr.rto <= tr.resendAfter {
322+ t.Fatalf("rto=%v, want above initial resend after RTT sample", tr.rto)
323+ }
324+ tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: clk.now, last: clk.now.Add(-(tr.rto - time.Millisecond))}
325+ tr.resendPending()
326+ if got := writes.Load(); got != 0 {
327+ t.Fatalf("writes before rto=%d, want 0", got)
328+ }
329+ tr.pending[2].last = clk.now.Add(-tr.rto)
330+ tr.resendPending()
331+ if got := writes.Load(); got != 1 {
332+ t.Fatalf("writes at rto=%d, want 1", got)
333+ }
334+}
335+
336+func TestRTTEstimatorIgnoresRetransmittedFramesAfterRecovery(t *testing.T) {
337+ clk := fixedClock{now: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)}
338+ tr, _ := newResendTestTransport(t, 10)
339+ tr.clock = clk
340+ tr.resendAfter = 100 * time.Millisecond
341+ tr.maxResendAfter = time.Second
342+ tr.rto = tr.resendAfter
343+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: clk.now.Add(-200 * time.Millisecond), last: clk.now.Add(-tr.resendAfter)}
344+ tr.resendPending()
345+ tr.linkState = ports.LinkStateOffline
346+
347+ tr.setLinkState(ports.LinkStateConnected, nil)
348+ tr.handleRecord(ackRecord(1))
349+
350+ if tr.rto != tr.resendAfter || tr.srtt != 0 || tr.rttvar != 0 {
351+ t.Fatalf("rtt sampled retransmitted frame after recovery: rto=%v srtt=%v rttvar=%v", tr.rto, tr.srtt, tr.rttvar)
352+ }
353+}
A internal/adapters/dgram/transport_send_test.go +537 -0
@@ -0,0 +1,537 @@
1+package dgram
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "net"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ pdgram "github.com/bnema/vev/pkg/dgram"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestPendingWireBytesMatchSealedDatagrams(t *testing.T) {
16+ const mtu = 128
17+ aPC, bPC := newPair()
18+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
19+ MTU: mtu, ResendAfter: time.Hour, Heartbeat: time.Hour,
20+ })
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ defer func() { _ = a.Close() }()
25+
26+ maxFragmentData := mtu - pdgram.HeaderSize - a.codec.Overhead() - 16
27+ frame := ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, maxFragmentData-dataRecordHeaderSize+1)}
28+ if err := a.Send(frame); err != nil {
29+ t.Fatal(err)
30+ }
31+
32+ want := 0
33+ for len(bPC.in) > 0 {
34+ want += len((<-bPC.in).b)
35+ }
36+ a.mu.Lock()
37+ got := a.pending[1].wireBytes
38+ initialCwnd := a.congestion.cwndBytes
39+ a.mu.Unlock()
40+ if got != want {
41+ t.Fatalf("pending wire bytes=%d, want sealed datagram bytes=%d", got, want)
42+ }
43+
44+ ack := make([]byte, 9)
45+ ack[0] = recAck
46+ binary.BigEndian.PutUint64(ack[1:], 1)
47+ a.handleRecord(ack)
48+ a.mu.Lock()
49+ gotCwnd := a.congestion.cwndBytes
50+ a.mu.Unlock()
51+ wantCwnd := initialCwnd + max(1, mtu*want/initialCwnd)
52+ if gotCwnd != wantCwnd {
53+ t.Fatalf("cwnd after ACK=%d, want %d from %d acknowledged sealed bytes", gotCwnd, wantCwnd, want)
54+ }
55+}
56+
57+func TestSendAsyncTransfersFragmentedRecordToBytePacedSender(t *testing.T) {
58+ const mtu = 128
59+ aPC, bPC := newPair()
60+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
61+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
62+ Clock: clk, MTU: mtu, ResendAfter: time.Hour, Heartbeat: time.Hour,
63+ })
64+ if err != nil {
65+ t.Fatal(err)
66+ }
67+ defer func() { _ = a.Close() }()
68+
69+ maxFragmentData := mtu - pdgram.HeaderSize - a.codec.Overhead()
70+ frame := ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 2*maxFragmentData)}
71+ if err := a.SendAsync(frame); err != nil {
72+ t.Fatal(err)
73+ }
74+ eventuallyPacketCount(t, bPC, dataBurstPackets)
75+ if got := len(bPC.in); got != dataBurstPackets {
76+ t.Fatalf("immediate datagrams=%d, want %d MTU burst", got, dataBurstPackets)
77+ }
78+ awaitSignal(t, clk.timerCreated, "shared data sender pacing timer")
79+ clk.advance(initialPacingRTT)
80+ eventuallyPacketCount(t, bPC, 3)
81+}
82+
83+func TestSmallOutputsShareInitialByteBurst(t *testing.T) {
84+ aPC, bPC := newPair()
85+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
86+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ defer func() { _ = a.Close() }()
91+
92+ for i := range 3 {
93+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
94+ t.Fatal(err)
95+ }
96+ }
97+ eventuallyPacketCount(t, bPC, 3)
98+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}); err != nil {
99+ t.Fatal(err)
100+ }
101+ eventuallyPacketCount(t, bPC, 4)
102+}
103+
104+func TestOutputPacingSendsAtMostOneOversizedFramePerTick(t *testing.T) {
105+ aPC, _ := newPair()
106+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
107+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ defer func() { _ = a.Close() }()
112+
113+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("prime")}); err != nil {
114+ t.Fatal(err)
115+ }
116+ large := ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: make([]byte, 24*a.mtu)})
117+ for range 2 {
118+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: large}); err != nil {
119+ t.Fatal(err)
120+ }
121+ }
122+ waitForManualTimers(t, clk, 4)
123+ previousPackets := len(aPC.in)
124+ for tick := 0; tick < 128; tick++ {
125+ clk.advance(initialPacingRTT)
126+ time.Sleep(time.Millisecond)
127+ packets := len(aPC.in)
128+ if delta := packets - previousPackets; delta > initialCongestionPackets {
129+ t.Fatalf("tick %d emitted %d packets, cwnd packets %d", tick, delta, initialCongestionPackets)
130+ }
131+ previousPackets = packets
132+ a.mu.Lock()
133+ last := a.pending[3]
134+ done := len(a.outputQueue) == 0 && last != nil && !last.initialInFlight
135+ a.mu.Unlock()
136+ if done {
137+ return
138+ }
139+ }
140+ t.Fatal("oversized queued frames did not complete within pacing ticks")
141+}
142+
143+func TestPacedInitialSendTimestampsFinalFragmentCompletion(t *testing.T) {
144+ aPC, _ := newPair()
145+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
146+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
147+ Clock: clk, ResendAfter: 500 * time.Millisecond, MaxResendAfter: time.Second, Heartbeat: time.Hour,
148+ })
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ defer func() { _ = a.Close() }()
153+ large := ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 24*a.mtu)}
154+ done := make(chan error, 1)
155+ go func() { done <- a.Send(large) }()
156+ start := clk.Now()
157+ for tick := 0; tick < 128; tick++ {
158+ a.mu.Lock()
159+ p := a.pending[1]
160+ if p != nil && p.initialInFlight && !p.last.IsZero() {
161+ a.mu.Unlock()
162+ t.Fatal("pending last timestamp set before final fragment")
163+ }
164+ a.mu.Unlock()
165+ clk.advance(initialPacingRTT)
166+ time.Sleep(time.Millisecond)
167+ select {
168+ case err := <-done:
169+ if err != nil {
170+ t.Fatal(err)
171+ }
172+ a.mu.Lock()
173+ completed := a.pending[1].last
174+ a.mu.Unlock()
175+ if elapsed := completed.Sub(start); elapsed <= initialPacingRTT {
176+ t.Fatalf("large send completed too early after %v", elapsed)
177+ }
178+ before := len(aPC.in)
179+ clk.advance(499 * time.Millisecond)
180+ a.resendPending()
181+ if got := len(aPC.in); got != before {
182+ t.Fatalf("resend emitted before one RTO after final fragment: %d -> %d", before, got)
183+ }
184+ a.handleRecord(ackRecord(1))
185+ a.mu.Lock()
186+ srtt := a.srtt
187+ a.mu.Unlock()
188+ require.Equal(t, 499*time.Millisecond, srtt, "RTT sample must start at final-fragment completion")
189+ return
190+ default:
191+ }
192+ }
193+ t.Fatal("large initial send did not complete")
194+}
195+
196+func TestOwnedSynchronousMaxSideEffectCompletesThroughConcurrentPacedWork(t *testing.T) {
197+ aPC, bPC := newPair()
198+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
199+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
200+ Clock: clk, ResendAfter: 250 * time.Millisecond, MaxResendAfter: time.Second, Heartbeat: time.Hour,
201+ })
202+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
203+ defer func() { _ = a.Close() }()
204+ defer func() { _ = b.Close() }()
205+ a.mu.Lock()
206+ a.srtt = 500 * time.Millisecond
207+ a.mu.Unlock()
208+ for i := range 8 {
209+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
210+ t.Fatal(err)
211+ }
212+ }
213+ now := clk.Now()
214+ a.mu.Lock()
215+ for _, seq := range []uint64{100, 101} {
216+ a.pending[seq] = &pending{
217+ frame: ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 20*a.mtu)},
218+ enqueued: now.Add(-time.Second), first: now.Add(-time.Second), last: now.Add(-time.Second),
219+ }
220+ }
221+ a.mu.Unlock()
222+ go a.resendPending()
223+ sideEffect := ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: make([]byte, 100*1024)})}
224+ done := make(chan error, 1)
225+ go func() { done <- a.SendSynchronous(sideEffect) }()
226+
227+ for tick := 0; tick < 512; tick++ {
228+ clk.advance(initialPacingRTT)
229+ time.Sleep(time.Millisecond)
230+ select {
231+ case err := <-done:
232+ if err != nil {
233+ t.Fatal(err)
234+ }
235+ if a.isClosed() {
236+ t.Fatal("healthy owned synchronous send force-closed transport")
237+ }
238+ return
239+ default:
240+ }
241+ }
242+ t.Fatal("maximum side effect did not complete through queued/retransmit pacing")
243+}
244+
245+func TestInputWaitsForDequeuedOutputBatch(t *testing.T) {
246+ aPC, bPC := newPair()
247+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
248+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
249+ if err != nil {
250+ t.Fatal(err)
251+ }
252+ defer func() { _ = a.Close() }()
253+
254+ a.writeMu.Lock()
255+ for _, payload := range []string{"first", "queued"} {
256+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte(payload)}); err != nil {
257+ t.Fatal(err)
258+ }
259+ }
260+ done := make(chan error, 1)
261+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}) }()
262+ select {
263+ case err := <-done:
264+ a.writeMu.Unlock()
265+ t.Fatalf("input completed before dequeued output batch: %v", err)
266+ case <-time.After(20 * time.Millisecond):
267+ }
268+ a.writeMu.Unlock()
269+ select {
270+ case err := <-done:
271+ if err != nil {
272+ t.Fatal(err)
273+ }
274+ case <-time.After(time.Second):
275+ t.Fatal("input did not complete after output batch was released")
276+ }
277+ if got := len(bPC.in); got != 3 {
278+ t.Fatalf("datagrams when input completed=%d, want output batch before input", got)
279+ }
280+}
281+
282+func TestPacedControlQueueFailureClosesTransport(t *testing.T) {
283+ aPC, bPC := newPair()
284+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
285+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
286+ if err != nil {
287+ t.Fatal(err)
288+ }
289+ defer func() { _ = a.Close() }()
290+
291+ if err := bPC.Close(); err != nil {
292+ t.Fatal(err)
293+ }
294+ inputDone := make(chan error, 1)
295+ go func() { inputDone <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}) }()
296+ if err := <-inputDone; err == nil {
297+ t.Fatal("data send succeeded after paced wire failure")
298+ }
299+ select {
300+ case <-a.done:
301+ case <-time.After(time.Second):
302+ t.Fatal("transport remained open after paced control queue write failed")
303+ }
304+}
305+
306+func TestRemoveQueuedPendingResolvesSynchronousWaiters(t *testing.T) {
307+ tr, _ := newResendTestTransport(t, 10)
308+ wantErr := errors.New("paced send failed")
309+ done := make(chan error, 1)
310+
311+ tr.mu.Lock()
312+ tr.pending[7] = &pending{}
313+ tr.mu.Unlock()
314+ tr.removeQueuedPending([]queuedSend{{seq: 7, reliable: true, done: done}}, wantErr)
315+
316+ require.ErrorIs(t, <-done, wantErr)
317+ _, ok := <-done
318+ require.False(t, ok, "completion channel must be closed")
319+ tr.mu.Lock()
320+ _, pending := tr.pending[7]
321+ tr.mu.Unlock()
322+ require.False(t, pending)
323+}
324+
325+func TestCloseUnblocksQueuedSynchronousFrames(t *testing.T) {
326+ frames := []struct {
327+ name string
328+ frame ports.Frame
329+ }{
330+ {name: "control", frame: ports.Frame{Type: ports.MsgPing}},
331+ {name: "terminal side effect", frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: []byte("osc")})}},
332+ }
333+ for _, tt := range frames {
334+ t.Run(tt.name, func(t *testing.T) {
335+ aPC, _ := newPair()
336+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
337+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
338+ if err != nil {
339+ t.Fatal(err)
340+ }
341+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 3*a.mtu)}); err != nil {
342+ t.Fatal(err)
343+ }
344+ done := make(chan error, 1)
345+ go func() { done <- a.Send(tt.frame) }()
346+ eventually(t, time.Second, func() bool {
347+ a.mu.Lock()
348+ defer a.mu.Unlock()
349+ return len(a.pending) == 2
350+ })
351+ _ = a.Close()
352+ if err := <-done; err == nil {
353+ t.Fatal("queued synchronous Send returned nil after close")
354+ }
355+ })
356+ }
357+}
358+
359+func TestOutputPaceFailureClosesTransportWithWriteError(t *testing.T) {
360+ aPC, bPC := newPair()
361+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
362+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
363+ if err != nil {
364+ t.Fatal(err)
365+ }
366+ defer func() { _ = a.Close() }()
367+
368+ if err := bPC.Close(); err != nil {
369+ t.Fatal(err)
370+ }
371+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("fails")}); err != nil {
372+ t.Fatal(err)
373+ }
374+
375+ select {
376+ case <-a.done:
377+ case <-time.After(time.Second):
378+ t.Fatal("transport remained open after paced output write failed")
379+ }
380+ if err := a.Send(ports.Frame{Type: ports.MsgInput}); err == nil || err.Error() != "peer closed" {
381+ t.Fatalf("send after paced write failure err=%v, want peer closed", err)
382+ }
383+}
384+
385+func TestInputAndControlRemainOrderedInBoundedPacedQueue(t *testing.T) {
386+ aPC, bPC := newPair()
387+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
388+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
389+ if err != nil {
390+ t.Fatal(err)
391+ }
392+ defer func() { _ = a.Close() }()
393+
394+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("output")}); err != nil {
395+ t.Fatal(err)
396+ }
397+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")}); err != nil {
398+ t.Fatal(err)
399+ }
400+ eventuallyPacketCount(t, bPC, 2)
401+ inputDone := make(chan error, 1)
402+ go func() { inputDone <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}) }()
403+ eventuallyPacketCount(t, bPC, 3)
404+ if err := <-inputDone; err != nil {
405+ t.Fatal(err)
406+ }
407+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued-before-control")}); err != nil {
408+ t.Fatal(err)
409+ }
410+ controlDone := make(chan error, 1)
411+ go func() { controlDone <- a.Send(ports.Frame{Type: ports.MsgPing}) }()
412+ eventuallyPacketCount(t, bPC, 5)
413+ if err := <-controlDone; err != nil {
414+ t.Fatal(err)
415+ }
416+}
417+
418+func TestSendWriteTimeoutBoundsBlockedPacketConn(t *testing.T) {
419+ pc := newBlockingWritePC()
420+ tr, err := NewTransportWithOptions(pc, testAddr("peer"), key(), 1, 2, Options{
421+ WriteTimeout: 10 * time.Millisecond,
422+ ResendAfter: time.Hour,
423+ Heartbeat: time.Hour,
424+ })
425+ if err != nil {
426+ t.Fatal(err)
427+ }
428+ defer func() { _ = tr.Close() }()
429+
430+ done := make(chan error, 1)
431+ go func() { done <- tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("blocked")}) }()
432+ select {
433+ case err := <-done:
434+ if err == nil {
435+ t.Fatal("Send returned nil, want write timeout")
436+ }
437+ var timeout interface{ Timeout() bool }
438+ if !errors.As(err, &timeout) || !timeout.Timeout() {
439+ t.Fatalf("Send err=%v, want timeout error", err)
440+ }
441+ tr.mu.Lock()
442+ pending := len(tr.pending)
443+ tr.mu.Unlock()
444+ if pending != 0 {
445+ t.Fatalf("pending after failed initial send=%d, want 0", pending)
446+ }
447+ case <-time.After(250 * time.Millisecond):
448+ t.Fatal("Send blocked without honoring WriteTimeout")
449+ }
450+}
451+
452+func TestPendingReliableQueueBackpressuresUntilAck(t *testing.T) {
453+ aPC, bPC := newPair()
454+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
455+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1})
456+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
457+ defer func() { _ = a.Close() }()
458+ defer func() { _ = b.Close() }()
459+
460+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
461+ t.Fatal(err)
462+ }
463+ done := make(chan error, 1)
464+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
465+
466+ select {
467+ case err := <-done:
468+ t.Fatalf("send returned before an ack released pending capacity: %v", err)
469+ case <-time.After(25 * time.Millisecond):
470+ }
471+
472+ a.handleRecord(ackRecord(1))
473+ select {
474+ case err := <-done:
475+ if err != nil {
476+ t.Fatalf("send after ack: %v", err)
477+ }
478+ case <-time.After(time.Second):
479+ t.Fatal("send did not resume after ack released pending capacity")
480+ }
481+ a.mu.Lock()
482+ pending := len(a.pending)
483+ a.mu.Unlock()
484+ if pending > 1 {
485+ t.Fatalf("pending=%d, want <= 1", pending)
486+ }
487+}
488+
489+func TestPendingReliableQueueReturnsErrPendingFullWhileConnected(t *testing.T) {
490+ aPC, bPC := newPair()
491+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
492+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
493+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1, MaxPendingWait: 50 * time.Millisecond, Clock: clk})
494+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
495+ defer func() { _ = a.Close() }()
496+ defer func() { _ = b.Close() }()
497+
498+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
499+ t.Fatal(err)
500+ }
501+ done := make(chan error, 1)
502+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
503+ waitForManualTimers(t, clk, 4)
504+
505+ clk.advance(50 * time.Millisecond)
506+ select {
507+ case err := <-done:
508+ if !errors.Is(err, ErrPendingFull) {
509+ t.Fatalf("send err=%v, want ErrPendingFull", err)
510+ }
511+ case <-time.After(time.Second):
512+ t.Fatal("send did not return after clock advanced past MaxPendingWait")
513+ }
514+}
515+
516+func TestWritePacketDeadlineUsesTransportClock(t *testing.T) {
517+ wantNow := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
518+ pc := &deadlineCapturePC{addr: testAddr("a"), done: make(chan struct{})}
519+ tr, err := NewTransportWithOptions(pc, testAddr("b"), key(), 1, 2, Options{
520+ Clock: fixedClock{now: wantNow},
521+ WriteTimeout: 250 * time.Millisecond,
522+ ResendAfter: time.Hour,
523+ Heartbeat: time.Hour,
524+ })
525+ if err != nil {
526+ t.Fatal(err)
527+ }
528+ defer func() { _ = tr.Close() }()
529+
530+ if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("deadline")}); err != nil {
531+ t.Fatal(err)
532+ }
533+ got, _ := pc.deadline.Load().(time.Time)
534+ if want := wantNow.Add(250 * time.Millisecond); !got.Equal(want) {
535+ t.Fatalf("write deadline=%v, want transport clock deadline %v", got, want)
536+ }
537+}
D internal/adapters/dgram/transport_test.go +0 -2479
@@ -1,2479 +0,0 @@
1-package dgram
2-
3-import (
4- "bytes"
5- "context"
6- "encoding/binary"
7- "errors"
8- "fmt"
9- "net"
10- "sync"
11- "sync/atomic"
12- "testing"
13- "time"
14-
15- "github.com/bnema/vev/internal/ports"
16- pdgram "github.com/bnema/vev/pkg/dgram"
17- "github.com/stretchr/testify/require"
18-)
19-
20-type timeoutErr struct{}
21-
22-func (timeoutErr) Error() string { return "i/o timeout" }
23-func (timeoutErr) Timeout() bool { return true }
24-
25-type blockingWritePC struct {
26- addr net.Addr
27-
28- mu sync.Mutex
29- closed bool
30- deadline time.Time
31- done chan struct{}
32-}
33-
34-func newBlockingWritePC() *blockingWritePC {
35- return &blockingWritePC{addr: testAddr("blocked"), done: make(chan struct{})}
36-}
37-
38-func (p *blockingWritePC) ReadFrom([]byte) (int, net.Addr, error) {
39- <-p.done
40- return 0, nil, errors.New("closed")
41-}
42-
43-func (p *blockingWritePC) WriteTo([]byte, net.Addr) (int, error) {
44- for {
45- p.mu.Lock()
46- closed := p.closed
47- deadline := p.deadline
48- p.mu.Unlock()
49- if closed {
50- return 0, errors.New("closed")
51- }
52- if !deadline.IsZero() {
53- wait := time.Until(deadline)
54- if wait > 0 {
55- timer := time.NewTimer(wait)
56- select {
57- case <-timer.C:
58- case <-p.done:
59- timer.Stop()
60- return 0, errors.New("closed")
61- }
62- }
63- return 0, timeoutErr{}
64- }
65- select {
66- case <-time.After(time.Millisecond):
67- case <-p.done:
68- return 0, errors.New("closed")
69- }
70- }
71-}
72-
73-func (p *blockingWritePC) Close() error {
74- p.mu.Lock()
75- defer p.mu.Unlock()
76- if !p.closed {
77- p.closed = true
78- close(p.done)
79- }
80- return nil
81-}
82-
83-func (p *blockingWritePC) LocalAddr() net.Addr { return p.addr }
84-func (p *blockingWritePC) SetDeadline(t time.Time) error {
85- p.mu.Lock()
86- p.deadline = t
87- p.mu.Unlock()
88- return nil
89-}
90-func (p *blockingWritePC) SetReadDeadline(time.Time) error { return nil }
91-func (p *blockingWritePC) SetWriteDeadline(t time.Time) error {
92- p.mu.Lock()
93- p.deadline = t
94- p.mu.Unlock()
95- return nil
96-}
97-
98-type fixedClock struct{ now time.Time }
99-
100-func (c fixedClock) Now() time.Time { return c.now }
101-func (c fixedClock) NewTimer(d time.Duration) ports.Timer {
102- return realTimer{t: time.NewTimer(d)}
103-}
104-
105-type manualClock struct {
106- mu sync.Mutex
107- now time.Time
108- timers map[*manualTimer]struct{}
109- timerCreated chan struct{}
110-}
111-
112-func newManualClock(now time.Time) *manualClock {
113- return &manualClock{
114- now: now,
115- timers: make(map[*manualTimer]struct{}),
116- timerCreated: make(chan struct{}, 64),
117- }
118-}
119-
120-func (c *manualClock) Now() time.Time {
121- c.mu.Lock()
122- defer c.mu.Unlock()
123- return c.now
124-}
125-
126-func (c *manualClock) NewTimer(d time.Duration) ports.Timer {
127- c.mu.Lock()
128- tm := &manualTimer{clock: c, c: make(chan time.Time, 1), deadline: c.now.Add(d), active: true}
129- c.timers[tm] = struct{}{}
130- c.mu.Unlock()
131- select {
132- case c.timerCreated <- struct{}{}:
133- default:
134- }
135- return tm
136-}
137-
138-func (c *manualClock) advance(d time.Duration) {
139- c.mu.Lock()
140- c.now = c.now.Add(d)
141- now := c.now
142- timers := make([]*manualTimer, 0, len(c.timers))
143- for tm := range c.timers {
144- timers = append(timers, tm)
145- }
146- c.mu.Unlock()
147- for _, tm := range timers {
148- tm.fireIfDue(now)
149- }
150-}
151-
152-type manualTimer struct {
153- clock *manualClock
154- c chan time.Time
155- deadline time.Time
156- active bool
157-}
158-
159-func (t *manualTimer) C() <-chan time.Time { return t.c }
160-func (t *manualTimer) Reset(d time.Duration) bool {
161- t.clock.mu.Lock()
162- wasActive := t.active
163- t.deadline = t.clock.now.Add(d)
164- t.active = true
165- t.clock.timers[t] = struct{}{}
166- t.clock.mu.Unlock()
167- return wasActive
168-}
169-func (t *manualTimer) Stop() bool {
170- t.clock.mu.Lock()
171- defer t.clock.mu.Unlock()
172- wasActive := t.active
173- t.active = false
174- delete(t.clock.timers, t)
175- return wasActive
176-}
177-func (t *manualTimer) fireIfDue(now time.Time) bool {
178- t.clock.mu.Lock()
179- if !t.active {
180- delete(t.clock.timers, t)
181- t.clock.mu.Unlock()
182- return false
183- }
184- if now.Before(t.deadline) {
185- t.clock.mu.Unlock()
186- return false
187- }
188- t.active = false
189- delete(t.clock.timers, t)
190- t.clock.mu.Unlock()
191- select {
192- case t.c <- now:
193- default:
194- }
195- return true
196-}
197-
198-func waitForManualTimers(t *testing.T, clk *manualClock, n int) {
199- t.Helper()
200- deadline := time.Now().Add(time.Second)
201- for time.Now().Before(deadline) {
202- clk.mu.Lock()
203- active := len(clk.timers)
204- clk.mu.Unlock()
205- if active >= n {
206- return
207- }
208- time.Sleep(time.Millisecond)
209- }
210- clk.mu.Lock()
211- active := len(clk.timers)
212- clk.mu.Unlock()
213- t.Fatalf("manual timers=%d, want at least %d", active, n)
214-}
215-
216-func TestCongestionControllerBoundsAndIntegerGrowth(t *testing.T) {
217- const mtu = 1200
218- c := newCongestionController(mtu)
219- if got, want := c.cwndBytes, initialCongestionPackets*mtu; got != want {
220- t.Fatalf("initial cwnd=%d, want %d", got, want)
221- }
222- if got, want := c.burstBytes(), dataBurstPackets*mtu; got != want {
223- t.Fatalf("initial burst=%d, want %d", got, want)
224- }
225- if got, want := c.bytesPerSecond(), initialCongestionPackets*mtu*int(time.Second/initialPacingRTT); got != want {
226- t.Fatalf("initial pacing rate=%d, want %d", got, want)
227- }
228-
229- c.onLoss()
230- if got, want := c.cwndBytes, initialCongestionPackets*mtu/2; got != want {
231- t.Fatalf("cwnd after first loss=%d, want %d", got, want)
232- }
233- c.cwndBytes = 3 * mtu
234- c.onLoss()
235- if got, want := c.cwndBytes, minimumCongestionPackets*mtu; got != want {
236- t.Fatalf("minimum cwnd=%d, want %d", got, want)
237- }
238-
239- c.cwndBytes = 10 * mtu
240- c.onACK(1)
241- if got, want := c.cwndBytes, 10*mtu+1; got != want {
242- t.Fatalf("minimum additive increase cwnd=%d, want %d", got, want)
243- }
244- c.onACK(10 * mtu)
245- if got, want := c.cwndBytes, 10*mtu+1+(mtu*10*mtu)/(10*mtu+1); got != want {
246- t.Fatalf("integer additive increase cwnd=%d, want %d", got, want)
247- }
248- c.cwndBytes = maximumCongestionBytes
249- c.onACK(maximumCongestionBytes)
250- if got := c.cwndBytes; got != maximumCongestionBytes {
251- t.Fatalf("maximum cwnd=%d, want %d", got, maximumCongestionBytes)
252- }
253-
254- c.onRTT(500 * time.Microsecond)
255- if got, want := c.bytesPerSecond(), maximumCongestionBytes*1000; got != want {
256- t.Fatalf("sub-millisecond pacing rate=%d, want %d", got, want)
257- }
258-}
259-
260-func TestBytePacerUsesBurstThenFakeClockRate(t *testing.T) {
261- const mtu = 1200
262- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
263- c := newCongestionController(mtu)
264- p := bytePacer{clk: clk}
265- done := make(chan struct{})
266- limits := func() (int, int) { return c.bytesPerSecond(), c.burstBytes() }
267-
268- for range dataBurstPackets {
269- if err := p.wait(done, mtu, limits); err != nil {
270- t.Fatal(err)
271- }
272- }
273- wait := make(chan error, 1)
274- go func() { wait <- p.wait(done, mtu, limits) }()
275- awaitSignal(t, clk.timerCreated, "byte pacer timer")
276- clk.advance(24 * time.Millisecond)
277- select {
278- case err := <-wait:
279- t.Fatalf("third MTU released early: %v", err)
280- default:
281- }
282- clk.advance(time.Millisecond)
283- if err := awaitResult(t, wait, "third paced MTU"); err != nil {
284- t.Fatal(err)
285- }
286-
287- c.onLoss()
288- next := make(chan error, 1)
289- go func() { next <- p.wait(done, mtu, limits) }()
290- awaitSignal(t, clk.timerCreated, "reduced-cwnd byte pacer timer")
291- clk.advance(49 * time.Millisecond)
292- select {
293- case err := <-next:
294- t.Fatalf("reduced cwnd did not lengthen wait: %v", err)
295- default:
296- }
297- clk.advance(time.Millisecond)
298- if err := awaitResult(t, next, "reduced-cwnd paced MTU"); err != nil {
299- t.Fatal(err)
300- }
301-}
302-
303-func TestBytePacerMaximumRateRefillsAfterMultiSecondFakeClockJump(t *testing.T) {
304- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
305- p := bytePacer{clk: clk}
306- done := make(chan struct{})
307- burst := int(^uint(0) >> 1)
308- limits := func() (int, int) { return burst, burst }
309-
310- if err := p.wait(done, burst, limits); err != nil {
311- t.Fatal(err)
312- }
313- clk.advance(3 * time.Second)
314- if err := p.wait(done, burst, limits); err != nil {
315- t.Fatal(err)
316- }
317-
318- wait := make(chan error, 1)
319- go func() { wait <- p.wait(done, burst, limits) }()
320- awaitSignal(t, clk.timerCreated, "maximum-rate byte pacer timer")
321- clk.advance(time.Second - time.Nanosecond)
322- select {
323- case err := <-wait:
324- t.Fatalf("maximum-rate burst released early: %v", err)
325- default:
326- }
327- clk.advance(time.Nanosecond)
328- if err := awaitResult(t, wait, "maximum-rate burst"); err != nil {
329- t.Fatal(err)
330- }
331-}
332-
333-func TestBytePacerCancellation(t *testing.T) {
334- const mtu = 1200
335- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
336- p := bytePacer{clk: clk}
337- done := make(chan struct{})
338- limits := func() (int, int) { return mtu, mtu }
339- if err := p.wait(done, mtu, limits); err != nil {
340- t.Fatal(err)
341- }
342- wait := make(chan error, 1)
343- go func() { wait <- p.wait(done, mtu, limits) }()
344- awaitSignal(t, clk.timerCreated, "cancelled byte pacer timer")
345- close(done)
346- if err := awaitResult(t, wait, "byte pacer cancellation"); !errors.Is(err, errPacerClosed) {
347- t.Fatalf("cancellation error=%v, want %v", err, errPacerClosed)
348- }
349-}
350-
351-func BenchmarkCongestionControllerACK(b *testing.B) {
352- c := newCongestionController(defaultMTU)
353- b.ReportAllocs()
354- for b.Loop() {
355- c.onACK(defaultMTU)
356- if c.cwndBytes == c.maxBytes {
357- c = newCongestionController(defaultMTU)
358- }
359- }
360-}
361-
362-func TestPendingWireBytesMatchSealedDatagrams(t *testing.T) {
363- const mtu = 128
364- aPC, bPC := newPair()
365- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
366- MTU: mtu, ResendAfter: time.Hour, Heartbeat: time.Hour,
367- })
368- if err != nil {
369- t.Fatal(err)
370- }
371- defer func() { _ = a.Close() }()
372-
373- maxFragmentData := mtu - pdgram.HeaderSize - a.codec.Overhead() - 16
374- frame := ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, maxFragmentData-dataRecordHeaderSize+1)}
375- if err := a.Send(frame); err != nil {
376- t.Fatal(err)
377- }
378-
379- want := 0
380- for len(bPC.in) > 0 {
381- want += len((<-bPC.in).b)
382- }
383- a.mu.Lock()
384- got := a.pending[1].wireBytes
385- initialCwnd := a.congestion.cwndBytes
386- a.mu.Unlock()
387- if got != want {
388- t.Fatalf("pending wire bytes=%d, want sealed datagram bytes=%d", got, want)
389- }
390-
391- ack := make([]byte, 9)
392- ack[0] = recAck
393- binary.BigEndian.PutUint64(ack[1:], 1)
394- a.handleRecord(ack)
395- a.mu.Lock()
396- gotCwnd := a.congestion.cwndBytes
397- a.mu.Unlock()
398- wantCwnd := initialCwnd + max(1, mtu*want/initialCwnd)
399- if gotCwnd != wantCwnd {
400- t.Fatalf("cwnd after ACK=%d, want %d from %d acknowledged sealed bytes", gotCwnd, wantCwnd, want)
401- }
402-}
403-
404-func TestSendAsyncTransfersFragmentedRecordToBytePacedSender(t *testing.T) {
405- const mtu = 128
406- aPC, bPC := newPair()
407- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
408- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
409- Clock: clk, MTU: mtu, ResendAfter: time.Hour, Heartbeat: time.Hour,
410- })
411- if err != nil {
412- t.Fatal(err)
413- }
414- defer func() { _ = a.Close() }()
415-
416- maxFragmentData := mtu - pdgram.HeaderSize - a.codec.Overhead()
417- frame := ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 2*maxFragmentData)}
418- if err := a.SendAsync(frame); err != nil {
419- t.Fatal(err)
420- }
421- eventuallyPacketCount(t, bPC, dataBurstPackets)
422- if got := len(bPC.in); got != dataBurstPackets {
423- t.Fatalf("immediate datagrams=%d, want %d MTU burst", got, dataBurstPackets)
424- }
425- awaitSignal(t, clk.timerCreated, "shared data sender pacing timer")
426- clk.advance(initialPacingRTT)
427- eventuallyPacketCount(t, bPC, 3)
428-}
429-
430-type deadlineCapturePC struct {
431- addr net.Addr
432- deadline atomic.Value
433- done chan struct{}
434-}
435-
436-func (p *deadlineCapturePC) ReadFrom([]byte) (int, net.Addr, error) {
437- <-p.done
438- return 0, nil, errors.New("closed")
439-}
440-func (p *deadlineCapturePC) WriteTo(b []byte, _ net.Addr) (int, error) { return len(b), nil }
441-func (p *deadlineCapturePC) Close() error {
442- select {
443- case <-p.done:
444- default:
445- close(p.done)
446- }
447- return nil
448-}
449-func (p *deadlineCapturePC) LocalAddr() net.Addr { return p.addr }
450-func (p *deadlineCapturePC) SetDeadline(time.Time) error { return nil }
451-func (p *deadlineCapturePC) SetReadDeadline(time.Time) error { return nil }
452-func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error {
453- if !t.IsZero() {
454- p.deadline.Store(t)
455- }
456- return nil
457-}
458-
459-func TestSmallOutputsShareInitialByteBurst(t *testing.T) {
460- aPC, bPC := newPair()
461- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
462- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
463- if err != nil {
464- t.Fatal(err)
465- }
466- defer func() { _ = a.Close() }()
467-
468- for i := range 3 {
469- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
470- t.Fatal(err)
471- }
472- }
473- eventuallyPacketCount(t, bPC, 3)
474- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}); err != nil {
475- t.Fatal(err)
476- }
477- eventuallyPacketCount(t, bPC, 4)
478-}
479-
480-func TestOutputPacingSendsAtMostOneOversizedFramePerTick(t *testing.T) {
481- aPC, _ := newPair()
482- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
483- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
484- if err != nil {
485- t.Fatal(err)
486- }
487- defer func() { _ = a.Close() }()
488-
489- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("prime")}); err != nil {
490- t.Fatal(err)
491- }
492- large := ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: make([]byte, 24*a.mtu)})
493- for range 2 {
494- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: large}); err != nil {
495- t.Fatal(err)
496- }
497- }
498- waitForManualTimers(t, clk, 4)
499- previousPackets := len(aPC.in)
500- for tick := 0; tick < 128; tick++ {
501- clk.advance(initialPacingRTT)
502- time.Sleep(time.Millisecond)
503- packets := len(aPC.in)
504- if delta := packets - previousPackets; delta > initialCongestionPackets {
505- t.Fatalf("tick %d emitted %d packets, cwnd packets %d", tick, delta, initialCongestionPackets)
506- }
507- previousPackets = packets
508- a.mu.Lock()
509- last := a.pending[3]
510- done := len(a.outputQueue) == 0 && last != nil && !last.initialInFlight
511- a.mu.Unlock()
512- if done {
513- return
514- }
515- }
516- t.Fatal("oversized queued frames did not complete within pacing ticks")
517-}
518-
519-func TestPacedInitialSendTimestampsFinalFragmentCompletion(t *testing.T) {
520- aPC, _ := newPair()
521- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
522- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
523- Clock: clk, ResendAfter: 500 * time.Millisecond, MaxResendAfter: time.Second, Heartbeat: time.Hour,
524- })
525- if err != nil {
526- t.Fatal(err)
527- }
528- defer func() { _ = a.Close() }()
529- large := ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 24*a.mtu)}
530- done := make(chan error, 1)
531- go func() { done <- a.Send(large) }()
532- start := clk.Now()
533- for tick := 0; tick < 128; tick++ {
534- a.mu.Lock()
535- p := a.pending[1]
536- if p != nil && p.initialInFlight && !p.last.IsZero() {
537- a.mu.Unlock()
538- t.Fatal("pending last timestamp set before final fragment")
539- }
540- a.mu.Unlock()
541- clk.advance(initialPacingRTT)
542- time.Sleep(time.Millisecond)
543- select {
544- case err := <-done:
545- if err != nil {
546- t.Fatal(err)
547- }
548- a.mu.Lock()
549- completed := a.pending[1].last
550- a.mu.Unlock()
551- if elapsed := completed.Sub(start); elapsed <= initialPacingRTT {
552- t.Fatalf("large send completed too early after %v", elapsed)
553- }
554- before := len(aPC.in)
555- clk.advance(499 * time.Millisecond)
556- a.resendPending()
557- if got := len(aPC.in); got != before {
558- t.Fatalf("resend emitted before one RTO after final fragment: %d -> %d", before, got)
559- }
560- a.handleRecord(ackRecord(1))
561- a.mu.Lock()
562- srtt := a.srtt
563- a.mu.Unlock()
564- require.Equal(t, 499*time.Millisecond, srtt, "RTT sample must start at final-fragment completion")
565- return
566- default:
567- }
568- }
569- t.Fatal("large initial send did not complete")
570-}
571-
572-func TestOwnedSynchronousMaxSideEffectCompletesThroughConcurrentPacedWork(t *testing.T) {
573- aPC, bPC := newPair()
574- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
575- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
576- Clock: clk, ResendAfter: 250 * time.Millisecond, MaxResendAfter: time.Second, Heartbeat: time.Hour,
577- })
578- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
579- defer func() { _ = a.Close() }()
580- defer func() { _ = b.Close() }()
581- a.mu.Lock()
582- a.srtt = 500 * time.Millisecond
583- a.mu.Unlock()
584- for i := range 8 {
585- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
586- t.Fatal(err)
587- }
588- }
589- now := clk.Now()
590- a.mu.Lock()
591- for _, seq := range []uint64{100, 101} {
592- a.pending[seq] = &pending{
593- frame: ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 20*a.mtu)},
594- enqueued: now.Add(-time.Second), first: now.Add(-time.Second), last: now.Add(-time.Second),
595- }
596- }
597- a.mu.Unlock()
598- go a.resendPending()
599- sideEffect := ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: make([]byte, 100*1024)})}
600- done := make(chan error, 1)
601- go func() { done <- a.SendSynchronous(sideEffect) }()
602-
603- for tick := 0; tick < 512; tick++ {
604- clk.advance(initialPacingRTT)
605- time.Sleep(time.Millisecond)
606- select {
607- case err := <-done:
608- if err != nil {
609- t.Fatal(err)
610- }
611- if a.isClosed() {
612- t.Fatal("healthy owned synchronous send force-closed transport")
613- }
614- return
615- default:
616- }
617- }
618- t.Fatal("maximum side effect did not complete through queued/retransmit pacing")
619-}
620-
621-func TestResendSkipsQueuedOutputBeforeFirstWireAttempt(t *testing.T) {
622- tr, writes := newResendTestTransport(t, 10)
623- tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")}}
624-
625- tr.resendPending()
626-
627- if got := writes.Load(); got != 0 {
628- t.Fatalf("writes=%d, want queued output excluded from resend", got)
629- }
630- if !tr.pending[1].first.IsZero() || !tr.pending[1].last.IsZero() {
631- t.Fatal("resend scan marked queued output as sent")
632- }
633-}
634-
635-func TestResendSkipsOutputDuringFirstWireAttempt(t *testing.T) {
636- tr, writes := newResendTestTransport(t, 10)
637- now := tr.clock.Now()
638- tr.pending[1] = &pending{
639- frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("sending")},
640- first: now.Add(-time.Second),
641- last: now.Add(-time.Second),
642- initialInFlight: true,
643- }
644-
645- tr.resendPending()
646- if got := writes.Load(); got != 0 {
647- t.Fatalf("writes=%d, want first wire attempt excluded from resend", got)
648- }
649- tr.pending[1].initialInFlight = false
650- tr.resendPending()
651- if got := writes.Load(); got != 1 {
652- t.Fatalf("writes=%d, want resend after first wire attempt completed", got)
653- }
654-}
655-
656-func TestInputWaitsForDequeuedOutputBatch(t *testing.T) {
657- aPC, bPC := newPair()
658- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
659- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
660- if err != nil {
661- t.Fatal(err)
662- }
663- defer func() { _ = a.Close() }()
664-
665- a.writeMu.Lock()
666- for _, payload := range []string{"first", "queued"} {
667- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte(payload)}); err != nil {
668- t.Fatal(err)
669- }
670- }
671- done := make(chan error, 1)
672- go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}) }()
673- select {
674- case err := <-done:
675- a.writeMu.Unlock()
676- t.Fatalf("input completed before dequeued output batch: %v", err)
677- case <-time.After(20 * time.Millisecond):
678- }
679- a.writeMu.Unlock()
680- select {
681- case err := <-done:
682- if err != nil {
683- t.Fatal(err)
684- }
685- case <-time.After(time.Second):
686- t.Fatal("input did not complete after output batch was released")
687- }
688- if got := len(bPC.in); got != 3 {
689- t.Fatalf("datagrams when input completed=%d, want output batch before input", got)
690- }
691-}
692-
693-func TestPacedControlQueueFailureClosesTransport(t *testing.T) {
694- aPC, bPC := newPair()
695- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
696- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
697- if err != nil {
698- t.Fatal(err)
699- }
700- defer func() { _ = a.Close() }()
701-
702- if err := bPC.Close(); err != nil {
703- t.Fatal(err)
704- }
705- inputDone := make(chan error, 1)
706- go func() { inputDone <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}) }()
707- if err := <-inputDone; err == nil {
708- t.Fatal("data send succeeded after paced wire failure")
709- }
710- select {
711- case <-a.done:
712- case <-time.After(time.Second):
713- t.Fatal("transport remained open after paced control queue write failed")
714- }
715-}
716-
717-func TestRemoveQueuedPendingResolvesSynchronousWaiters(t *testing.T) {
718- tr, _ := newResendTestTransport(t, 10)
719- wantErr := errors.New("paced send failed")
720- done := make(chan error, 1)
721-
722- tr.mu.Lock()
723- tr.pending[7] = &pending{}
724- tr.mu.Unlock()
725- tr.removeQueuedPending([]queuedSend{{seq: 7, reliable: true, done: done}}, wantErr)
726-
727- require.ErrorIs(t, <-done, wantErr)
728- _, ok := <-done
729- require.False(t, ok, "completion channel must be closed")
730- tr.mu.Lock()
731- _, pending := tr.pending[7]
732- tr.mu.Unlock()
733- require.False(t, pending)
734-}
735-
736-func TestCloseUnblocksQueuedSynchronousFrames(t *testing.T) {
737- frames := []struct {
738- name string
739- frame ports.Frame
740- }{
741- {name: "control", frame: ports.Frame{Type: ports.MsgPing}},
742- {name: "terminal side effect", frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: []byte("osc")})}},
743- }
744- for _, tt := range frames {
745- t.Run(tt.name, func(t *testing.T) {
746- aPC, _ := newPair()
747- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
748- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
749- if err != nil {
750- t.Fatal(err)
751- }
752- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 3*a.mtu)}); err != nil {
753- t.Fatal(err)
754- }
755- done := make(chan error, 1)
756- go func() { done <- a.Send(tt.frame) }()
757- eventually(t, time.Second, func() bool {
758- a.mu.Lock()
759- defer a.mu.Unlock()
760- return len(a.pending) == 2
761- })
762- _ = a.Close()
763- if err := <-done; err == nil {
764- t.Fatal("queued synchronous Send returned nil after close")
765- }
766- })
767- }
768-}
769-
770-func TestOutputPaceFailureClosesTransportWithWriteError(t *testing.T) {
771- aPC, bPC := newPair()
772- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
773- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
774- if err != nil {
775- t.Fatal(err)
776- }
777- defer func() { _ = a.Close() }()
778-
779- if err := bPC.Close(); err != nil {
780- t.Fatal(err)
781- }
782- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("fails")}); err != nil {
783- t.Fatal(err)
784- }
785-
786- select {
787- case <-a.done:
788- case <-time.After(time.Second):
789- t.Fatal("transport remained open after paced output write failed")
790- }
791- if err := a.Send(ports.Frame{Type: ports.MsgInput}); err == nil || err.Error() != "peer closed" {
792- t.Fatalf("send after paced write failure err=%v, want peer closed", err)
793- }
794-}
795-
796-func TestInputAndControlRemainOrderedInBoundedPacedQueue(t *testing.T) {
797- aPC, bPC := newPair()
798- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
799- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
800- if err != nil {
801- t.Fatal(err)
802- }
803- defer func() { _ = a.Close() }()
804-
805- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("output")}); err != nil {
806- t.Fatal(err)
807- }
808- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")}); err != nil {
809- t.Fatal(err)
810- }
811- eventuallyPacketCount(t, bPC, 2)
812- inputDone := make(chan error, 1)
813- go func() { inputDone <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}) }()
814- eventuallyPacketCount(t, bPC, 3)
815- if err := <-inputDone; err != nil {
816- t.Fatal(err)
817- }
818- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued-before-control")}); err != nil {
819- t.Fatal(err)
820- }
821- controlDone := make(chan error, 1)
822- go func() { controlDone <- a.Send(ports.Frame{Type: ports.MsgPing}) }()
823- eventuallyPacketCount(t, bPC, 5)
824- if err := <-controlDone; err != nil {
825- t.Fatal(err)
826- }
827-}
828-
829-func eventuallyPacketCount(t *testing.T, pc *fakePC, want int) {
830- t.Helper()
831- deadline := time.Now().Add(time.Second)
832- for time.Now().Before(deadline) {
833- if len(pc.in) >= want {
834- return
835- }
836- time.Sleep(time.Millisecond)
837- }
838- t.Fatalf("datagrams=%d, want at least %d", len(pc.in), want)
839-}
840-
841-func eventually(t *testing.T, timeout time.Duration, condition func() bool) {
842- t.Helper()
843- deadline := time.Now().Add(timeout)
844- for time.Now().Before(deadline) {
845- if condition() {
846- return
847- }
848- time.Sleep(time.Millisecond)
849- }
850- t.Fatal("condition was not met before timeout")
851-}
852-
853-func TestSendWriteTimeoutBoundsBlockedPacketConn(t *testing.T) {
854- pc := newBlockingWritePC()
855- tr, err := NewTransportWithOptions(pc, testAddr("peer"), key(), 1, 2, Options{
856- WriteTimeout: 10 * time.Millisecond,
857- ResendAfter: time.Hour,
858- Heartbeat: time.Hour,
859- })
860- if err != nil {
861- t.Fatal(err)
862- }
863- defer func() { _ = tr.Close() }()
864-
865- done := make(chan error, 1)
866- go func() { done <- tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("blocked")}) }()
867- select {
868- case err := <-done:
869- if err == nil {
870- t.Fatal("Send returned nil, want write timeout")
871- }
872- var timeout interface{ Timeout() bool }
873- if !errors.As(err, &timeout) || !timeout.Timeout() {
874- t.Fatalf("Send err=%v, want timeout error", err)
875- }
876- tr.mu.Lock()
877- pending := len(tr.pending)
878- tr.mu.Unlock()
879- if pending != 0 {
880- t.Fatalf("pending after failed initial send=%d, want 0", pending)
881- }
882- case <-time.After(250 * time.Millisecond):
883- t.Fatal("Send blocked without honoring WriteTimeout")
884- }
885-}
886-
887-func TestReliableDuplicateAckedButNotDeliveredTwice(t *testing.T) {
888- aPC, bPC := newPair()
889- var droppedAck atomic.Bool
890- bPC.drop = func(_ []byte, addr net.Addr) bool {
891- return addr.String() == "a" && droppedAck.CompareAndSwap(false, true)
892- }
893- a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
894- if err != nil {
895- t.Fatal(err)
896- }
897- b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
898- if err != nil {
899- t.Fatal(err)
900- }
901- defer func() {
902- if err := a.Close(); err != nil {
903- t.Errorf("close a: %v", err)
904- }
905- }()
906- defer func() {
907- if err := b.Close(); err != nil {
908- t.Errorf("close b: %v", err)
909- }
910- }()
911- if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil {
912- t.Fatal(err)
913- }
914- got := recvWithin(t, b, time.Second)
915- if got.Type != ports.MsgInput || string(got.Payload) != "typed" {
916- t.Fatalf("got=%+v", got)
917- }
918- eventually(t, time.Second, droppedAck.Load)
919- if got, ok := recvMaybe(b, 3*defaultResend); ok {
920- t.Fatalf("duplicate reliable frame delivered: %+v", got)
921- }
922-}
923-
924-func TestReliableInputRetransmitsUntilAck(t *testing.T) {
925- aPC, bPC := newPair()
926- var dropped atomic.Bool
927- aPC.drop = func(_ []byte, addr net.Addr) bool {
928- return addr.String() == "b" && dropped.CompareAndSwap(false, true)
929- }
930- a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
931- if err != nil {
932- t.Fatal(err)
933- }
934- b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
935- if err != nil {
936- t.Fatal(err)
937- }
938- defer func() {
939- if err := a.Close(); err != nil {
940- t.Errorf("close a: %v", err)
941- }
942- }()
943- defer func() {
944- if err := b.Close(); err != nil {
945- t.Errorf("close b: %v", err)
946- }
947- }()
948- if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil {
949- t.Fatal(err)
950- }
951- got := recvWithin(t, b, time.Second)
952- if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !dropped.Load() {
953- t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
954- }
955-}
956-
957-func TestAuthenticatedPeerChangeUpdatesPeerAndEmitsConnected(t *testing.T) {
958- aPC, bPC := newPair()
959- a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
960- if err != nil {
961- t.Fatal(err)
962- }
963- defer func() {
964- if err := a.Close(); err != nil {
965- t.Errorf("close a: %v", err)
966- }
967- }()
968- evilPC := &fakePC{addr: testAddr("evil"), in: make(chan packet, 100), peers: map[string]*fakePC{}}
969- aPC.peers["evil"] = evilPC
970- _ = bPC
971- old := a.Peer().String()
972- a.setLinkState(ports.LinkStateOffline, nil)
973- <-a.LinkEvents()
974- aPC.in <- packet{[]byte("not authenticated"), testAddr("evil")}
975- if a.Peer().String() != old {
976- t.Fatalf("unauthenticated packet changed peer to %v", a.Peer())
977- }
978- c, _ := pdgram.NewCodec(key())
979- rec := encodeData(7, true, ports.Frame{Type: ports.MsgPing})
980- frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU)
981- raw, _ := pdgram.MarshalFragment(frags[0])
982- aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")}
983- waitPeer(t, a, "evil")
984- select {
985- case ev := <-a.LinkEvents():
986- if ev.State != ports.LinkStateConnected {
987- t.Fatalf("event state=%v, want connected", ev.State)
988- }
989- case <-time.After(time.Second):
990- t.Fatal("missing connected event after authenticated rehome")
991- }
992- select {
993- case pkt := <-evilPC.in:
994- if pkt.addr.String() != "a" {
995- t.Fatalf("ack source=%v, want a", pkt.addr)
996- }
997- case <-time.After(time.Second):
998- t.Fatal("missing ACK to authenticated new peer")
999- }
1000-}
1001-
1002-func TestAuthenticatedFragmentSeparatesContactProgressAndPeerEligibility(t *testing.T) {
1003- aPC, bPC := newPair()
1004- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
1005- a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
1006- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
1007- })
1008- if err != nil {
1009- t.Fatal(err)
1010- }
1011- defer func() { _ = a.Close() }()
1012- defer func() { _ = bPC.Close() }()
1013-
1014- a.setLinkState(ports.LinkStateDegraded, nil)
1015- <-a.LinkEvents()
1016- codec, err := pdgram.NewCodec(key())
1017- if err != nil {
1018- t.Fatal(err)
1019- }
1020- authenticated := make(chan struct{}, 8)
1021- malformed := make(chan struct{}, 8)
1022- a.mu.Lock()
1023- a.afterAuthenticatedPacket = func() { authenticated <- struct{}{} }
1024- a.afterMalformedFragment = func() { malformed <- struct{}{} }
1025- initialPacket := a.health.lastPacket
1026- initialRecord := a.health.lastRecord
1027- a.mu.Unlock()
1028-
1029- sealFragment := func(counter uint64, frag pdgram.Fragment) []byte {
1030- t.Helper()
1031- raw, marshalErr := pdgram.MarshalFragment(frag)
1032- if marshalErr != nil {
1033- t.Fatal(marshalErr)
1034- }
1035- return codec.Seal(2, counter, raw, nil)
1036- }
1037- waitSignal := func(ch <-chan struct{}, what string) {
1038- t.Helper()
1039- select {
1040- case <-ch:
1041- case <-time.After(time.Second):
1042- t.Fatalf("timeout waiting for %s", what)
1043- }
1044- }
1045- assertHealth := func(wantPacket, wantRecord time.Time, wantPeer string, wantState ports.LinkState) {
1046- t.Helper()
1047- a.mu.Lock()
1048- gotPacket := a.health.lastPacket
1049- gotRecord := a.health.lastRecord
1050- a.mu.Unlock()
1051- if !gotPacket.Equal(wantPacket) || !gotRecord.Equal(wantRecord) {
1052- t.Fatalf("health packet/record=(%v,%v), want (%v,%v)", gotPacket, gotRecord, wantPacket, wantRecord)
1053- }
1054- if got := a.Peer().String(); got != wantPeer {
1055- t.Fatalf("peer=%s, want %s", got, wantPeer)
1056- }
1057- if got := a.LinkState(); got != wantState {
1058- t.Fatalf("state=%v, want %v", got, wantState)
1059- }
1060- }
1061-
1062- // Neither failed authentication nor an authenticated but invalid fragment
1063- // crosses the contact or roaming trust boundary.
1064- badAEAD := codec.Seal(2, 1, []byte("not a fragment"), nil)
1065- badAEAD[len(badAEAD)-1] ^= 0xff
1066- aPC.in <- packet{b: badAEAD, addr: testAddr("roam1")}
1067- aPC.in <- packet{b: codec.Seal(2, 2, []byte("not a fragment"), nil), addr: testAddr("roam1")}
1068- waitSignal(malformed, "malformed fragment")
1069- assertHealth(initialPacket, initialRecord, "b", ports.LinkStateDegraded)
1070-
1071- clk.advance(time.Second)
1072- partial := pdgram.Fragment{Seq: 100, Index: 0, Count: 2, Data: []byte("half")}
1073- acceptedPacket := clk.Now()
1074- acceptedRaw := sealFragment(10, partial)
1075- aPC.in <- packet{b: acceptedRaw, addr: testAddr("roam1")}
1076- waitSignal(authenticated, "accepted incomplete fragment")
1077- assertHealth(acceptedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
1078-
1079- // A replay and an Add-time fragment inconsistency cannot refresh contact or
1080- // migrate the peer. Reassembly diagnostics still update on the Add error.
1081- aPC.in <- packet{b: acceptedRaw, addr: testAddr("roam2")}
1082- aPC.in <- packet{b: codec.Seal(2, 11, []byte("still not a fragment"), nil), addr: testAddr("roam2")}
1083- waitSignal(malformed, "replay barrier")
1084- assertHealth(acceptedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
1085-
1086- aPC.in <- packet{b: sealFragment(12, pdgram.Fragment{Seq: 100, Index: 1, Count: 3, Data: []byte("mismatch")}), addr: testAddr("roam2")}
1087- waitSignal(malformed, "reassembly rejection")
1088- assertHealth(acceptedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
1089-
1090- // Fresh reordered packets refresh contact, but only the greatest accepted
1091- // AEAD counter is eligible to move the roaming peer. Contact alone must not
1092- // re-arm socket hopping while stream progress remains stalled.
1093- a.mu.Lock()
1094- a.hoppedOffline = true
1095- a.mu.Unlock()
1096- clk.advance(time.Second)
1097- reorderedPacket := clk.Now()
1098- aPC.in <- packet{b: sealFragment(9, pdgram.Fragment{Seq: 101, Index: 0, Count: 2, Data: []byte("older")}), addr: testAddr("roam2")}
1099- waitSignal(authenticated, "fresh reordered fragment")
1100- assertHealth(reorderedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
1101- a.mu.Lock()
1102- if !a.hoppedOffline {
1103- a.mu.Unlock()
1104- t.Fatal("fragment-only contact re-armed socket hopping")
1105- }
1106- a.mu.Unlock()
1107-
1108- clk.advance(time.Second)
1109- newestPacket := clk.Now()
1110- aPC.in <- packet{b: sealFragment(13, pdgram.Fragment{Seq: 102, Index: 0, Count: 2, Data: []byte("newer")}), addr: testAddr("roam2")}
1111- waitSignal(authenticated, "new greatest fragment")
1112- assertHealth(newestPacket, initialRecord, "roam2", ports.LinkStateDegraded)
1113-
1114- clk.advance(time.Second)
1115- completeAt := clk.Now()
1116- aPC.in <- packet{b: sealFragment(14, pdgram.Fragment{Seq: 103, Index: 0, Count: 1, Data: probeRecord(recPong, 999)}), addr: testAddr("roam2")}
1117- waitSignal(authenticated, "complete record")
1118- eventually(t, time.Second, func() bool { return a.LinkState() == ports.LinkStateConnected })
1119- assertHealth(completeAt, completeAt, "roam2", ports.LinkStateConnected)
1120-}
1121-
1122-func TestPortHopPreservesPendingReliableMessages(t *testing.T) {
1123- aPC, bPC := newPair()
1124- var hopped atomic.Bool
1125- aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
1126- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
1127- ResendAfter: 20 * time.Millisecond,
1128- ProbeAfter: time.Millisecond,
1129- DeadAfter: time.Hour,
1130- RebindPacketConn: func(old net.PacketConn) (net.PacketConn, error) {
1131- newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
1132- bPC.peers["a2"] = newPC
1133- hopped.Store(true)
1134- return newPC, nil
1135- },
1136- })
1137- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1138- defer func() { _ = a.Close() }()
1139- defer func() { _ = b.Close() }()
1140-
1141- if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("survive")}); err != nil {
1142- t.Fatal(err)
1143- }
1144- a.mu.Lock()
1145- a.health.lastPacket = time.Now().Add(-time.Second)
1146- a.mu.Unlock()
1147- a.checkSilence()
1148- if !hopped.Load() {
1149- t.Fatal("expected packet conn rebind")
1150- }
1151- aPC.drop = nil
1152- got := recvWithin(t, b, time.Second)
1153- if got.Type != ports.MsgInput || string(got.Payload) != "survive" {
1154- t.Fatalf("got=%+v, want pending reliable message", got)
1155- }
1156- select {
1157- case <-a.done:
1158- t.Fatal("transport closed during port hop")
1159- default:
1160- }
1161-}
1162-
1163-func TestPortHopRetriesAfterRebindFailure(t *testing.T) {
1164- aPC, bPC := newPair()
1165- var attempts atomic.Int32
1166- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
1167- ResendAfter: time.Hour,
1168- Heartbeat: time.Hour,
1169- ProbeAfter: time.Millisecond,
1170- DeadAfter: time.Hour,
1171- RebindPacketConn: func(old net.PacketConn) (net.PacketConn, error) {
1172- if attempts.Add(1) == 1 {
1173- return nil, errors.New("temporary rebind failure")
1174- }
1175- newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
1176- bPC.peers["a2"] = newPC
1177- return newPC, nil
1178- },
1179- })
1180- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1181- defer func() { _ = a.Close() }()
1182- defer func() { _ = b.Close() }()
1183-
1184- a.mu.Lock()
1185- a.health.lastPacket = time.Now().Add(-time.Second)
1186- a.mu.Unlock()
1187- a.checkSilence()
1188- a.mu.Lock()
1189- firstPC := a.pc
1190- a.mu.Unlock()
1191- if attempts.Load() != 1 || firstPC != aPC {
1192- t.Fatalf("first failed hop attempts=%d pc=%v, want one attempt and original pc", attempts.Load(), firstPC.LocalAddr())
1193- }
1194-
1195- a.checkSilence()
1196- a.mu.Lock()
1197- secondPC := a.pc
1198- a.mu.Unlock()
1199- if attempts.Load() != 2 || secondPC == aPC {
1200- t.Fatalf("second hop attempts=%d pc=%v, want retry with new pc", attempts.Load(), secondPC.LocalAddr())
1201- }
1202-}
1203-
1204-func TestServerWithoutRebindDoesNotHopPorts(t *testing.T) {
1205- aPC, bPC := newPair()
1206- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ProbeAfter: time.Millisecond, DeadAfter: time.Hour})
1207- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1208- defer func() { _ = a.Close() }()
1209- defer func() { _ = b.Close() }()
1210- a.mu.Lock()
1211- a.health.lastPacket = time.Now().Add(-time.Second)
1212- a.mu.Unlock()
1213- a.checkSilence()
1214- if a.pc != aPC {
1215- t.Fatal("server/proxy transport hopped without rebind hook")
1216- }
1217-}
1218-
1219-func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) {
1220- tr := &Transport{maxRecvBuffer: 1, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
1221- _, ack, queued := tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("future")})
1222- if !ack || !queued {
1223- t.Fatalf("future enqueue ack=%v queued=%v, want true/true", ack, queued)
1224- }
1225- _, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})
1226- if !ack || !queued {
1227- t.Fatalf("next enqueue ack=%v queued=%v, want true/true", ack, queued)
1228- }
1229- if _, ok := tr.recvBuf[2]; !ok {
1230- t.Fatal("acked future frame was dropped when next frame arrived")
1231- }
1232-}
1233-
1234-func TestDataRecordWireEncoding(t *testing.T) {
1235- got := encodeData(5, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("x")})
1236- want := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(ports.MsgOutput), 0, 'x'}
1237- if !bytes.Equal(got, want) {
1238- t.Fatalf("encoded data=%v, want %v", got, want)
1239- }
1240- seq, reliable, frame, ok := decodeData(got)
1241- if !ok || seq != 5 || !reliable || frame.Type != ports.MsgOutput || string(frame.Payload) != "x" {
1242- t.Fatalf("decoded seq=%d reliable=%v frame=%+v ok=%v", seq, reliable, frame, ok)
1243- }
1244-}
1245-
1246-func TestDataRecordWireDecodeRejectsUnknownFlags(t *testing.T) {
1247- withTrailingGarbage := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(ports.MsgOutput), 1, 'x'}
1248- if _, _, _, ok := decodeData(withTrailingGarbage); ok {
1249- t.Fatal("decodeData accepted unknown data-record flags with trailing garbage")
1250- }
1251-}
1252-
1253-func TestOutputRetransmitsUntilAck(t *testing.T) {
1254- aPC, bPC := newPair()
1255- var dropped atomic.Bool
1256- aPC.drop = func(_ []byte, addr net.Addr) bool {
1257- return addr.String() == "b" && dropped.CompareAndSwap(false, true)
1258- }
1259- a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
1260- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1261- defer func() {
1262- if err := a.Close(); err != nil {
1263- t.Errorf("close a: %v", err)
1264- }
1265- }()
1266- defer func() {
1267- if err := b.Close(); err != nil {
1268- t.Errorf("close b: %v", err)
1269- }
1270- }()
1271- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state1")}); err != nil {
1272- t.Fatal(err)
1273- }
1274- got := recvWithin(t, b, time.Second)
1275- if got.Type != ports.MsgOutput || string(got.Payload) != "state1" || !dropped.Load() {
1276- t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
1277- }
1278-}
1279-
1280-func TestImmediateLoopbackAckSamplesStampedFinalWrite(t *testing.T) {
1281- aPC, bPC := newPair()
1282- a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
1283- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1284- defer func() { _ = a.Close() }()
1285- defer func() { _ = b.Close() }()
1286- aPC.afterWrite = func() {
1287- deadline := time.Now().Add(time.Second)
1288- for time.Now().Before(deadline) {
1289- a.mu.Lock()
1290- _, pending := a.pending[1]
1291- a.mu.Unlock()
1292- if !pending {
1293- return
1294- }
1295- time.Sleep(time.Microsecond)
1296- }
1297- t.Error("ACK did not retire pending frame before WriteTo returned")
1298- }
1299-
1300- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state")}); err != nil {
1301- t.Fatal(err)
1302- }
1303- eventually(t, time.Second, func() bool {
1304- a.mu.Lock()
1305- defer a.mu.Unlock()
1306- return a.srtt > 0
1307- })
1308- a.mu.Lock()
1309- srtt, rto := a.srtt, a.rto
1310- a.mu.Unlock()
1311- if srtt <= 0 || srtt > time.Second {
1312- t.Fatalf("srtt=%v, want finite immediate-loopback sample", srtt)
1313- }
1314- if rto < a.resendAfter || rto > a.maxResendAfter {
1315- t.Fatalf("rto=%v outside [%v,%v]", rto, a.resendAfter, a.maxResendAfter)
1316- }
1317-}
1318-
1319-func TestReliableDeliveryPreservesInputSequenceOrder(t *testing.T) {
1320- aPC, bPC := newPair()
1321- a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
1322- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1323- defer func() { _ = a.Close() }()
1324- defer func() { _ = b.Close() }()
1325-
1326- for i := range 100 {
1327- if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte{byte(i)}}); err != nil {
1328- t.Fatal(err)
1329- }
1330- }
1331- for i := range 100 {
1332- got := recvWithin(t, b, time.Second)
1333- if got.Type != ports.MsgInput || len(got.Payload) != 1 || got.Payload[0] != byte(i) {
1334- t.Fatalf("frame %d delivered out of order: %+v", i, got)
1335- }
1336- }
1337-}
1338-
1339-func TestOutputDependencyChainRetransmitsDroppedPredecessors(t *testing.T) {
1340- aPC, bPC := newPair()
1341- var sendAttempts atomic.Int32
1342- aPC.drop = func(_ []byte, addr net.Addr) bool {
1343- return addr.String() == "b" && sendAttempts.Add(1) <= 2
1344- }
1345- a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
1346- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1347- defer func() { _ = a.Close() }()
1348- defer func() { _ = b.Close() }()
1349-
1350- for _, payload := range []string{"state1", "state2", "state3"} {
1351- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte(payload)}); err != nil {
1352- t.Fatal(err)
1353- }
1354- }
1355-
1356- for _, want := range []string{"state1", "state2", "state3"} {
1357- got := recvWithin(t, b, time.Second)
1358- if got.Type != ports.MsgOutput || string(got.Payload) != want {
1359- t.Fatalf("got=%+v, want ordered prerequisite output %q", got, want)
1360- }
1361- }
1362- if sendAttempts.Load() < 3 {
1363- t.Fatalf("send attempts=%d, want first two output sends dropped before newest arrived", sendAttempts.Load())
1364- }
1365-}
1366-
1367-func TestAckProcessingContinuesWhenConsumerBackpressured(t *testing.T) {
1368- aPC, bPC := newPair()
1369- a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
1370- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1371- defer func() { _ = a.Close() }()
1372- defer func() { _ = b.Close() }()
1373-
1374- for i := range cap(b.in) + 20 {
1375- if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
1376- t.Fatal(err)
1377- }
1378- }
1379- deadline := time.Now().Add(time.Second)
1380- for time.Now().Before(deadline) {
1381- a.mu.Lock()
1382- pending := len(a.pending)
1383- a.mu.Unlock()
1384- if pending == 0 {
1385- return
1386- }
1387- time.Sleep(10 * time.Millisecond)
1388- }
1389- a.mu.Lock()
1390- pending := len(a.pending)
1391- a.mu.Unlock()
1392- t.Fatalf("acks blocked behind undrained consumer; pending=%d", pending)
1393-}
1394-
1395-func newResendTestTransport(t *testing.T, maxResendPerTick int) (*Transport, *atomic.Int32) {
1396- t.Helper()
1397- aPC, _ := newPair()
1398- codec, err := pdgram.NewCodec(key())
1399- if err != nil {
1400- t.Fatal(err)
1401- }
1402- var writes atomic.Int32
1403- aPC.drop = func(_ []byte, addr net.Addr) bool {
1404- if addr.String() == "b" {
1405- writes.Add(1)
1406- return true
1407- }
1408- return false
1409- }
1410- return &Transport{
1411- pc: aPC,
1412- peer: testAddr("b"),
1413- codec: codec,
1414- sendDir: 1,
1415- mtu: pdgram.DefaultMTU,
1416- pending: make(map[uint64]*pending),
1417- resendAfter: 100 * time.Millisecond,
1418- maxResendAfter: 500 * time.Millisecond,
1419- maxResendPerTick: maxResendPerTick,
1420- rto: 100 * time.Millisecond,
1421- clock: realClock{},
1422- congestion: newCongestionController(pdgram.DefaultMTU),
1423- done: make(chan struct{}),
1424- }, &writes
1425-}
1426-
1427-func TestResendPendingCapsBurst(t *testing.T) {
1428- tr, writes := newResendTestTransport(t, 2)
1429- now := tr.clock.Now()
1430- for i := range 5 {
1431- tr.pending[uint64(i+1)] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}, last: now.Add(-time.Second)}
1432- }
1433-
1434- tr.resendPending()
1435- if got := writes.Load(); got != 2 {
1436- t.Fatalf("resend writes=%d, want capped burst of 2", got)
1437- }
1438-}
1439-
1440-func TestRetransmitBatchReducesCongestionWindowOnce(t *testing.T) {
1441- tr, writes := newResendTestTransport(t, 10)
1442- now := tr.clock.Now()
1443- for seq := uint64(1); seq <= 3; seq++ {
1444- tr.pending[seq] = &pending{
1445- frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(seq)}},
1446- last: now.Add(-time.Second),
1447- }
1448- }
1449- initialCwnd := tr.congestion.cwndBytes
1450-
1451- tr.resendPending()
1452-
1453- if got := writes.Load(); got != 3 {
1454- t.Fatalf("retransmit writes=%d, want 3", got)
1455- }
1456- if got, want := tr.congestion.cwndBytes, initialCwnd/2; got != want {
1457- t.Fatalf("cwnd after one retransmit batch=%d, want %d", got, want)
1458- }
1459-}
1460-
1461-func TestACKBeforeQueuedRetransmitDoesNotReduceCongestionWindow(t *testing.T) {
1462- tr, writes := newResendTestTransport(t, 1)
1463- now := tr.clock.Now()
1464- tr.pending[1] = &pending{
1465- frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("acked")},
1466- first: now.Add(-time.Second),
1467- last: now.Add(-time.Second),
1468- wireBytes: tr.mtu,
1469- }
1470- initialCwnd := tr.congestion.cwndBytes
1471- tr.mu.Lock()
1472- batch, _ := tr.selectRetransmitsLocked(now)
1473- tr.mu.Unlock()
1474- if len(batch) != 1 {
1475- t.Fatalf("selected retransmits=%d, want 1", len(batch))
1476- }
1477- tr.handleRecord(ackRecord(1))
1478- cwndAfterACK := initialCwnd + max(1, tr.mtu*tr.mtu/initialCwnd)
1479- counterAfterACK := tr.ctr
1480-
1481- tr.runRetransmitBatch(&bytePacer{clk: tr.clock}, batch)
1482-
1483- if got := tr.ctr; got != counterAfterACK {
1484- t.Fatalf("packet counter after ACK=%d, want unchanged %d", got, counterAfterACK)
1485- }
1486- if got := writes.Load(); got != 0 {
1487- t.Fatalf("writes after ACK=%d, want 0", got)
1488- }
1489- if got := tr.congestion.cwndBytes; got != cwndAfterACK {
1490- t.Fatalf("cwnd after ACK race=%d, want ACK-only growth %d", got, cwndAfterACK)
1491- }
1492-}
1493-
1494-func TestOversizedRetransmitHonorsPacketBudget(t *testing.T) {
1495- tr, writes := newResendTestTransport(t, 1)
1496- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
1497- tr.clock = clk
1498- tr.done = make(chan struct{})
1499- tr.pending[1] = &pending{
1500- frame: ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 24*tr.mtu)},
1501- last: clk.now.Add(-time.Second),
1502- }
1503- done := make(chan struct{})
1504- go func() {
1505- tr.resendPending()
1506- close(done)
1507- }()
1508-
1509- previous := int32(0)
1510- for tick := 0; tick < 128; tick++ {
1511- clk.advance(initialPacingRTT)
1512- time.Sleep(time.Millisecond)
1513- current := writes.Load()
1514- if delta := current - previous; delta > dataBurstPackets {
1515- t.Fatalf("tick %d retransmitted %d packets, burst %d", tick, delta, dataBurstPackets)
1516- }
1517- previous = current
1518- select {
1519- case <-done:
1520- return
1521- default:
1522- }
1523- }
1524- t.Fatal("oversized retransmit did not complete within pacing ticks")
1525-}
1526-
1527-func TestResendPendingUsesExponentialBackoffAndMaxDelay(t *testing.T) {
1528- tr, writes := newResendTestTransport(t, 10)
1529- now := tr.clock.Now()
1530- tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("too-soon")}, last: now.Add(-150 * time.Millisecond), attempts: 1}
1531- tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("ready")}, last: now.Add(-250 * time.Millisecond), attempts: 1}
1532- tr.pending[3] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("capped")}, last: now.Add(-time.Second), attempts: 10}
1533-
1534- tr.resendPending()
1535- if got := writes.Load(); got != 2 {
1536- t.Fatalf("resend writes=%d, want ready and capped frames only", got)
1537- }
1538- if attempts := tr.pending[1].attempts; attempts != 1 {
1539- t.Fatalf("too-soon attempts=%d, want unchanged", attempts)
1540- }
1541- if attempts := tr.pending[2].attempts; attempts != 2 {
1542- t.Fatalf("ready attempts=%d, want incremented", attempts)
1543- }
1544- if attempts := tr.pending[3].attempts; attempts != 11 {
1545- t.Fatalf("capped attempts=%d, want incremented", attempts)
1546- }
1547-}
1548-
1549-func TestProbeTimeoutAndSuccess(t *testing.T) {
1550- aPC, bPC := newPair()
1551- a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
1552- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1553- defer func() { _ = a.Close() }()
1554- defer func() { _ = b.Close() }()
1555-
1556- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
1557- if err := a.Probe(ctx); err != nil {
1558- cancel()
1559- t.Fatalf("probe success: %v", err)
1560- }
1561- cancel()
1562-
1563- bPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "a" }
1564- ctx, cancel = context.WithTimeout(context.Background(), 50*time.Millisecond)
1565- defer cancel()
1566- if err := a.Probe(ctx); !errors.Is(err, context.DeadlineExceeded) {
1567- t.Fatalf("probe timeout err=%v, want context deadline", err)
1568- }
1569-}
1570-
1571-func TestRecvUnblocksWhenPeerDeadAfter(t *testing.T) {
1572- aPC, bPC := newPair()
1573- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: 25 * time.Millisecond, DeadAfter: 75 * time.Millisecond})
1574- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1575- defer func() { _ = a.Close() }()
1576- defer func() { _ = b.Close() }()
1577- a.mu.Lock()
1578- a.health.lastPacket = time.Now().Add(-time.Second)
1579- a.mu.Unlock()
1580-
1581- _, err := recvErrWithin(t, a, time.Second)
1582- if !errors.Is(err, ErrLinkDead) {
1583- t.Fatalf("Recv err=%v, want ErrLinkDead", err)
1584- }
1585-}
1586-
1587-func TestCumulativeAckReleasesAllEarlierPendingFrames(t *testing.T) {
1588- tr, _ := newResendTestTransport(t, 10)
1589- now := tr.clock.Now()
1590- for seq := uint64(1); seq <= 3; seq++ {
1591- tr.pending[seq] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(seq)}}, first: now, last: now}
1592- }
1593-
1594- tr.handleRecord(ackRecord(3))
1595-
1596- if got := len(tr.pending); got != 0 {
1597- t.Fatalf("pending after cumulative ack=%d, want 0", got)
1598- }
1599-}
1600-
1601-func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) {
1602- tr := &Transport{maxRecvBuffer: maxRecvBuffer, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
1603-
1604- ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("third")})
1605- if !ack || !queued {
1606- t.Fatalf("out-of-order enqueue ack=%v queued=%v, want true/true", ack, queued)
1607- }
1608- if got, want := ackSeq, uint64(0); got != want {
1609- t.Fatalf("out-of-order ack=%d, want highest contiguous %d", got, want)
1610- }
1611-
1612- ackSeq, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("first")})
1613- if !ack || !queued {
1614- t.Fatalf("first enqueue ack=%v queued=%v, want true/true", ack, queued)
1615- }
1616- if got, want := ackSeq, uint64(1); got != want {
1617- t.Fatalf("first contiguous ack=%d, want %d", got, want)
1618- }
1619-
1620- ackSeq, ack, queued = tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")})
1621- if !ack || !queued {
1622- t.Fatalf("gap fill enqueue ack=%v queued=%v, want true/true", ack, queued)
1623- }
1624- if got, want := ackSeq, uint64(3); got != want {
1625- t.Fatalf("filled gap ack=%d, want %d", got, want)
1626- }
1627-}
1628-
1629-func TestRTTEstimatorUpdatesRTOFromAckSample(t *testing.T) {
1630- clk := fixedClock{now: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)}
1631- tr, writes := newResendTestTransport(t, 10)
1632- tr.clock = clk
1633- tr.resendAfter = 100 * time.Millisecond
1634- tr.maxResendAfter = time.Second
1635- tr.rto = tr.resendAfter
1636- tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: clk.now.Add(-200 * time.Millisecond), last: clk.now.Add(-200 * time.Millisecond)}
1637-
1638- tr.handleRecord(ackRecord(1))
1639-
1640- if tr.rto <= tr.resendAfter {
1641- t.Fatalf("rto=%v, want above initial resend after RTT sample", tr.rto)
1642- }
1643- tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: clk.now, last: clk.now.Add(-(tr.rto - time.Millisecond))}
1644- tr.resendPending()
1645- if got := writes.Load(); got != 0 {
1646- t.Fatalf("writes before rto=%d, want 0", got)
1647- }
1648- tr.pending[2].last = clk.now.Add(-tr.rto)
1649- tr.resendPending()
1650- if got := writes.Load(); got != 1 {
1651- t.Fatalf("writes at rto=%d, want 1", got)
1652- }
1653-}
1654-
1655-func TestRTTEstimatorIgnoresRetransmittedFramesAfterRecovery(t *testing.T) {
1656- clk := fixedClock{now: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)}
1657- tr, _ := newResendTestTransport(t, 10)
1658- tr.clock = clk
1659- tr.resendAfter = 100 * time.Millisecond
1660- tr.maxResendAfter = time.Second
1661- tr.rto = tr.resendAfter
1662- tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: clk.now.Add(-200 * time.Millisecond), last: clk.now.Add(-tr.resendAfter)}
1663- tr.resendPending()
1664- tr.linkState = ports.LinkStateOffline
1665-
1666- tr.setLinkState(ports.LinkStateConnected, nil)
1667- tr.handleRecord(ackRecord(1))
1668-
1669- if tr.rto != tr.resendAfter || tr.srtt != 0 || tr.rttvar != 0 {
1670- t.Fatalf("rtt sampled retransmitted frame after recovery: rto=%v srtt=%v rttvar=%v", tr.rto, tr.srtt, tr.rttvar)
1671- }
1672-}
1673-
1674-func TestPendingReliableQueueBackpressuresUntilAck(t *testing.T) {
1675- aPC, bPC := newPair()
1676- aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
1677- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1})
1678- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1679- defer func() { _ = a.Close() }()
1680- defer func() { _ = b.Close() }()
1681-
1682- if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
1683- t.Fatal(err)
1684- }
1685- done := make(chan error, 1)
1686- go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
1687-
1688- select {
1689- case err := <-done:
1690- t.Fatalf("send returned before an ack released pending capacity: %v", err)
1691- case <-time.After(25 * time.Millisecond):
1692- }
1693-
1694- a.handleRecord(ackRecord(1))
1695- select {
1696- case err := <-done:
1697- if err != nil {
1698- t.Fatalf("send after ack: %v", err)
1699- }
1700- case <-time.After(time.Second):
1701- t.Fatal("send did not resume after ack released pending capacity")
1702- }
1703- a.mu.Lock()
1704- pending := len(a.pending)
1705- a.mu.Unlock()
1706- if pending > 1 {
1707- t.Fatalf("pending=%d, want <= 1", pending)
1708- }
1709-}
1710-
1711-func TestPendingReliableQueueReturnsErrPendingFullWhileConnected(t *testing.T) {
1712- aPC, bPC := newPair()
1713- aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
1714- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
1715- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1, MaxPendingWait: 50 * time.Millisecond, Clock: clk})
1716- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1717- defer func() { _ = a.Close() }()
1718- defer func() { _ = b.Close() }()
1719-
1720- if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
1721- t.Fatal(err)
1722- }
1723- done := make(chan error, 1)
1724- go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
1725- waitForManualTimers(t, clk, 4)
1726-
1727- clk.advance(50 * time.Millisecond)
1728- select {
1729- case err := <-done:
1730- if !errors.Is(err, ErrPendingFull) {
1731- t.Fatalf("send err=%v, want ErrPendingFull", err)
1732- }
1733- case <-time.After(time.Second):
1734- t.Fatal("send did not return after clock advanced past MaxPendingWait")
1735- }
1736-}
1737-
1738-func TestPendingAttemptsResetWhenLinkRecovers(t *testing.T) {
1739- tr, _ := newResendTestTransport(t, 10)
1740- tr.linkState = ports.LinkStateDegraded
1741- tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stale")}, attempts: 4}
1742- tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stale")}, attempts: 7}
1743-
1744- tr.setLinkState(ports.LinkStateConnected, nil)
1745-
1746- for seq, p := range tr.pending {
1747- if p.attempts != 0 {
1748- t.Fatalf("pending[%d] attempts=%d, want reset on connected recovery", seq, p.attempts)
1749- }
1750- }
1751-}
1752-
1753-func TestWritePacketDeadlineUsesTransportClock(t *testing.T) {
1754- wantNow := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
1755- pc := &deadlineCapturePC{addr: testAddr("a"), done: make(chan struct{})}
1756- tr, err := NewTransportWithOptions(pc, testAddr("b"), key(), 1, 2, Options{
1757- Clock: fixedClock{now: wantNow},
1758- WriteTimeout: 250 * time.Millisecond,
1759- ResendAfter: time.Hour,
1760- Heartbeat: time.Hour,
1761- })
1762- if err != nil {
1763- t.Fatal(err)
1764- }
1765- defer func() { _ = tr.Close() }()
1766-
1767- if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("deadline")}); err != nil {
1768- t.Fatal(err)
1769- }
1770- got, _ := pc.deadline.Load().(time.Time)
1771- if want := wantNow.Add(250 * time.Millisecond); !got.Equal(want) {
1772- t.Fatalf("write deadline=%v, want transport clock deadline %v", got, want)
1773- }
1774-}
1775-
1776-func TestProbeReplyDoesNotBlockOnWriteMu(t *testing.T) {
1777- aPC, bPC := newPair()
1778- tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: time.Hour, Heartbeat: time.Hour})
1779- if err != nil {
1780- t.Fatal(err)
1781- }
1782- defer func() { _ = tr.Close() }()
1783- defer func() { _ = bPC.Close() }()
1784-
1785- tr.writeMu.Lock()
1786- done := make(chan struct{})
1787- go func() {
1788- for id := uint64(1); id <= probeReplyBufferSize*4; id++ {
1789- tr.handleRecord(probeRecord(recProbe, id))
1790- }
1791- close(done)
1792- }()
1793-
1794- select {
1795- case <-done:
1796- case <-time.After(time.Second):
1797- tr.writeMu.Unlock()
1798- <-done
1799- t.Fatal("probe reply burst blocked behind writeMu")
1800- }
1801- tr.writeMu.Unlock()
1802-}
1803-
1804-func TestPendingReliableQueueReturnsWhenLinkDegrades(t *testing.T) {
1805- aPC, bPC := newPair()
1806- aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
1807- clk := newManualClock(time.Now())
1808- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1, MaxPendingWait: time.Hour, Clock: clk})
1809- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1810- defer func() { _ = a.Close() }()
1811- defer func() { _ = b.Close() }()
1812-
1813- if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
1814- t.Fatal(err)
1815- }
1816- done := make(chan error, 2)
1817- go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
1818- go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("third")}) }()
1819- waitForManualTimers(t, clk, 2)
1820- a.setLinkState(ports.LinkStateDegraded, nil)
1821- for i := range 2 {
1822- select {
1823- case err := <-done:
1824- if !errors.Is(err, ErrPendingFull) {
1825- t.Fatalf("send %d err=%v, want ErrPendingFull", i, err)
1826- }
1827- case <-time.After(time.Second):
1828- t.Fatalf("send %d did not unblock after link degraded", i)
1829- }
1830- }
1831-}
1832-
1833-func ackRecord(seq uint64) []byte {
1834- var b [9]byte
1835- b[0] = recAck
1836- binary.BigEndian.PutUint64(b[1:], seq)
1837- return b[:]
1838-}
1839-
1840-func probeRecord(kind byte, id uint64) []byte {
1841- var b [9]byte
1842- b[0] = kind
1843- binary.BigEndian.PutUint64(b[1:], id)
1844- return b[:]
1845-}
1846-
1847-func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) {
1848- tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, done: make(chan struct{})}
1849- tr.deliverCond = sync.NewCond(&tr.deliverMu)
1850- for i := range maxRecvBuffer + 100 {
1851- tr.enqueueReliable(uint64(1000+i), ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}})
1852- }
1853- if got := len(tr.recvBuf); got != maxRecvBuffer {
1854- t.Fatalf("recvBuf len=%d, want %d", got, maxRecvBuffer)
1855- }
1856- tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})
1857- if _, ok := tr.recvBuf[1]; !ok {
1858- t.Fatalf("next expected sequence was dropped when far-future buffer was full")
1859- }
1860- if got := len(tr.recvBuf); got > maxRecvBuffer+1 {
1861- t.Fatalf("recvBuf len=%d, want <= %d", got, maxRecvBuffer+1)
1862- }
1863-}
1864-
1865-func TestOutputPrerequisitesRemainInFullReceiveBuffer(t *testing.T) {
1866- tr := &Transport{
1867- nextRecvSeq: 1,
1868- recvBuf: map[uint64]ports.Frame{2: {Type: ports.MsgOutput, Payload: []byte("stale")}},
1869- maxRecvBuffer: 1,
1870- done: make(chan struct{}),
1871- }
1872- tr.deliverCond = sync.NewCond(&tr.deliverMu)
1873-
1874- ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("replacement")})
1875- if ack || queued || ackSeq != 0 {
1876- t.Fatalf("replacement ackSeq=%d ack=%v queued=%v, want output rejected while prerequisite buffer is full", ackSeq, ack, queued)
1877- }
1878- if _, ok := tr.recvBuf[2]; !ok {
1879- t.Fatal("prerequisite output was discarded from full receive buffer")
1880- }
1881- if _, ok := tr.recvBuf[3]; ok {
1882- t.Fatal("far-future replacement bypassed full receive buffer")
1883- }
1884-}
1885-
1886-func TestReliableFullRecvBufferDoesNotAckDroppedFarFutureFrame(t *testing.T) {
1887- aPC, bPC := newPair()
1888- codec, err := pdgram.NewCodec(key())
1889- if err != nil {
1890- t.Fatal(err)
1891- }
1892- tr := &Transport{pc: aPC, peer: testAddr("b"), codec: codec, sendDir: 1, mtu: pdgram.DefaultMTU, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, clock: realClock{}, done: make(chan struct{})}
1893- tr.deliverCond = sync.NewCond(&tr.deliverMu)
1894- defer func() { _ = aPC.Close() }()
1895- defer func() { _ = bPC.Close() }()
1896-
1897- for i := range maxRecvBuffer {
1898- tr.recvBuf[uint64(1000+i)] = ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}
1899- }
1900- tr.handleRecord(encodeData(9999, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("dropped")}))
1901- select {
1902- case pkt := <-bPC.in:
1903- t.Fatalf("unexpected ACK packet for dropped frame: %x", pkt.b)
1904- case <-time.After(25 * time.Millisecond):
1905- }
1906-
1907- tr.handleRecord(encodeData(1, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}))
1908- select {
1909- case <-bPC.in:
1910- case <-time.After(time.Second):
1911- t.Fatal("expected ACK for buffered contiguous frame")
1912- }
1913-}
1914-
1915-func recvMaybe(tr *Transport, d time.Duration) (ports.Frame, bool) {
1916- ch := make(chan ports.Frame, 1)
1917- go func() { f, _ := tr.Recv(); ch <- f }()
1918- select {
1919- case f := <-ch:
1920- return f, true
1921- case <-time.After(d):
1922- return ports.Frame{}, false
1923- }
1924-}
1925-
1926-func recvWithin(t *testing.T, tr *Transport, d time.Duration) ports.Frame {
1927- t.Helper()
1928- ch := make(chan ports.Frame, 1)
1929- go func() { f, _ := tr.Recv(); ch <- f }()
1930- select {
1931- case f := <-ch:
1932- return f
1933- case <-time.After(d):
1934- t.Fatal("timeout")
1935- return ports.Frame{}
1936- }
1937-}
1938-
1939-func waitPeer(t *testing.T, tr *Transport, want string) {
1940- t.Helper()
1941- deadline := time.Now().Add(time.Second)
1942- for time.Now().Before(deadline) {
1943- if tr.Peer().String() == want {
1944- return
1945- }
1946- time.Sleep(time.Millisecond)
1947- }
1948- t.Fatalf("peer=%v, want %s", tr.Peer(), want)
1949-}
1950-
1951-func recvErrWithin(t *testing.T, tr *Transport, d time.Duration) (ports.Frame, error) {
1952- t.Helper()
1953- type result struct {
1954- f ports.Frame
1955- err error
1956- }
1957- ch := make(chan result, 1)
1958- go func() {
1959- f, err := tr.Recv()
1960- ch <- result{f: f, err: err}
1961- }()
1962- select {
1963- case r := <-ch:
1964- return r.f, r.err
1965- case <-time.After(d):
1966- t.Fatal("timeout")
1967- return ports.Frame{}, nil
1968- }
1969-}
1970-
1971-func TestLinkStateTransitionsFromConfigurableSilence(t *testing.T) {
1972- aPC, bPC := newPair()
1973- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{DegradedAfter: time.Second, ProbeAfter: 2 * time.Second, OfflineAfter: 3 * time.Second, DeadAfter: 4 * time.Second})
1974- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
1975- defer func() { _ = a.Close() }()
1976- defer func() { _ = b.Close() }()
1977-
1978- tests := []struct {
1979- name string
1980- age time.Duration
1981- want ports.LinkState
1982- }{
1983- {"degraded", time.Second, ports.LinkStateDegraded},
1984- {"probing", 2 * time.Second, ports.LinkStateProbing},
1985- {"offline", 3 * time.Second, ports.LinkStateOffline},
1986- }
1987- for _, tt := range tests {
1988- t.Run(tt.name, func(t *testing.T) {
1989- a.mu.Lock()
1990- staleAt := time.Now().Add(-tt.age)
1991- a.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
1992- a.mu.Unlock()
1993- a.checkSilence()
1994- if got := a.LinkState(); got != tt.want {
1995- t.Fatalf("LinkState()=%v, want %v", got, tt.want)
1996- }
1997- select {
1998- case ev := <-a.LinkEvents():
1999- if ev.State != tt.want {
2000- t.Fatalf("event state=%v, want %v", ev.State, tt.want)
2001- }
2002- default:
2003- t.Fatalf("missing link event for %v", tt.want)
2004- }
2005- })
2006- }
2007-}
2008-
2009-func TestLinkHealthSeparatesPacketContactFromUsefulProgress(t *testing.T) {
2010- now := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
2011- threshold := func(seconds int) time.Duration { return time.Duration(seconds) * time.Second }
2012- tests := []struct {
2013- name string
2014- packetAge time.Duration
2015- recordAge time.Duration
2016- ackAge time.Duration
2017- hasPending bool
2018- wantState ports.LinkState
2019- wantHop bool
2020- wantDead bool
2021- }{
2022- {name: "fresh contact without useful progress degrades", packetAge: 0, recordAge: threshold(10), ackAge: threshold(10), wantState: ports.LinkStateDegraded},
2023- {name: "pending round trip stall probes and hops", packetAge: 0, recordAge: threshold(20), ackAge: threshold(20), hasPending: true, wantState: ports.LinkStateProbing, wantHop: true},
2024- {name: "fragment-only contact never becomes offline", packetAge: 0, recordAge: threshold(30), ackAge: threshold(30), wantState: ports.LinkStateDegraded},
2025- {name: "packet silence probes and hops", packetAge: threshold(20), recordAge: threshold(20), ackAge: threshold(20), wantState: ports.LinkStateProbing, wantHop: true},
2026- {name: "packet silence becomes offline", packetAge: threshold(30), recordAge: threshold(30), ackAge: threshold(30), wantState: ports.LinkStateOffline},
2027- {name: "packet silence becomes dead", packetAge: threshold(60), recordAge: threshold(60), ackAge: threshold(60), wantState: ports.LinkStateDead, wantDead: true},
2028- {name: "complete record cannot mask pending stall", packetAge: 0, recordAge: 0, ackAge: threshold(30), hasPending: true, wantState: ports.LinkStateProbing, wantHop: true},
2029- {name: "complete record restores connected without pending data", packetAge: 0, recordAge: 0, ackAge: threshold(30), wantState: ports.LinkStateConnected},
2030- {name: "ack progress restores connected", packetAge: 0, recordAge: threshold(30), ackAge: 0, hasPending: true, wantState: ports.LinkStateConnected},
2031- }
2032-
2033- for _, tt := range tests {
2034- t.Run(tt.name, func(t *testing.T) {
2035- health := healthTracker{
2036- lastPacket: now.Add(-tt.packetAge),
2037- lastRecord: now.Add(-tt.recordAge),
2038- lastProgress: now.Add(-tt.ackAge),
2039- }
2040- state, hop, dead := health.decide(now, tt.hasPending, threshold(10), threshold(20), threshold(30), threshold(60))
2041- if state != tt.wantState || hop != tt.wantHop || dead != tt.wantDead {
2042- t.Fatalf("decide()=(%v,%v,%v), want (%v,%v,%v)", state, hop, dead, tt.wantState, tt.wantHop, tt.wantDead)
2043- }
2044- })
2045- }
2046-}
2047-
2048-func TestLinkHealthPendingStallWithFreshContactProbesOnceWithoutDying(t *testing.T) {
2049- aPC, bPC := newPair()
2050- var hops atomic.Int32
2051- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
2052- ResendAfter: time.Hour, Heartbeat: time.Hour, ProbeAfter: 20 * time.Second, DeadAfter: 60 * time.Second,
2053- RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
2054- hops.Add(1)
2055- return &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}, nil
2056- },
2057- })
2058- defer func() { _ = a.Close() }()
2059- defer func() { _ = bPC.Close() }()
2060-
2061- now := time.Now()
2062- a.mu.Lock()
2063- a.health = healthTracker{lastPacket: now, lastRecord: now.Add(-time.Minute), lastProgress: now.Add(-time.Minute)}
2064- a.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, enqueued: now.Add(-time.Minute), attempts: 3}
2065- a.mu.Unlock()
2066-
2067- a.checkSilence()
2068- a.checkSilence()
2069- if got := a.LinkState(); got != ports.LinkStateProbing {
2070- t.Fatalf("state=%v, want probing", got)
2071- }
2072- if got := hops.Load(); got != 1 {
2073- t.Fatalf("socket hops=%d, want one", got)
2074- }
2075- select {
2076- case <-a.done:
2077- t.Fatal("fresh authenticated contact must prevent pending-stall death")
2078- default:
2079- }
2080-}
2081-
2082-func TestLinkHealthHeartbeatControlCannotMaskPendingACKStall(t *testing.T) {
2083- aPC, bPC := newPair()
2084- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
2085- var hops atomic.Int32
2086- tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
2087- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
2088- RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
2089- attempt := hops.Add(1)
2090- return &fakePC{addr: testAddr(fmt.Sprintf("a%d", attempt+1)), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}, nil
2091- },
2092- })
2093- if err != nil {
2094- t.Fatal(err)
2095- }
2096- defer func() { _ = tr.Close() }()
2097- defer func() { _ = bPC.Close() }()
2098- if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("unacked")}); err != nil {
2099- t.Fatal(err)
2100- }
2101-
2102- codec, err := pdgram.NewCodec(key())
2103- if err != nil {
2104- t.Fatal(err)
2105- }
2106- authenticated := make(chan struct{}, 32)
2107- tr.mu.Lock()
2108- tr.afterAuthenticatedPacket = func() { authenticated <- struct{}{} }
2109- tr.mu.Unlock()
2110- var counter uint64
2111- sendHeartbeatPong := func() {
2112- t.Helper()
2113- counter++
2114- frag := pdgram.Fragment{Seq: counter, Index: 0, Count: 1, Data: probeRecord(recPong, counter)}
2115- raw, marshalErr := pdgram.MarshalFragment(frag)
2116- if marshalErr != nil {
2117- t.Fatal(marshalErr)
2118- }
2119- tr.mu.Lock()
2120- actual := tr.pc
2121- pc, ok := actual.(*fakePC)
2122- tr.mu.Unlock()
2123- if !ok {
2124- t.Fatalf("transport packet conn = %T, want *fakePC", actual)
2125- }
2126- pc.in <- packet{b: codec.Seal(2, counter, raw, nil), addr: testAddr("b")}
2127- select {
2128- case <-authenticated:
2129- case <-time.After(time.Second):
2130- t.Fatal("heartbeat pong was not authenticated")
2131- }
2132- }
2133-
2134- for elapsed := 3 * time.Second; elapsed <= 66*time.Second; elapsed += 3 * time.Second {
2135- clk.advance(3 * time.Second)
2136- sendHeartbeatPong()
2137- eventually(t, time.Second, func() bool {
2138- tr.mu.Lock()
2139- defer tr.mu.Unlock()
2140- return tr.health.lastRecord.Equal(clk.Now())
2141- })
2142- if elapsed >= 20*time.Second {
2143- eventually(t, time.Second, func() bool {
2144- tr.mu.Lock()
2145- defer tr.mu.Unlock()
2146- return tr.pc != aPC
2147- })
2148- }
2149- }
2150- if got := tr.LinkState(); got != ports.LinkStateProbing {
2151- t.Fatalf("state=%v, want probing despite fresh heartbeat control traffic", got)
2152- }
2153- if got := hops.Load(); got != 1 {
2154- t.Fatalf("socket hops=%d, want one pending round-trip recovery hop", got)
2155- }
2156- select {
2157- case <-tr.done:
2158- t.Fatal("fresh authenticated control contact must prevent offline/dead closure")
2159- default:
2160- }
2161-}
2162-
2163-func TestLinkHealthOnlyAckProgressThatRemovesPendingRestoresConnected(t *testing.T) {
2164- tr, _ := newResendTestTransport(t, 10)
2165- now := tr.clock.Now()
2166- tr.linkState = ports.LinkStateDegraded
2167- tr.health = healthTracker{lastPacket: now, lastRecord: now.Add(-time.Minute), lastProgress: now.Add(-time.Minute)}
2168- tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: now, last: now}
2169-
2170- tr.handleRecord(ackRecord(1))
2171- if got := tr.LinkState(); got != ports.LinkStateConnected {
2172- t.Fatalf("state after advancing ACK=%v, want connected", got)
2173- }
2174- tr.mu.Lock()
2175- if tr.health.lastProgress.Before(now) {
2176- tr.mu.Unlock()
2177- t.Fatalf("last progress=%v, want at or after %v", tr.health.lastProgress, now)
2178- }
2179- tr.mu.Unlock()
2180-
2181- tr.setLinkState(ports.LinkStateDegraded, nil)
2182- tr.handleRecord(ackRecord(1))
2183- if got := tr.LinkState(); got != ports.LinkStateDegraded {
2184- t.Fatalf("duplicate ACK state=%v, want degraded", got)
2185- }
2186-}
2187-
2188-func TestLinkHealthFreshProgressInvalidatesStaleDecision(t *testing.T) {
2189- tests := []struct {
2190- name string
2191- staleAge time.Duration
2192- freshen func(*Transport)
2193- wantState ports.LinkState
2194- }{
2195- {
2196- name: "fresh packet contact prevents stale dead close",
2197- staleAge: time.Minute,
2198- freshen: func(tr *Transport) {
2199- tr.mu.Lock()
2200- now := tr.clock.Now()
2201- tr.health.authenticatedPacket(now)
2202- tr.lastAuthenticatedPacket = now
2203- tr.mu.Unlock()
2204- },
2205- wantState: ports.LinkStateDegraded,
2206- },
2207- {
2208- name: "complete record prevents stale probing state and hop",
2209- staleAge: 20 * time.Second,
2210- freshen: func(tr *Transport) {
2211- tr.mu.Lock()
2212- now := tr.clock.Now()
2213- tr.health.authenticatedPacket(now)
2214- tr.health.completeRecord(now)
2215- tr.lastAuthenticatedPacket = now
2216- tr.lastCompleteRecord = now
2217- tr.mu.Unlock()
2218- tr.setLinkState(ports.LinkStateConnected, nil)
2219- },
2220- wantState: ports.LinkStateConnected,
2221- },
2222- }
2223-
2224- for _, tt := range tests {
2225- t.Run(tt.name, func(t *testing.T) {
2226- aPC, bPC := newPair()
2227- var hops atomic.Int32
2228- tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
2229- ResendAfter: time.Hour,
2230- Heartbeat: time.Hour,
2231- RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
2232- hops.Add(1)
2233- return &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}, nil
2234- },
2235- })
2236- if err != nil {
2237- t.Fatal(err)
2238- }
2239- defer func() { _ = tr.Close() }()
2240- defer func() { _ = bPC.Close() }()
2241-
2242- now := tr.clock.Now()
2243- tr.mu.Lock()
2244- staleAt := now.Add(-tt.staleAge)
2245- tr.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
2246- decisionReady := make(chan struct{})
2247- resume := make(chan struct{})
2248- tr.afterHealthDecision = func() {
2249- close(decisionReady)
2250- <-resume
2251- }
2252- tr.mu.Unlock()
2253-
2254- done := make(chan struct{})
2255- go func() {
2256- tr.checkSilence()
2257- close(done)
2258- }()
2259- select {
2260- case <-decisionReady:
2261- case <-time.After(time.Second):
2262- t.Fatal("health decision hook was not reached")
2263- }
2264- tt.freshen(tr)
2265- close(resume)
2266- select {
2267- case <-done:
2268- case <-time.After(time.Second):
2269- t.Fatal("health check did not finish")
2270- }
2271-
2272- if got := tr.LinkState(); got != tt.wantState {
2273- t.Fatalf("state=%v, want %v after fresh health evidence", got, tt.wantState)
2274- }
2275- if got := hops.Load(); got != 0 {
2276- t.Fatalf("socket hops=%d, want none after recovery", got)
2277- }
2278- for len(tr.linkEvents) > 0 {
2279- ev := <-tr.linkEvents
2280- if ev.State == ports.LinkStateDead || ev.State == ports.LinkStateProbing {
2281- t.Fatalf("stale link event emitted after recovery: %v", ev.State)
2282- }
2283- }
2284- select {
2285- case <-tr.done:
2286- t.Fatal("transport closed from stale health decision")
2287- default:
2288- }
2289- })
2290- }
2291-}
2292-
2293-func TestLinkHealthStaleRebindRestoresFutureHopAllowance(t *testing.T) {
2294- aPC, bPC := newPair()
2295- clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
2296- rebindStarted := make(chan struct{})
2297- resumeRebind := make(chan struct{})
2298- var rebinds atomic.Int32
2299- tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
2300- Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
2301- RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
2302- attempt := rebinds.Add(1)
2303- pc := &fakePC{addr: testAddr(fmt.Sprintf("a%d", attempt+1)), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
2304- if attempt == 1 {
2305- close(rebindStarted)
2306- <-resumeRebind
2307- }
2308- return pc, nil
2309- },
2310- })
2311- if err != nil {
2312- t.Fatal(err)
2313- }
2314- defer func() { _ = tr.Close() }()
2315- defer func() { _ = bPC.Close() }()
2316-
2317- tr.mu.Lock()
2318- staleAt := clk.Now().Add(-20 * time.Second)
2319- tr.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
2320- tr.mu.Unlock()
2321- firstDone := make(chan struct{})
2322- go func() {
2323- tr.checkSilence()
2324- close(firstDone)
2325- }()
2326- select {
2327- case <-rebindStarted:
2328- case <-time.After(time.Second):
2329- t.Fatal("first rebind did not start")
2330- }
2331-
2332- tr.mu.Lock()
2333- now := clk.Now()
2334- tr.health.authenticatedPacket(now)
2335- tr.lastAuthenticatedPacket = now
2336- tr.mu.Unlock()
2337- close(resumeRebind)
2338- select {
2339- case <-firstDone:
2340- case <-time.After(time.Second):
2341- t.Fatal("stale rebind did not finish")
2342- }
2343- tr.mu.Lock()
2344- latched := tr.hoppedOffline
2345- pcAfterStale := tr.pc
2346- tr.mu.Unlock()
2347- if latched {
2348- t.Fatal("stale rebind consumed the future hop allowance")
2349- }
2350- if pcAfterStale != aPC {
2351- t.Fatal("stale rebind replaced the active packet conn")
2352- }
2353-
2354- clk.advance(20 * time.Second)
2355- tr.checkSilence()
2356- tr.mu.Lock()
2357- pcAfterRetry := tr.pc
2358- tr.mu.Unlock()
2359- if got := rebinds.Load(); got != 2 {
2360- t.Fatalf("rebind attempts=%d, want a later eligible retry", got)
2361- }
2362- if pcAfterRetry == aPC {
2363- t.Fatal("later stale interval did not hop the packet conn")
2364- }
2365-}
2366-
2367-func TestLinkHealthStateEventsCannotPublishAfterNewerRecovery(t *testing.T) {
2368- aPC, bPC := newPair()
2369- tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: time.Hour, Heartbeat: time.Hour})
2370- if err != nil {
2371- t.Fatal(err)
2372- }
2373- defer func() { _ = tr.Close() }()
2374- defer func() { _ = bPC.Close() }()
2375-
2376- probingCommitted := make(chan struct{})
2377- resumeProbing := make(chan struct{})
2378- tr.mu.Lock()
2379- tr.linkState = ports.LinkStateDegraded
2380- tr.afterLinkStateCommit = func(state ports.LinkState) {
2381- if state != ports.LinkStateProbing {
2382- return
2383- }
2384- close(probingCommitted)
2385- <-resumeProbing
2386- }
2387- tr.mu.Unlock()
2388-
2389- probingDone := make(chan struct{})
2390- go func() {
2391- tr.setLinkState(ports.LinkStateProbing, nil)
2392- close(probingDone)
2393- }()
2394- select {
2395- case <-probingCommitted:
2396- case <-time.After(time.Second):
2397- t.Fatal("probing commit hook was not reached")
2398- }
2399- tr.setLinkState(ports.LinkStateConnected, nil)
2400- close(resumeProbing)
2401- select {
2402- case <-probingDone:
2403- case <-time.After(time.Second):
2404- t.Fatal("probing publisher did not finish")
2405- }
2406-
2407- if got := tr.LinkState(); got != ports.LinkStateConnected {
2408- t.Fatalf("state=%v, want connected", got)
2409- }
2410- select {
2411- case ev := <-tr.LinkEvents():
2412- if ev.State != ports.LinkStateConnected {
2413- t.Fatalf("first event=%v, want connected", ev.State)
2414- }
2415- default:
2416- t.Fatal("missing connected recovery event")
2417- }
2418- select {
2419- case ev := <-tr.LinkEvents():
2420- t.Fatalf("stale event published after recovery: %v", ev.State)
2421- default:
2422- }
2423-}
2424-
2425-func TestHopFiresAtProbingNotDegraded(t *testing.T) {
2426- aPC, bPC := newPair()
2427- var hopped atomic.Bool
2428- aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
2429- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
2430- DegradedAfter: time.Second, ProbeAfter: 2 * time.Second, OfflineAfter: 3 * time.Second, DeadAfter: time.Hour,
2431- RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
2432- newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
2433- bPC.peers["a2"] = newPC
2434- hopped.Store(true)
2435- return newPC, nil
2436- },
2437- })
2438- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
2439- defer func() { _ = a.Close() }()
2440- defer func() { _ = b.Close() }()
2441-
2442- a.mu.Lock()
2443- staleAt := time.Now().Add(-time.Second)
2444- a.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
2445- a.mu.Unlock()
2446- a.checkSilence()
2447- if hopped.Load() {
2448- t.Fatal("socket hopped at degraded silence")
2449- }
2450-
2451- a.mu.Lock()
2452- a.health.lastPacket = time.Now().Add(-2 * time.Second)
2453- a.mu.Unlock()
2454- a.checkSilence()
2455- if !hopped.Load() {
2456- t.Fatal("socket did not hop at probing silence")
2457- }
2458-}
2459-
2460-func TestShortSilenceDoesNotKillTransport(t *testing.T) {
2461- aPC, bPC := newPair()
2462- a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{DegradedAfter: 50 * time.Millisecond, DeadAfter: time.Hour})
2463- b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
2464- defer func() { _ = a.Close() }()
2465- defer func() { _ = b.Close() }()
2466- a.mu.Lock()
2467- staleAt := time.Now().Add(-time.Second)
2468- a.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
2469- a.mu.Unlock()
2470- a.checkSilence()
2471- if got := a.LinkState(); got != ports.LinkStateDegraded {
2472- t.Fatalf("LinkState()=%v, want degraded", got)
2473- }
2474- select {
2475- case <-a.done:
2476- t.Fatal("transport closed before DeadAfter")
2477- default:
2478- }
2479-}
A internal/adapters/dgram/transport_test_helpers_test.go +433 -0
@@ -0,0 +1,433 @@
1+package dgram
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "net"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/ports"
13+ pdgram "github.com/bnema/vev/pkg/dgram"
14+)
15+
16+type timeoutErr struct{}
17+
18+func (timeoutErr) Error() string { return "i/o timeout" }
19+func (timeoutErr) Timeout() bool { return true }
20+
21+type blockingWritePC struct {
22+ addr net.Addr
23+
24+ mu sync.Mutex
25+ closed bool
26+ deadline time.Time
27+ done chan struct{}
28+}
29+
30+func newBlockingWritePC() *blockingWritePC {
31+ return &blockingWritePC{addr: testAddr("blocked"), done: make(chan struct{})}
32+}
33+
34+func (p *blockingWritePC) ReadFrom([]byte) (int, net.Addr, error) {
35+ <-p.done
36+ return 0, nil, errors.New("closed")
37+}
38+
39+func (p *blockingWritePC) WriteTo([]byte, net.Addr) (int, error) {
40+ for {
41+ p.mu.Lock()
42+ closed := p.closed
43+ deadline := p.deadline
44+ p.mu.Unlock()
45+ if closed {
46+ return 0, errors.New("closed")
47+ }
48+ if !deadline.IsZero() {
49+ wait := time.Until(deadline)
50+ if wait > 0 {
51+ timer := time.NewTimer(wait)
52+ select {
53+ case <-timer.C:
54+ case <-p.done:
55+ timer.Stop()
56+ return 0, errors.New("closed")
57+ }
58+ }
59+ return 0, timeoutErr{}
60+ }
61+ select {
62+ case <-time.After(time.Millisecond):
63+ case <-p.done:
64+ return 0, errors.New("closed")
65+ }
66+ }
67+}
68+
69+func (p *blockingWritePC) Close() error {
70+ p.mu.Lock()
71+ defer p.mu.Unlock()
72+ if !p.closed {
73+ p.closed = true
74+ close(p.done)
75+ }
76+ return nil
77+}
78+
79+func (p *blockingWritePC) LocalAddr() net.Addr { return p.addr }
80+func (p *blockingWritePC) SetDeadline(t time.Time) error {
81+ p.mu.Lock()
82+ p.deadline = t
83+ p.mu.Unlock()
84+ return nil
85+}
86+func (p *blockingWritePC) SetReadDeadline(time.Time) error { return nil }
87+func (p *blockingWritePC) SetWriteDeadline(t time.Time) error {
88+ p.mu.Lock()
89+ p.deadline = t
90+ p.mu.Unlock()
91+ return nil
92+}
93+
94+type fixedClock struct{ now time.Time }
95+
96+func (c fixedClock) Now() time.Time { return c.now }
97+func (c fixedClock) NewTimer(d time.Duration) ports.Timer {
98+ return realTimer{t: time.NewTimer(d)}
99+}
100+
101+type manualClock struct {
102+ mu sync.Mutex
103+ now time.Time
104+ timers map[*manualTimer]struct{}
105+ timerCreated chan struct{}
106+}
107+
108+func newManualClock(now time.Time) *manualClock {
109+ return &manualClock{
110+ now: now,
111+ timers: make(map[*manualTimer]struct{}),
112+ timerCreated: make(chan struct{}, 64),
113+ }
114+}
115+
116+func (c *manualClock) Now() time.Time {
117+ c.mu.Lock()
118+ defer c.mu.Unlock()
119+ return c.now
120+}
121+
122+func (c *manualClock) NewTimer(d time.Duration) ports.Timer {
123+ c.mu.Lock()
124+ tm := &manualTimer{clock: c, c: make(chan time.Time, 1), deadline: c.now.Add(d), active: true}
125+ c.timers[tm] = struct{}{}
126+ c.mu.Unlock()
127+ select {
128+ case c.timerCreated <- struct{}{}:
129+ default:
130+ }
131+ return tm
132+}
133+
134+func (c *manualClock) advance(d time.Duration) {
135+ c.mu.Lock()
136+ c.now = c.now.Add(d)
137+ now := c.now
138+ timers := make([]*manualTimer, 0, len(c.timers))
139+ for tm := range c.timers {
140+ timers = append(timers, tm)
141+ }
142+ c.mu.Unlock()
143+ for _, tm := range timers {
144+ tm.fireIfDue(now)
145+ }
146+}
147+
148+type manualTimer struct {
149+ clock *manualClock
150+ c chan time.Time
151+ deadline time.Time
152+ active bool
153+}
154+
155+func (t *manualTimer) C() <-chan time.Time { return t.c }
156+func (t *manualTimer) Reset(d time.Duration) bool {
157+ t.clock.mu.Lock()
158+ wasActive := t.active
159+ t.deadline = t.clock.now.Add(d)
160+ t.active = true
161+ t.clock.timers[t] = struct{}{}
162+ t.clock.mu.Unlock()
163+ return wasActive
164+}
165+func (t *manualTimer) Stop() bool {
166+ t.clock.mu.Lock()
167+ defer t.clock.mu.Unlock()
168+ wasActive := t.active
169+ t.active = false
170+ delete(t.clock.timers, t)
171+ return wasActive
172+}
173+func (t *manualTimer) fireIfDue(now time.Time) bool {
174+ t.clock.mu.Lock()
175+ if !t.active {
176+ delete(t.clock.timers, t)
177+ t.clock.mu.Unlock()
178+ return false
179+ }
180+ if now.Before(t.deadline) {
181+ t.clock.mu.Unlock()
182+ return false
183+ }
184+ t.active = false
185+ delete(t.clock.timers, t)
186+ t.clock.mu.Unlock()
187+ select {
188+ case t.c <- now:
189+ default:
190+ }
191+ return true
192+}
193+
194+func waitForManualTimers(t *testing.T, clk *manualClock, n int) {
195+ t.Helper()
196+ deadline := time.Now().Add(time.Second)
197+ for time.Now().Before(deadline) {
198+ clk.mu.Lock()
199+ active := len(clk.timers)
200+ clk.mu.Unlock()
201+ if active >= n {
202+ return
203+ }
204+ time.Sleep(time.Millisecond)
205+ }
206+ clk.mu.Lock()
207+ active := len(clk.timers)
208+ clk.mu.Unlock()
209+ t.Fatalf("manual timers=%d, want at least %d", active, n)
210+}
211+
212+type deadlineCapturePC struct {
213+ addr net.Addr
214+ deadline atomic.Value
215+ done chan struct{}
216+}
217+
218+func (p *deadlineCapturePC) ReadFrom([]byte) (int, net.Addr, error) {
219+ <-p.done
220+ return 0, nil, errors.New("closed")
221+}
222+func (p *deadlineCapturePC) WriteTo(b []byte, _ net.Addr) (int, error) { return len(b), nil }
223+func (p *deadlineCapturePC) Close() error {
224+ select {
225+ case <-p.done:
226+ default:
227+ close(p.done)
228+ }
229+ return nil
230+}
231+func (p *deadlineCapturePC) LocalAddr() net.Addr { return p.addr }
232+func (p *deadlineCapturePC) SetDeadline(time.Time) error { return nil }
233+func (p *deadlineCapturePC) SetReadDeadline(time.Time) error { return nil }
234+func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error {
235+ if !t.IsZero() {
236+ p.deadline.Store(t)
237+ }
238+ return nil
239+}
240+
241+func eventuallyPacketCount(t *testing.T, pc *fakePC, want int) {
242+ t.Helper()
243+ deadline := time.Now().Add(time.Second)
244+ for time.Now().Before(deadline) {
245+ if len(pc.in) >= want {
246+ return
247+ }
248+ time.Sleep(time.Millisecond)
249+ }
250+ t.Fatalf("datagrams=%d, want at least %d", len(pc.in), want)
251+}
252+
253+func eventually(t *testing.T, timeout time.Duration, condition func() bool) {
254+ t.Helper()
255+ deadline := time.Now().Add(timeout)
256+ for time.Now().Before(deadline) {
257+ if condition() {
258+ return
259+ }
260+ time.Sleep(time.Millisecond)
261+ }
262+ t.Fatal("condition was not met before timeout")
263+}
264+
265+func newResendTestTransport(t *testing.T, maxResendPerTick int) (*Transport, *atomic.Int32) {
266+ t.Helper()
267+ aPC, _ := newPair()
268+ codec, err := pdgram.NewCodec(key())
269+ if err != nil {
270+ t.Fatal(err)
271+ }
272+ var writes atomic.Int32
273+ aPC.drop = func(_ []byte, addr net.Addr) bool {
274+ if addr.String() == "b" {
275+ writes.Add(1)
276+ return true
277+ }
278+ return false
279+ }
280+ return &Transport{
281+ pc: aPC,
282+ peer: testAddr("b"),
283+ codec: codec,
284+ sendDir: 1,
285+ mtu: pdgram.DefaultMTU,
286+ pending: make(map[uint64]*pending),
287+ resendAfter: 100 * time.Millisecond,
288+ maxResendAfter: 500 * time.Millisecond,
289+ maxResendPerTick: maxResendPerTick,
290+ rto: 100 * time.Millisecond,
291+ clock: realClock{},
292+ congestion: newCongestionController(pdgram.DefaultMTU),
293+ done: make(chan struct{}),
294+ }, &writes
295+}
296+
297+func ackRecord(seq uint64) []byte {
298+ var b [9]byte
299+ b[0] = recAck
300+ binary.BigEndian.PutUint64(b[1:], seq)
301+ return b[:]
302+}
303+
304+func probeRecord(kind byte, id uint64) []byte {
305+ var b [9]byte
306+ b[0] = kind
307+ binary.BigEndian.PutUint64(b[1:], id)
308+ return b[:]
309+}
310+
311+func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) {
312+ tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, done: make(chan struct{})}
313+ tr.deliverCond = sync.NewCond(&tr.deliverMu)
314+ for i := range maxRecvBuffer + 100 {
315+ tr.enqueueReliable(uint64(1000+i), ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}})
316+ }
317+ if got := len(tr.recvBuf); got != maxRecvBuffer {
318+ t.Fatalf("recvBuf len=%d, want %d", got, maxRecvBuffer)
319+ }
320+ tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})
321+ if _, ok := tr.recvBuf[1]; !ok {
322+ t.Fatalf("next expected sequence was dropped when far-future buffer was full")
323+ }
324+ if got := len(tr.recvBuf); got > maxRecvBuffer+1 {
325+ t.Fatalf("recvBuf len=%d, want <= %d", got, maxRecvBuffer+1)
326+ }
327+}
328+
329+func TestOutputPrerequisitesRemainInFullReceiveBuffer(t *testing.T) {
330+ tr := &Transport{
331+ nextRecvSeq: 1,
332+ recvBuf: map[uint64]ports.Frame{2: {Type: ports.MsgOutput, Payload: []byte("stale")}},
333+ maxRecvBuffer: 1,
334+ done: make(chan struct{}),
335+ }
336+ tr.deliverCond = sync.NewCond(&tr.deliverMu)
337+
338+ ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("replacement")})
339+ if ack || queued || ackSeq != 0 {
340+ t.Fatalf("replacement ackSeq=%d ack=%v queued=%v, want output rejected while prerequisite buffer is full", ackSeq, ack, queued)
341+ }
342+ if _, ok := tr.recvBuf[2]; !ok {
343+ t.Fatal("prerequisite output was discarded from full receive buffer")
344+ }
345+ if _, ok := tr.recvBuf[3]; ok {
346+ t.Fatal("far-future replacement bypassed full receive buffer")
347+ }
348+}
349+
350+func TestReliableFullRecvBufferDoesNotAckDroppedFarFutureFrame(t *testing.T) {
351+ aPC, bPC := newPair()
352+ codec, err := pdgram.NewCodec(key())
353+ if err != nil {
354+ t.Fatal(err)
355+ }
356+ tr := &Transport{pc: aPC, peer: testAddr("b"), codec: codec, sendDir: 1, mtu: pdgram.DefaultMTU, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, clock: realClock{}, done: make(chan struct{})}
357+ tr.deliverCond = sync.NewCond(&tr.deliverMu)
358+ defer func() { _ = aPC.Close() }()
359+ defer func() { _ = bPC.Close() }()
360+
361+ for i := range maxRecvBuffer {
362+ tr.recvBuf[uint64(1000+i)] = ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}
363+ }
364+ tr.handleRecord(encodeData(9999, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("dropped")}))
365+ select {
366+ case pkt := <-bPC.in:
367+ t.Fatalf("unexpected ACK packet for dropped frame: %x", pkt.b)
368+ case <-time.After(25 * time.Millisecond):
369+ }
370+
371+ tr.handleRecord(encodeData(1, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}))
372+ select {
373+ case <-bPC.in:
374+ case <-time.After(time.Second):
375+ t.Fatal("expected ACK for buffered contiguous frame")
376+ }
377+}
378+
379+func recvMaybe(tr *Transport, d time.Duration) (ports.Frame, bool) {
380+ ch := make(chan ports.Frame, 1)
381+ go func() { f, _ := tr.Recv(); ch <- f }()
382+ select {
383+ case f := <-ch:
384+ return f, true
385+ case <-time.After(d):
386+ return ports.Frame{}, false
387+ }
388+}
389+
390+func recvWithin(t *testing.T, tr *Transport, d time.Duration) ports.Frame {
391+ t.Helper()
392+ ch := make(chan ports.Frame, 1)
393+ go func() { f, _ := tr.Recv(); ch <- f }()
394+ select {
395+ case f := <-ch:
396+ return f
397+ case <-time.After(d):
398+ t.Fatal("timeout")
399+ return ports.Frame{}
400+ }
401+}
402+
403+func waitPeer(t *testing.T, tr *Transport, want string) {
404+ t.Helper()
405+ deadline := time.Now().Add(time.Second)
406+ for time.Now().Before(deadline) {
407+ if tr.Peer().String() == want {
408+ return
409+ }
410+ time.Sleep(time.Millisecond)
411+ }
412+ t.Fatalf("peer=%v, want %s", tr.Peer(), want)
413+}
414+
415+func recvErrWithin(t *testing.T, tr *Transport, d time.Duration) (ports.Frame, error) {
416+ t.Helper()
417+ type result struct {
418+ f ports.Frame
419+ err error
420+ }
421+ ch := make(chan result, 1)
422+ go func() {
423+ f, err := tr.Recv()
424+ ch <- result{f: f, err: err}
425+ }()
426+ select {
427+ case r := <-ch:
428+ return r.f, r.err
429+ case <-time.After(d):
430+ t.Fatal("timeout")
431+ return ports.Frame{}, nil
432+ }
433+}

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