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+}
@@ -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+}
@@ -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+}
@@ -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+}
@@ -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+}
@@ -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+}
@@ -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 large diff (2479 lines changed): collapsed
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+}
@@ -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