detached 7f56b4a

fix(dgram): track pending UDP round trips

commit
7f56b4a7041c6056211542a91ee74cfa7b5b071c
author
brice · · 2026-07-10 06:46 UTC
parent
68a2ea2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +110 -5
M internal/adapters/dgram/health.go +19 -2
@@ -12,6 +12,7 @@ type healthTracker struct {
1212 lastPacket time.Time
1313 lastRecord time.Time
1414 lastProgress time.Time
15+ pendingSince time.Time
1516 generation uint64
1617 }
1718
@@ -34,6 +35,16 @@ func (h *healthTracker) ackProgress(now time.Time) {
3435 h.generation++
3536 }
3637
38+func (h *healthTracker) pendingStarted(now time.Time) {
39+ h.pendingSince = now
40+ h.generation++
41+}
42+
43+func (h *healthTracker) pendingCleared() {
44+ h.pendingSince = time.Time{}
45+ h.generation++
46+}
47+
3748 func (h healthTracker) decide(now time.Time, hasPending bool, degradedAfter, probeAfter, offlineAfter, deadAfter time.Duration) (ports.LinkState, bool, bool) {
3849 packetAge := now.Sub(h.lastPacket)
3950 switch {
@@ -50,8 +61,14 @@ func (h healthTracker) decide(now time.Time, hasPending bool, degradedAfter, pro
5061 qualityAt = h.lastProgress
5162 }
5263 qualityAge := now.Sub(qualityAt)
53- if hasPending && probeAfter > 0 && qualityAge >= probeAfter {
54- return ports.LinkStateProbing, true, false
64+ if hasPending && probeAfter > 0 {
65+ pendingBaseline := h.pendingSince
66+ if pendingBaseline.IsZero() || h.lastProgress.After(pendingBaseline) {
67+ pendingBaseline = h.lastProgress
68+ }
69+ if now.Sub(pendingBaseline) >= probeAfter {
70+ return ports.LinkStateProbing, true, false
71+ }
5572 }
5673 if degradedAfter > 0 && qualityAge >= degradedAfter {
5774 return ports.LinkStateDegraded, false, false
@@ -12,6 +12,7 @@ type healthTracker struct {
12 lastPacket time.Time12 lastPacket time.Time
13 lastRecord time.Time13 lastRecord time.Time
14 lastProgress time.Time14 lastProgress time.Time
15+ pendingSince time.Time
15 generation uint6416 generation uint64
16 }17 }
17 18
@@ -34,6 +35,16 @@ func (h *healthTracker) ackProgress(now time.Time) {
34 h.generation++35 h.generation++
35 }36 }
36 37
38+func (h *healthTracker) pendingStarted(now time.Time) {
39+ h.pendingSince = now
40+ h.generation++
41+}
42+
43+func (h *healthTracker) pendingCleared() {
44+ h.pendingSince = time.Time{}
45+ h.generation++
46+}
47+
37 func (h healthTracker) decide(now time.Time, hasPending bool, degradedAfter, probeAfter, offlineAfter, deadAfter time.Duration) (ports.LinkState, bool, bool) {48 func (h healthTracker) decide(now time.Time, hasPending bool, degradedAfter, probeAfter, offlineAfter, deadAfter time.Duration) (ports.LinkState, bool, bool) {
38 packetAge := now.Sub(h.lastPacket)49 packetAge := now.Sub(h.lastPacket)
39 switch {50 switch {
@@ -50,8 +61,14 @@ func (h healthTracker) decide(now time.Time, hasPending bool, degradedAfter, pro
50 qualityAt = h.lastProgress61 qualityAt = h.lastProgress
51 }62 }
52 qualityAge := now.Sub(qualityAt)63 qualityAge := now.Sub(qualityAt)
53- if hasPending && probeAfter > 0 && qualityAge >= probeAfter {64+ if hasPending && probeAfter > 0 {
54- return ports.LinkStateProbing, true, false65+ pendingBaseline := h.pendingSince
66+ if pendingBaseline.IsZero() || h.lastProgress.After(pendingBaseline) {
67+ pendingBaseline = h.lastProgress
68+ }
69+ if now.Sub(pendingBaseline) >= probeAfter {
70+ return ports.LinkStateProbing, true, false
71+ }
55 }72 }
56 if degradedAfter > 0 && qualityAge >= degradedAfter {73 if degradedAfter > 0 && qualityAge >= degradedAfter {
57 return ports.LinkStateDegraded, false, false74 return ports.LinkStateDegraded, false, false
M internal/adapters/dgram/transport.go +12 -2
@@ -395,7 +395,11 @@ func (t *Transport) send(f ports.Frame, async bool) error {
395395 t.seq++
396396 seq := t.seq
397397 if reliable {
398- t.pending[seq] = &pending{frame: f, enqueued: t.clock.Now()}
398+ now := t.clock.Now()
399+ if len(t.pending) == 0 {
400+ t.health.pendingStarted(now)
401+ }
402+ t.pending[seq] = &pending{frame: f, enqueued: now}
399403 }
400404 if shouldPaceOutput(f) {
401405 now := t.clock.Now()
@@ -554,6 +558,9 @@ func (t *Transport) removePendingLocked(seq uint64) bool {
554558 return false
555559 }
556560 delete(t.pending, seq)
561+ if len(t.pending) == 0 {
562+ t.health.pendingCleared()
563+ }
557564 return true
558565 }
559566
@@ -838,6 +845,7 @@ func (t *Transport) readLoop(pc net.PacketConn) {
838845 }
839846 t.recordCompleteRecord()
840847 t.handleRecord(payload)
848+ t.checkSilence()
841849 }
842850 }
843851
@@ -856,7 +864,6 @@ func (t *Transport) recordCompleteRecord() {
856864 t.health.completeRecord(now)
857865 t.lastCompleteRecord = now
858866 t.mu.Unlock()
859- t.setLinkState(ports.LinkStateConnected, nil)
860867 }
861868
862869 func (t *Transport) handleRecord(p []byte) {
@@ -884,6 +891,9 @@ func (t *Transport) handleRecord(p []byte) {
884891 if acked {
885892 now := t.clock.Now()
886893 t.health.ackProgress(now)
894+ if len(t.pending) == 0 {
895+ t.health.pendingCleared()
896+ }
887897 t.lastACKProgress = now
888898 t.notifySendWaitersLocked()
889899 }
@@ -395,7 +395,11 @@ func (t *Transport) send(f ports.Frame, async bool) error {
395 t.seq++395 t.seq++
396 seq := t.seq396 seq := t.seq
397 if reliable {397 if reliable {
398- t.pending[seq] = &pending{frame: f, enqueued: t.clock.Now()}398+ now := t.clock.Now()
399+ if len(t.pending) == 0 {
400+ t.health.pendingStarted(now)
401+ }
402+ t.pending[seq] = &pending{frame: f, enqueued: now}
399 }403 }
400 if shouldPaceOutput(f) {404 if shouldPaceOutput(f) {
401 now := t.clock.Now()405 now := t.clock.Now()
@@ -554,6 +558,9 @@ func (t *Transport) removePendingLocked(seq uint64) bool {
554 return false558 return false
555 }559 }
556 delete(t.pending, seq)560 delete(t.pending, seq)
561+ if len(t.pending) == 0 {
562+ t.health.pendingCleared()
563+ }
557 return true564 return true
558 }565 }
559 566
@@ -838,6 +845,7 @@ func (t *Transport) readLoop(pc net.PacketConn) {
838 }845 }
839 t.recordCompleteRecord()846 t.recordCompleteRecord()
840 t.handleRecord(payload)847 t.handleRecord(payload)
848+ t.checkSilence()
841 }849 }
842 }850 }
843 851
@@ -856,7 +864,6 @@ func (t *Transport) recordCompleteRecord() {
856 t.health.completeRecord(now)864 t.health.completeRecord(now)
857 t.lastCompleteRecord = now865 t.lastCompleteRecord = now
858 t.mu.Unlock()866 t.mu.Unlock()
859- t.setLinkState(ports.LinkStateConnected, nil)
860 }867 }
861 868
862 func (t *Transport) handleRecord(p []byte) {869 func (t *Transport) handleRecord(p []byte) {
@@ -884,6 +891,9 @@ func (t *Transport) handleRecord(p []byte) {
884 if acked {891 if acked {
885 now := t.clock.Now()892 now := t.clock.Now()
886 t.health.ackProgress(now)893 t.health.ackProgress(now)
894+ if len(t.pending) == 0 {
895+ t.health.pendingCleared()
896+ }
887 t.lastACKProgress = now897 t.lastACKProgress = now
888 t.notifySendWaitersLocked()898 t.notifySendWaitersLocked()
889 }899 }
M internal/adapters/dgram/transport_test.go +79 -1
@@ -1843,7 +1843,8 @@ func TestLinkHealthSeparatesPacketContactFromUsefulProgress(t *testing.T) {
18431843 {name: "packet silence probes and hops", packetAge: threshold(20), recordAge: threshold(20), ackAge: threshold(20), wantState: ports.LinkStateProbing, wantHop: true},
18441844 {name: "packet silence becomes offline", packetAge: threshold(30), recordAge: threshold(30), ackAge: threshold(30), wantState: ports.LinkStateOffline},
18451845 {name: "packet silence becomes dead", packetAge: threshold(60), recordAge: threshold(60), ackAge: threshold(60), wantState: ports.LinkStateDead, wantDead: true},
1846- {name: "complete record restores connected", packetAge: 0, recordAge: 0, ackAge: threshold(30), hasPending: true, wantState: ports.LinkStateConnected},
1846+ {name: "complete record cannot mask pending stall", packetAge: 0, recordAge: 0, ackAge: threshold(30), hasPending: true, wantState: ports.LinkStateProbing, wantHop: true},
1847+ {name: "complete record restores connected without pending data", packetAge: 0, recordAge: 0, ackAge: threshold(30), wantState: ports.LinkStateConnected},
18471848 {name: "ack progress restores connected", packetAge: 0, recordAge: threshold(30), ackAge: 0, hasPending: true, wantState: ports.LinkStateConnected},
18481849 }
18491850
@@ -1896,6 +1897,83 @@ func TestLinkHealthPendingStallWithFreshContactProbesOnceWithoutDying(t *testing
18961897 }
18971898 }
18981899
1900+func TestLinkHealthHeartbeatControlCannotMaskPendingACKStall(t *testing.T) {
1901+ aPC, bPC := newPair()
1902+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
1903+ var hops atomic.Int32
1904+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
1905+ Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
1906+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
1907+ attempt := hops.Add(1)
1908+ return &fakePC{addr: testAddr(fmt.Sprintf("a%d", attempt+1)), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}, nil
1909+ },
1910+ })
1911+ if err != nil {
1912+ t.Fatal(err)
1913+ }
1914+ defer func() { _ = tr.Close() }()
1915+ defer func() { _ = bPC.Close() }()
1916+ if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("unacked")}); err != nil {
1917+ t.Fatal(err)
1918+ }
1919+
1920+ codec, err := pdgram.NewCodec(key())
1921+ if err != nil {
1922+ t.Fatal(err)
1923+ }
1924+ authenticated := make(chan struct{}, 32)
1925+ tr.mu.Lock()
1926+ tr.afterAuthenticatedPacket = func() { authenticated <- struct{}{} }
1927+ tr.mu.Unlock()
1928+ var counter uint64
1929+ sendHeartbeatPong := func() {
1930+ t.Helper()
1931+ counter++
1932+ frag := pdgram.Fragment{Seq: counter, Index: 0, Count: 1, Data: probeRecord(recPong, counter)}
1933+ raw, marshalErr := pdgram.MarshalFragment(frag)
1934+ if marshalErr != nil {
1935+ t.Fatal(marshalErr)
1936+ }
1937+ tr.mu.Lock()
1938+ pc := tr.pc.(*fakePC)
1939+ tr.mu.Unlock()
1940+ pc.in <- packet{b: codec.Seal(2, counter, raw, nil), addr: testAddr("b")}
1941+ select {
1942+ case <-authenticated:
1943+ case <-time.After(time.Second):
1944+ t.Fatal("heartbeat pong was not authenticated")
1945+ }
1946+ }
1947+
1948+ for elapsed := 3 * time.Second; elapsed <= 66*time.Second; elapsed += 3 * time.Second {
1949+ clk.advance(3 * time.Second)
1950+ sendHeartbeatPong()
1951+ eventually(t, time.Second, func() bool {
1952+ tr.mu.Lock()
1953+ defer tr.mu.Unlock()
1954+ return tr.health.lastRecord.Equal(clk.Now())
1955+ })
1956+ if elapsed >= 20*time.Second {
1957+ eventually(t, time.Second, func() bool {
1958+ tr.mu.Lock()
1959+ defer tr.mu.Unlock()
1960+ return tr.pc != aPC
1961+ })
1962+ }
1963+ }
1964+ if got := tr.LinkState(); got != ports.LinkStateProbing {
1965+ t.Fatalf("state=%v, want probing despite fresh heartbeat control traffic", got)
1966+ }
1967+ if got := hops.Load(); got != 1 {
1968+ t.Fatalf("socket hops=%d, want one pending round-trip recovery hop", got)
1969+ }
1970+ select {
1971+ case <-tr.done:
1972+ t.Fatal("fresh authenticated control contact must prevent offline/dead closure")
1973+ default:
1974+ }
1975+}
1976+
18991977 func TestLinkHealthOnlyAckProgressThatRemovesPendingRestoresConnected(t *testing.T) {
19001978 tr, _ := newResendTestTransport(t, 10)
19011979 now := tr.clock.Now()
@@ -1843,7 +1843,8 @@ func TestLinkHealthSeparatesPacketContactFromUsefulProgress(t *testing.T) {
1843 {name: "packet silence probes and hops", packetAge: threshold(20), recordAge: threshold(20), ackAge: threshold(20), wantState: ports.LinkStateProbing, wantHop: true},1843 {name: "packet silence probes and hops", packetAge: threshold(20), recordAge: threshold(20), ackAge: threshold(20), wantState: ports.LinkStateProbing, wantHop: true},
1844 {name: "packet silence becomes offline", packetAge: threshold(30), recordAge: threshold(30), ackAge: threshold(30), wantState: ports.LinkStateOffline},1844 {name: "packet silence becomes offline", packetAge: threshold(30), recordAge: threshold(30), ackAge: threshold(30), wantState: ports.LinkStateOffline},
1845 {name: "packet silence becomes dead", packetAge: threshold(60), recordAge: threshold(60), ackAge: threshold(60), wantState: ports.LinkStateDead, wantDead: true},1845 {name: "packet silence becomes dead", packetAge: threshold(60), recordAge: threshold(60), ackAge: threshold(60), wantState: ports.LinkStateDead, wantDead: true},
1846- {name: "complete record restores connected", packetAge: 0, recordAge: 0, ackAge: threshold(30), hasPending: true, wantState: ports.LinkStateConnected},1846+ {name: "complete record cannot mask pending stall", packetAge: 0, recordAge: 0, ackAge: threshold(30), hasPending: true, wantState: ports.LinkStateProbing, wantHop: true},
1847+ {name: "complete record restores connected without pending data", packetAge: 0, recordAge: 0, ackAge: threshold(30), wantState: ports.LinkStateConnected},
1847 {name: "ack progress restores connected", packetAge: 0, recordAge: threshold(30), ackAge: 0, hasPending: true, wantState: ports.LinkStateConnected},1848 {name: "ack progress restores connected", packetAge: 0, recordAge: threshold(30), ackAge: 0, hasPending: true, wantState: ports.LinkStateConnected},
1848 }1849 }
1849 1850
@@ -1896,6 +1897,83 @@ func TestLinkHealthPendingStallWithFreshContactProbesOnceWithoutDying(t *testing
1896 }1897 }
1897 }1898 }
1898 1899
1900+func TestLinkHealthHeartbeatControlCannotMaskPendingACKStall(t *testing.T) {
1901+ aPC, bPC := newPair()
1902+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
1903+ var hops atomic.Int32
1904+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
1905+ Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
1906+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
1907+ attempt := hops.Add(1)
1908+ return &fakePC{addr: testAddr(fmt.Sprintf("a%d", attempt+1)), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}, nil
1909+ },
1910+ })
1911+ if err != nil {
1912+ t.Fatal(err)
1913+ }
1914+ defer func() { _ = tr.Close() }()
1915+ defer func() { _ = bPC.Close() }()
1916+ if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("unacked")}); err != nil {
1917+ t.Fatal(err)
1918+ }
1919+
1920+ codec, err := pdgram.NewCodec(key())
1921+ if err != nil {
1922+ t.Fatal(err)
1923+ }
1924+ authenticated := make(chan struct{}, 32)
1925+ tr.mu.Lock()
1926+ tr.afterAuthenticatedPacket = func() { authenticated <- struct{}{} }
1927+ tr.mu.Unlock()
1928+ var counter uint64
1929+ sendHeartbeatPong := func() {
1930+ t.Helper()
1931+ counter++
1932+ frag := pdgram.Fragment{Seq: counter, Index: 0, Count: 1, Data: probeRecord(recPong, counter)}
1933+ raw, marshalErr := pdgram.MarshalFragment(frag)
1934+ if marshalErr != nil {
1935+ t.Fatal(marshalErr)
1936+ }
1937+ tr.mu.Lock()
1938+ pc := tr.pc.(*fakePC)
1939+ tr.mu.Unlock()
1940+ pc.in <- packet{b: codec.Seal(2, counter, raw, nil), addr: testAddr("b")}
1941+ select {
1942+ case <-authenticated:
1943+ case <-time.After(time.Second):
1944+ t.Fatal("heartbeat pong was not authenticated")
1945+ }
1946+ }
1947+
1948+ for elapsed := 3 * time.Second; elapsed <= 66*time.Second; elapsed += 3 * time.Second {
1949+ clk.advance(3 * time.Second)
1950+ sendHeartbeatPong()
1951+ eventually(t, time.Second, func() bool {
1952+ tr.mu.Lock()
1953+ defer tr.mu.Unlock()
1954+ return tr.health.lastRecord.Equal(clk.Now())
1955+ })
1956+ if elapsed >= 20*time.Second {
1957+ eventually(t, time.Second, func() bool {
1958+ tr.mu.Lock()
1959+ defer tr.mu.Unlock()
1960+ return tr.pc != aPC
1961+ })
1962+ }
1963+ }
1964+ if got := tr.LinkState(); got != ports.LinkStateProbing {
1965+ t.Fatalf("state=%v, want probing despite fresh heartbeat control traffic", got)
1966+ }
1967+ if got := hops.Load(); got != 1 {
1968+ t.Fatalf("socket hops=%d, want one pending round-trip recovery hop", got)
1969+ }
1970+ select {
1971+ case <-tr.done:
1972+ t.Fatal("fresh authenticated control contact must prevent offline/dead closure")
1973+ default:
1974+ }
1975+}
1976+
1899 func TestLinkHealthOnlyAckProgressThatRemovesPendingRestoresConnected(t *testing.T) {1977 func TestLinkHealthOnlyAckProgressThatRemovesPendingRestoresConnected(t *testing.T) {
1900 tr, _ := newResendTestTransport(t, 10)1978 tr, _ := newResendTestTransport(t, 10)
1901 now := tr.clock.Now()1979 now := tr.clock.Now()

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