detached 68a2ea2

fix(dgram): serialize UDP health recovery

commit
68a2ea203d920106d62233328797fdef18d46731
author
brice · · 2026-07-10 06:35 UTC
parent
aeaca5d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +211 -47
M internal/adapters/dgram/transport.go +78 -47
@@ -167,41 +167,47 @@ type Transport struct {
167167 // afterHealthDecision is a test synchronization hook. It runs after a health
168168 // snapshot is taken and before that decision is conditionally committed.
169169 afterHealthDecision func()
170- observe DiagnosticObserver
171- diagnosticCh chan Diagnostic
172- heartbeat time.Duration
173- resendAfter time.Duration
174- maxResendAfter time.Duration
175- maxResendPerTick int
176- srtt time.Duration
177- rttvar time.Duration
178- rto time.Duration
179- writeTimeout time.Duration
180- degradedAfter time.Duration
181- probeAfter time.Duration
182- offlineAfter time.Duration
183- deadAfter time.Duration
184- maxPending int
185- maxPendingWait time.Duration
186- maxRecvBuffer int
187- clock ports.Clock
188- linkState ports.LinkState
189- linkEvents chan ports.LinkEvent
190- probeWait map[uint64]chan struct{}
191- sendWake chan struct{}
192- control chan controlRecord
193- ackWake chan struct{}
194- ackSend chan uint64
195- controlMu sync.Mutex
196- ackSeq uint64
197- ackQueued bool
198- rebind func(net.PacketConn) (net.PacketConn, error)
199- hoppedOffline bool
200- outputQueue []queuedSend
201- outputWake chan struct{}
202- outputNext time.Time
203- retransmitWork chan []retransmitRecord
204- writeDeadlines *writeDeadlineState
170+ // afterLinkStateCommit is a test synchronization hook. It runs after state
171+ // mutation and before ordered event publication.
172+ afterLinkStateCommit func(ports.LinkState)
173+ observe DiagnosticObserver
174+ diagnosticCh chan Diagnostic
175+ heartbeat time.Duration
176+ resendAfter time.Duration
177+ maxResendAfter time.Duration
178+ maxResendPerTick int
179+ srtt time.Duration
180+ rttvar time.Duration
181+ rto time.Duration
182+ writeTimeout time.Duration
183+ degradedAfter time.Duration
184+ probeAfter time.Duration
185+ offlineAfter time.Duration
186+ deadAfter time.Duration
187+ maxPending int
188+ maxPendingWait time.Duration
189+ maxRecvBuffer int
190+ clock ports.Clock
191+ linkState ports.LinkState
192+ linkStateGeneration uint64
193+ linkEvents chan ports.LinkEvent
194+ linkEventMu sync.Mutex
195+ probeWait map[uint64]chan struct{}
196+ sendWake chan struct{}
197+ control chan controlRecord
198+ ackWake chan struct{}
199+ ackSend chan uint64
200+ controlMu sync.Mutex
201+ ackSeq uint64
202+ ackQueued bool
203+ rebind func(net.PacketConn) (net.PacketConn, error)
204+ hoppedOffline bool
205+ hopGeneration uint64
206+ outputQueue []queuedSend
207+ outputWake chan struct{}
208+ outputNext time.Time
209+ retransmitWork chan []retransmitRecord
210+ writeDeadlines *writeDeadlineState
205211
206212 recvMu sync.Mutex
207213 replay *pdgram.ReplayWindow
@@ -1189,18 +1195,18 @@ func (t *Transport) hopPacketConnOnce(generation uint64) {
11891195 old := t.pc
11901196 rebind := t.rebind
11911197 t.hoppedOffline = true
1198+ t.hopGeneration = generation
11921199 t.mu.Unlock()
11931200 pc, err := rebind(old)
11941201 if err != nil {
11951202 t.mu.Lock()
1196- if t.pc == old && t.health.generation == generation && t.linkState == ports.LinkStateProbing {
1197- t.hoppedOffline = false
1198- }
1203+ t.rollbackHopLocked(generation)
11991204 t.mu.Unlock()
12001205 return
12011206 }
12021207 t.mu.Lock()
12031208 if t.closed || t.pc != old || t.health.generation != generation || t.linkState != ports.LinkStateProbing {
1209+ t.rollbackHopLocked(generation)
12041210 t.mu.Unlock()
12051211 _ = pc.Close()
12061212 return
@@ -1212,6 +1218,13 @@ func (t *Transport) hopPacketConnOnce(generation uint64) {
12121218 _ = old.Close()
12131219 }
12141220
1221+func (t *Transport) rollbackHopLocked(generation uint64) {
1222+ if t.hoppedOffline && t.hopGeneration == generation {
1223+ t.hoppedOffline = false
1224+ t.hopGeneration = 0
1225+ }
1226+}
1227+
12151228 func (t *Transport) checkSilence() {
12161229 runHook := true
12171230 for {
@@ -1242,7 +1255,8 @@ func (t *Transport) checkSilence() {
12421255 t.mu.Unlock()
12431256 continue
12441257 }
1245- changed := t.setLinkStateLocked(state)
1258+ changed, stateGeneration := t.setLinkStateLocked(state)
1259+ afterStateCommit := t.afterLinkStateCommit
12461260 closed := false
12471261 if dead {
12481262 closed = t.closeWithErrorLocked(ErrLinkDead)
@@ -1250,11 +1264,14 @@ func (t *Transport) checkSilence() {
12501264 t.mu.Unlock()
12511265
12521266 if changed {
1267+ if afterStateCommit != nil {
1268+ afterStateCommit(state)
1269+ }
12531270 var linkErr error
12541271 if dead {
12551272 linkErr = ErrLinkDead
12561273 }
1257- t.publishLinkState(state, now, linkErr)
1274+ t.publishLinkState(state, now, linkErr, stateGeneration)
12581275 }
12591276 if closed {
12601277 t.broadcastClosed()
@@ -1270,20 +1287,26 @@ func (t *Transport) checkSilence() {
12701287 func (t *Transport) setLinkState(state ports.LinkState, err error) {
12711288 now := t.clock.Now()
12721289 t.mu.Lock()
1273- changed := t.setLinkStateLocked(state)
1290+ changed, stateGeneration := t.setLinkStateLocked(state)
1291+ afterStateCommit := t.afterLinkStateCommit
12741292 t.mu.Unlock()
12751293 if changed {
1276- t.publishLinkState(state, now, err)
1294+ if afterStateCommit != nil {
1295+ afterStateCommit(state)
1296+ }
1297+ t.publishLinkState(state, now, err, stateGeneration)
12771298 }
12781299 }
12791300
1280-func (t *Transport) setLinkStateLocked(state ports.LinkState) bool {
1301+func (t *Transport) setLinkStateLocked(state ports.LinkState) (bool, uint64) {
12811302 if t.linkState == state {
1282- return false
1303+ return false, t.linkStateGeneration
12831304 }
12841305 t.linkState = state
1306+ t.linkStateGeneration++
12851307 if state == ports.LinkStateConnected {
12861308 t.hoppedOffline = false
1309+ t.hopGeneration = 0
12871310 for _, p := range t.pending {
12881311 if p != nil {
12891312 p.attempts = 0
@@ -1291,16 +1314,24 @@ func (t *Transport) setLinkStateLocked(state ports.LinkState) bool {
12911314 }
12921315 }
12931316 t.notifySendWaitersLocked()
1294- return true
1317+ return true, t.linkStateGeneration
12951318 }
12961319
1297-func (t *Transport) publishLinkState(state ports.LinkState, now time.Time, err error) {
1298- t.emitDiagnostic()
1320+func (t *Transport) publishLinkState(state ports.LinkState, now time.Time, err error, generation uint64) {
1321+ t.linkEventMu.Lock()
1322+ defer t.linkEventMu.Unlock()
1323+ t.mu.Lock()
1324+ if t.linkState != state || t.linkStateGeneration != generation {
1325+ t.mu.Unlock()
1326+ return
1327+ }
12991328 event := ports.LinkEvent{State: state, At: now, Err: err}
13001329 select {
13011330 case t.linkEvents <- event:
13021331 default:
13031332 }
1333+ t.mu.Unlock()
1334+ t.emitDiagnostic()
13041335 }
13051336
13061337 func (t *Transport) diagnosticLoop() {
@@ -167,41 +167,47 @@ type Transport struct {
167 // afterHealthDecision is a test synchronization hook. It runs after a health167 // afterHealthDecision is a test synchronization hook. It runs after a health
168 // snapshot is taken and before that decision is conditionally committed.168 // snapshot is taken and before that decision is conditionally committed.
169 afterHealthDecision func()169 afterHealthDecision func()
170- observe DiagnosticObserver170+ // afterLinkStateCommit is a test synchronization hook. It runs after state
171- diagnosticCh chan Diagnostic171+ // mutation and before ordered event publication.
172- heartbeat time.Duration172+ afterLinkStateCommit func(ports.LinkState)
173- resendAfter time.Duration173+ observe DiagnosticObserver
174- maxResendAfter time.Duration174+ diagnosticCh chan Diagnostic
175- maxResendPerTick int175+ heartbeat time.Duration
176- srtt time.Duration176+ resendAfter time.Duration
177- rttvar time.Duration177+ maxResendAfter time.Duration
178- rto time.Duration178+ maxResendPerTick int
179- writeTimeout time.Duration179+ srtt time.Duration
180- degradedAfter time.Duration180+ rttvar time.Duration
181- probeAfter time.Duration181+ rto time.Duration
182- offlineAfter time.Duration182+ writeTimeout time.Duration
183- deadAfter time.Duration183+ degradedAfter time.Duration
184- maxPending int184+ probeAfter time.Duration
185- maxPendingWait time.Duration185+ offlineAfter time.Duration
186- maxRecvBuffer int186+ deadAfter time.Duration
187- clock ports.Clock187+ maxPending int
188- linkState ports.LinkState188+ maxPendingWait time.Duration
189- linkEvents chan ports.LinkEvent189+ maxRecvBuffer int
190- probeWait map[uint64]chan struct{}190+ clock ports.Clock
191- sendWake chan struct{}191+ linkState ports.LinkState
192- control chan controlRecord192+ linkStateGeneration uint64
193- ackWake chan struct{}193+ linkEvents chan ports.LinkEvent
194- ackSend chan uint64194+ linkEventMu sync.Mutex
195- controlMu sync.Mutex195+ probeWait map[uint64]chan struct{}
196- ackSeq uint64196+ sendWake chan struct{}
197- ackQueued bool197+ control chan controlRecord
198- rebind func(net.PacketConn) (net.PacketConn, error)198+ ackWake chan struct{}
199- hoppedOffline bool199+ ackSend chan uint64
200- outputQueue []queuedSend200+ controlMu sync.Mutex
201- outputWake chan struct{}201+ ackSeq uint64
202- outputNext time.Time202+ ackQueued bool
203- retransmitWork chan []retransmitRecord203+ rebind func(net.PacketConn) (net.PacketConn, error)
204- writeDeadlines *writeDeadlineState204+ hoppedOffline bool
205+ hopGeneration uint64
206+ outputQueue []queuedSend
207+ outputWake chan struct{}
208+ outputNext time.Time
209+ retransmitWork chan []retransmitRecord
210+ writeDeadlines *writeDeadlineState
205 211
206 recvMu sync.Mutex212 recvMu sync.Mutex
207 replay *pdgram.ReplayWindow213 replay *pdgram.ReplayWindow
@@ -1189,18 +1195,18 @@ func (t *Transport) hopPacketConnOnce(generation uint64) {
1189 old := t.pc1195 old := t.pc
1190 rebind := t.rebind1196 rebind := t.rebind
1191 t.hoppedOffline = true1197 t.hoppedOffline = true
1198+ t.hopGeneration = generation
1192 t.mu.Unlock()1199 t.mu.Unlock()
1193 pc, err := rebind(old)1200 pc, err := rebind(old)
1194 if err != nil {1201 if err != nil {
1195 t.mu.Lock()1202 t.mu.Lock()
1196- if t.pc == old && t.health.generation == generation && t.linkState == ports.LinkStateProbing {1203+ t.rollbackHopLocked(generation)
1197- t.hoppedOffline = false
1198- }
1199 t.mu.Unlock()1204 t.mu.Unlock()
1200 return1205 return
1201 }1206 }
1202 t.mu.Lock()1207 t.mu.Lock()
1203 if t.closed || t.pc != old || t.health.generation != generation || t.linkState != ports.LinkStateProbing {1208 if t.closed || t.pc != old || t.health.generation != generation || t.linkState != ports.LinkStateProbing {
1209+ t.rollbackHopLocked(generation)
1204 t.mu.Unlock()1210 t.mu.Unlock()
1205 _ = pc.Close()1211 _ = pc.Close()
1206 return1212 return
@@ -1212,6 +1218,13 @@ func (t *Transport) hopPacketConnOnce(generation uint64) {
1212 _ = old.Close()1218 _ = old.Close()
1213 }1219 }
1214 1220
1221+func (t *Transport) rollbackHopLocked(generation uint64) {
1222+ if t.hoppedOffline && t.hopGeneration == generation {
1223+ t.hoppedOffline = false
1224+ t.hopGeneration = 0
1225+ }
1226+}
1227+
1215 func (t *Transport) checkSilence() {1228 func (t *Transport) checkSilence() {
1216 runHook := true1229 runHook := true
1217 for {1230 for {
@@ -1242,7 +1255,8 @@ func (t *Transport) checkSilence() {
1242 t.mu.Unlock()1255 t.mu.Unlock()
1243 continue1256 continue
1244 }1257 }
1245- changed := t.setLinkStateLocked(state)1258+ changed, stateGeneration := t.setLinkStateLocked(state)
1259+ afterStateCommit := t.afterLinkStateCommit
1246 closed := false1260 closed := false
1247 if dead {1261 if dead {
1248 closed = t.closeWithErrorLocked(ErrLinkDead)1262 closed = t.closeWithErrorLocked(ErrLinkDead)
@@ -1250,11 +1264,14 @@ func (t *Transport) checkSilence() {
1250 t.mu.Unlock()1264 t.mu.Unlock()
1251 1265
1252 if changed {1266 if changed {
1267+ if afterStateCommit != nil {
1268+ afterStateCommit(state)
1269+ }
1253 var linkErr error1270 var linkErr error
1254 if dead {1271 if dead {
1255 linkErr = ErrLinkDead1272 linkErr = ErrLinkDead
1256 }1273 }
1257- t.publishLinkState(state, now, linkErr)1274+ t.publishLinkState(state, now, linkErr, stateGeneration)
1258 }1275 }
1259 if closed {1276 if closed {
1260 t.broadcastClosed()1277 t.broadcastClosed()
@@ -1270,20 +1287,26 @@ func (t *Transport) checkSilence() {
1270 func (t *Transport) setLinkState(state ports.LinkState, err error) {1287 func (t *Transport) setLinkState(state ports.LinkState, err error) {
1271 now := t.clock.Now()1288 now := t.clock.Now()
1272 t.mu.Lock()1289 t.mu.Lock()
1273- changed := t.setLinkStateLocked(state)1290+ changed, stateGeneration := t.setLinkStateLocked(state)
1291+ afterStateCommit := t.afterLinkStateCommit
1274 t.mu.Unlock()1292 t.mu.Unlock()
1275 if changed {1293 if changed {
1276- t.publishLinkState(state, now, err)1294+ if afterStateCommit != nil {
1295+ afterStateCommit(state)
1296+ }
1297+ t.publishLinkState(state, now, err, stateGeneration)
1277 }1298 }
1278 }1299 }
1279 1300
1280-func (t *Transport) setLinkStateLocked(state ports.LinkState) bool {1301+func (t *Transport) setLinkStateLocked(state ports.LinkState) (bool, uint64) {
1281 if t.linkState == state {1302 if t.linkState == state {
1282- return false1303+ return false, t.linkStateGeneration
1283 }1304 }
1284 t.linkState = state1305 t.linkState = state
1306+ t.linkStateGeneration++
1285 if state == ports.LinkStateConnected {1307 if state == ports.LinkStateConnected {
1286 t.hoppedOffline = false1308 t.hoppedOffline = false
1309+ t.hopGeneration = 0
1287 for _, p := range t.pending {1310 for _, p := range t.pending {
1288 if p != nil {1311 if p != nil {
1289 p.attempts = 01312 p.attempts = 0
@@ -1291,16 +1314,24 @@ func (t *Transport) setLinkStateLocked(state ports.LinkState) bool {
1291 }1314 }
1292 }1315 }
1293 t.notifySendWaitersLocked()1316 t.notifySendWaitersLocked()
1294- return true1317+ return true, t.linkStateGeneration
1295 }1318 }
1296 1319
1297-func (t *Transport) publishLinkState(state ports.LinkState, now time.Time, err error) {1320+func (t *Transport) publishLinkState(state ports.LinkState, now time.Time, err error, generation uint64) {
1298- t.emitDiagnostic()1321+ t.linkEventMu.Lock()
1322+ defer t.linkEventMu.Unlock()
1323+ t.mu.Lock()
1324+ if t.linkState != state || t.linkStateGeneration != generation {
1325+ t.mu.Unlock()
1326+ return
1327+ }
1299 event := ports.LinkEvent{State: state, At: now, Err: err}1328 event := ports.LinkEvent{State: state, At: now, Err: err}
1300 select {1329 select {
1301 case t.linkEvents <- event:1330 case t.linkEvents <- event:
1302 default:1331 default:
1303 }1332 }
1333+ t.mu.Unlock()
1334+ t.emitDiagnostic()
1304 }1335 }
1305 1336
1306 func (t *Transport) diagnosticLoop() {1337 func (t *Transport) diagnosticLoop() {
M internal/adapters/dgram/transport_test.go +133 -0
@@ -5,6 +5,7 @@ import (
55 "context"
66 "encoding/binary"
77 "errors"
8+ "fmt"
89 "net"
910 "sync"
1011 "sync/atomic"
@@ -2025,6 +2026,138 @@ func TestLinkHealthFreshProgressInvalidatesStaleDecision(t *testing.T) {
20252026 }
20262027 }
20272028
2029+func TestLinkHealthStaleRebindRestoresFutureHopAllowance(t *testing.T) {
2030+ aPC, bPC := newPair()
2031+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
2032+ rebindStarted := make(chan struct{})
2033+ resumeRebind := make(chan struct{})
2034+ var rebinds atomic.Int32
2035+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
2036+ Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
2037+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
2038+ attempt := rebinds.Add(1)
2039+ pc := &fakePC{addr: testAddr(fmt.Sprintf("a%d", attempt+1)), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
2040+ if attempt == 1 {
2041+ close(rebindStarted)
2042+ <-resumeRebind
2043+ }
2044+ return pc, nil
2045+ },
2046+ })
2047+ if err != nil {
2048+ t.Fatal(err)
2049+ }
2050+ defer func() { _ = tr.Close() }()
2051+ defer func() { _ = bPC.Close() }()
2052+
2053+ tr.mu.Lock()
2054+ staleAt := clk.Now().Add(-20 * time.Second)
2055+ tr.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
2056+ tr.mu.Unlock()
2057+ firstDone := make(chan struct{})
2058+ go func() {
2059+ tr.checkSilence()
2060+ close(firstDone)
2061+ }()
2062+ select {
2063+ case <-rebindStarted:
2064+ case <-time.After(time.Second):
2065+ t.Fatal("first rebind did not start")
2066+ }
2067+
2068+ tr.mu.Lock()
2069+ now := clk.Now()
2070+ tr.health.authenticatedPacket(now)
2071+ tr.lastAuthenticatedPacket = now
2072+ tr.mu.Unlock()
2073+ close(resumeRebind)
2074+ select {
2075+ case <-firstDone:
2076+ case <-time.After(time.Second):
2077+ t.Fatal("stale rebind did not finish")
2078+ }
2079+ tr.mu.Lock()
2080+ latched := tr.hoppedOffline
2081+ pcAfterStale := tr.pc
2082+ tr.mu.Unlock()
2083+ if latched {
2084+ t.Fatal("stale rebind consumed the future hop allowance")
2085+ }
2086+ if pcAfterStale != aPC {
2087+ t.Fatal("stale rebind replaced the active packet conn")
2088+ }
2089+
2090+ clk.advance(20 * time.Second)
2091+ tr.checkSilence()
2092+ tr.mu.Lock()
2093+ pcAfterRetry := tr.pc
2094+ tr.mu.Unlock()
2095+ if got := rebinds.Load(); got != 2 {
2096+ t.Fatalf("rebind attempts=%d, want a later eligible retry", got)
2097+ }
2098+ if pcAfterRetry == aPC {
2099+ t.Fatal("later stale interval did not hop the packet conn")
2100+ }
2101+}
2102+
2103+func TestLinkHealthStateEventsCannotPublishAfterNewerRecovery(t *testing.T) {
2104+ aPC, bPC := newPair()
2105+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: time.Hour, Heartbeat: time.Hour})
2106+ if err != nil {
2107+ t.Fatal(err)
2108+ }
2109+ defer func() { _ = tr.Close() }()
2110+ defer func() { _ = bPC.Close() }()
2111+
2112+ probingCommitted := make(chan struct{})
2113+ resumeProbing := make(chan struct{})
2114+ tr.mu.Lock()
2115+ tr.linkState = ports.LinkStateDegraded
2116+ tr.afterLinkStateCommit = func(state ports.LinkState) {
2117+ if state != ports.LinkStateProbing {
2118+ return
2119+ }
2120+ close(probingCommitted)
2121+ <-resumeProbing
2122+ }
2123+ tr.mu.Unlock()
2124+
2125+ probingDone := make(chan struct{})
2126+ go func() {
2127+ tr.setLinkState(ports.LinkStateProbing, nil)
2128+ close(probingDone)
2129+ }()
2130+ select {
2131+ case <-probingCommitted:
2132+ case <-time.After(time.Second):
2133+ t.Fatal("probing commit hook was not reached")
2134+ }
2135+ tr.setLinkState(ports.LinkStateConnected, nil)
2136+ close(resumeProbing)
2137+ select {
2138+ case <-probingDone:
2139+ case <-time.After(time.Second):
2140+ t.Fatal("probing publisher did not finish")
2141+ }
2142+
2143+ if got := tr.LinkState(); got != ports.LinkStateConnected {
2144+ t.Fatalf("state=%v, want connected", got)
2145+ }
2146+ select {
2147+ case ev := <-tr.LinkEvents():
2148+ if ev.State != ports.LinkStateConnected {
2149+ t.Fatalf("first event=%v, want connected", ev.State)
2150+ }
2151+ default:
2152+ t.Fatal("missing connected recovery event")
2153+ }
2154+ select {
2155+ case ev := <-tr.LinkEvents():
2156+ t.Fatalf("stale event published after recovery: %v", ev.State)
2157+ default:
2158+ }
2159+}
2160+
20282161 func TestHopFiresAtProbingNotDegraded(t *testing.T) {
20292162 aPC, bPC := newPair()
20302163 var hopped atomic.Bool
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "encoding/binary"6 "encoding/binary"
7 "errors"7 "errors"
8+ "fmt"
8 "net"9 "net"
9 "sync"10 "sync"
10 "sync/atomic"11 "sync/atomic"
@@ -2025,6 +2026,138 @@ func TestLinkHealthFreshProgressInvalidatesStaleDecision(t *testing.T) {
2025 }2026 }
2026 }2027 }
2027 2028
2029+func TestLinkHealthStaleRebindRestoresFutureHopAllowance(t *testing.T) {
2030+ aPC, bPC := newPair()
2031+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
2032+ rebindStarted := make(chan struct{})
2033+ resumeRebind := make(chan struct{})
2034+ var rebinds atomic.Int32
2035+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
2036+ Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
2037+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
2038+ attempt := rebinds.Add(1)
2039+ pc := &fakePC{addr: testAddr(fmt.Sprintf("a%d", attempt+1)), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
2040+ if attempt == 1 {
2041+ close(rebindStarted)
2042+ <-resumeRebind
2043+ }
2044+ return pc, nil
2045+ },
2046+ })
2047+ if err != nil {
2048+ t.Fatal(err)
2049+ }
2050+ defer func() { _ = tr.Close() }()
2051+ defer func() { _ = bPC.Close() }()
2052+
2053+ tr.mu.Lock()
2054+ staleAt := clk.Now().Add(-20 * time.Second)
2055+ tr.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
2056+ tr.mu.Unlock()
2057+ firstDone := make(chan struct{})
2058+ go func() {
2059+ tr.checkSilence()
2060+ close(firstDone)
2061+ }()
2062+ select {
2063+ case <-rebindStarted:
2064+ case <-time.After(time.Second):
2065+ t.Fatal("first rebind did not start")
2066+ }
2067+
2068+ tr.mu.Lock()
2069+ now := clk.Now()
2070+ tr.health.authenticatedPacket(now)
2071+ tr.lastAuthenticatedPacket = now
2072+ tr.mu.Unlock()
2073+ close(resumeRebind)
2074+ select {
2075+ case <-firstDone:
2076+ case <-time.After(time.Second):
2077+ t.Fatal("stale rebind did not finish")
2078+ }
2079+ tr.mu.Lock()
2080+ latched := tr.hoppedOffline
2081+ pcAfterStale := tr.pc
2082+ tr.mu.Unlock()
2083+ if latched {
2084+ t.Fatal("stale rebind consumed the future hop allowance")
2085+ }
2086+ if pcAfterStale != aPC {
2087+ t.Fatal("stale rebind replaced the active packet conn")
2088+ }
2089+
2090+ clk.advance(20 * time.Second)
2091+ tr.checkSilence()
2092+ tr.mu.Lock()
2093+ pcAfterRetry := tr.pc
2094+ tr.mu.Unlock()
2095+ if got := rebinds.Load(); got != 2 {
2096+ t.Fatalf("rebind attempts=%d, want a later eligible retry", got)
2097+ }
2098+ if pcAfterRetry == aPC {
2099+ t.Fatal("later stale interval did not hop the packet conn")
2100+ }
2101+}
2102+
2103+func TestLinkHealthStateEventsCannotPublishAfterNewerRecovery(t *testing.T) {
2104+ aPC, bPC := newPair()
2105+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: time.Hour, Heartbeat: time.Hour})
2106+ if err != nil {
2107+ t.Fatal(err)
2108+ }
2109+ defer func() { _ = tr.Close() }()
2110+ defer func() { _ = bPC.Close() }()
2111+
2112+ probingCommitted := make(chan struct{})
2113+ resumeProbing := make(chan struct{})
2114+ tr.mu.Lock()
2115+ tr.linkState = ports.LinkStateDegraded
2116+ tr.afterLinkStateCommit = func(state ports.LinkState) {
2117+ if state != ports.LinkStateProbing {
2118+ return
2119+ }
2120+ close(probingCommitted)
2121+ <-resumeProbing
2122+ }
2123+ tr.mu.Unlock()
2124+
2125+ probingDone := make(chan struct{})
2126+ go func() {
2127+ tr.setLinkState(ports.LinkStateProbing, nil)
2128+ close(probingDone)
2129+ }()
2130+ select {
2131+ case <-probingCommitted:
2132+ case <-time.After(time.Second):
2133+ t.Fatal("probing commit hook was not reached")
2134+ }
2135+ tr.setLinkState(ports.LinkStateConnected, nil)
2136+ close(resumeProbing)
2137+ select {
2138+ case <-probingDone:
2139+ case <-time.After(time.Second):
2140+ t.Fatal("probing publisher did not finish")
2141+ }
2142+
2143+ if got := tr.LinkState(); got != ports.LinkStateConnected {
2144+ t.Fatalf("state=%v, want connected", got)
2145+ }
2146+ select {
2147+ case ev := <-tr.LinkEvents():
2148+ if ev.State != ports.LinkStateConnected {
2149+ t.Fatalf("first event=%v, want connected", ev.State)
2150+ }
2151+ default:
2152+ t.Fatal("missing connected recovery event")
2153+ }
2154+ select {
2155+ case ev := <-tr.LinkEvents():
2156+ t.Fatalf("stale event published after recovery: %v", ev.State)
2157+ default:
2158+ }
2159+}
2160+
2028 func TestHopFiresAtProbingNotDegraded(t *testing.T) {2161 func TestHopFiresAtProbingNotDegraded(t *testing.T) {
2029 aPC, bPC := newPair()2162 aPC, bPC := newPair()
2030 var hopped atomic.Bool2163 var hopped atomic.Bool

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