detached ba81cef

fix(dgram): move UDP control writes off receive loop

commit
ba81cef3e8a28345bfa33eb363703d43415c844c
author
brice · · 2026-07-10 00:31 UTC
parent
eac5808
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +139 -27
A internal/adapters/dgram/control.go +112 -0
@@ -0,0 +1,112 @@
1+package dgram
2+
3+import (
4+ "encoding/binary"
5+ "runtime"
6+
7+ pdgram "github.com/bnema/vev/pkg/dgram"
8+)
9+
10+const controlQueueSize = 16
11+
12+type controlRecord struct {
13+ kind byte
14+ id uint64
15+}
16+
17+// queueACK retains the greatest cumulative acknowledgement without ever making
18+// packet receipt wait for a socket write.
19+func (t *Transport) queueACK(seq uint64) {
20+ t.controlMu.Lock()
21+ if !t.ackQueued || seq > t.ackSeq {
22+ t.ackSeq = seq
23+ t.ackQueued = true
24+ }
25+ t.controlMu.Unlock()
26+ if t.ackWake == nil {
27+ // Small unit-test transports may exercise record handling without the
28+ // goroutines installed by NewTransportWithOptions.
29+ go t.sendAck(seq)
30+ return
31+ }
32+ select {
33+ case t.ackWake <- struct{}{}:
34+ default:
35+ }
36+ // Let the dedicated writer claim queued control before delivery wakes a
37+ // consumer; this remains a non-blocking receive-path operation.
38+ runtime.Gosched()
39+}
40+
41+// queueControl is deliberately lossy when the bounded control queue is full:
42+// probes and pongs are advisory and must not stall authenticated receipt.
43+func (t *Transport) queueControl(kind byte, id uint64) bool {
44+ select {
45+ case t.control <- controlRecord{kind: kind, id: id}:
46+ return true
47+ default:
48+ return false
49+ }
50+}
51+
52+func (t *Transport) takeACK() (uint64, bool) {
53+ t.controlMu.Lock()
54+ defer t.controlMu.Unlock()
55+ seq, ok := t.ackSeq, t.ackQueued
56+ t.ackSeq = 0
57+ t.ackQueued = false
58+ return seq, ok
59+}
60+
61+func (t *Transport) controlLoop() {
62+ for {
63+ // Probes and pongs always win over an ACK that is ready to send.
64+ select {
65+ case record := <-t.control:
66+ t.sendControl(record.kind, record.id)
67+ continue
68+ default:
69+ }
70+
71+ select {
72+ case record := <-t.control:
73+ t.sendControl(record.kind, record.id)
74+ case <-t.ackWake:
75+ if seq, ok := t.takeACK(); ok {
76+ t.sendControl(recAck, seq)
77+ }
78+ case <-t.done:
79+ return
80+ }
81+ }
82+}
83+
84+func (t *Transport) sendControl(kind byte, id uint64) {
85+ var payload [9]byte
86+ payload[0] = kind
87+ binary.BigEndian.PutUint64(payload[1:], id)
88+ frags, err := pdgram.FragmentPayload(t.nextCounter(), payload[:], t.mtu-pdgram.HeaderSize-t.codec.Overhead())
89+ if err != nil {
90+ return
91+ }
92+ for _, frag := range frags {
93+ raw, err := pdgram.MarshalFragment(frag)
94+ if err != nil {
95+ return
96+ }
97+ pkt := t.codec.Seal(t.sendDir, t.nextCounter(), raw, nil)
98+ t.mu.Lock()
99+ pc := t.pc
100+ peer := t.peer
101+ closed := t.closed
102+ t.mu.Unlock()
103+ if closed || peer == nil {
104+ return
105+ }
106+ // PacketConn permits concurrent callers. Control traffic deliberately
107+ // bypasses data's write lock so a retransmit cannot starve heartbeats.
108+ if _, err := pc.WriteTo(pkt, peer); err != nil {
109+ return
110+ }
111+ }
112+}
@@ -0,0 +1,112 @@
1+package dgram
2+
3+import (
4+ "encoding/binary"
5+ "runtime"
6+
7+ pdgram "github.com/bnema/vev/pkg/dgram"
8+)
9+
10+const controlQueueSize = 16
11+
12+type controlRecord struct {
13+ kind byte
14+ id uint64
15+}
16+
17+// queueACK retains the greatest cumulative acknowledgement without ever making
18+// packet receipt wait for a socket write.
19+func (t *Transport) queueACK(seq uint64) {
20+ t.controlMu.Lock()
21+ if !t.ackQueued || seq > t.ackSeq {
22+ t.ackSeq = seq
23+ t.ackQueued = true
24+ }
25+ t.controlMu.Unlock()
26+ if t.ackWake == nil {
27+ // Small unit-test transports may exercise record handling without the
28+ // goroutines installed by NewTransportWithOptions.
29+ go t.sendAck(seq)
30+ return
31+ }
32+ select {
33+ case t.ackWake <- struct{}{}:
34+ default:
35+ }
36+ // Let the dedicated writer claim queued control before delivery wakes a
37+ // consumer; this remains a non-blocking receive-path operation.
38+ runtime.Gosched()
39+}
40+
41+// queueControl is deliberately lossy when the bounded control queue is full:
42+// probes and pongs are advisory and must not stall authenticated receipt.
43+func (t *Transport) queueControl(kind byte, id uint64) bool {
44+ select {
45+ case t.control <- controlRecord{kind: kind, id: id}:
46+ return true
47+ default:
48+ return false
49+ }
50+}
51+
52+func (t *Transport) takeACK() (uint64, bool) {
53+ t.controlMu.Lock()
54+ defer t.controlMu.Unlock()
55+ seq, ok := t.ackSeq, t.ackQueued
56+ t.ackSeq = 0
57+ t.ackQueued = false
58+ return seq, ok
59+}
60+
61+func (t *Transport) controlLoop() {
62+ for {
63+ // Probes and pongs always win over an ACK that is ready to send.
64+ select {
65+ case record := <-t.control:
66+ t.sendControl(record.kind, record.id)
67+ continue
68+ default:
69+ }
70+
71+ select {
72+ case record := <-t.control:
73+ t.sendControl(record.kind, record.id)
74+ case <-t.ackWake:
75+ if seq, ok := t.takeACK(); ok {
76+ t.sendControl(recAck, seq)
77+ }
78+ case <-t.done:
79+ return
80+ }
81+ }
82+}
83+
84+func (t *Transport) sendControl(kind byte, id uint64) {
85+ var payload [9]byte
86+ payload[0] = kind
87+ binary.BigEndian.PutUint64(payload[1:], id)
88+ frags, err := pdgram.FragmentPayload(t.nextCounter(), payload[:], t.mtu-pdgram.HeaderSize-t.codec.Overhead())
89+ if err != nil {
90+ return
91+ }
92+ for _, frag := range frags {
93+ raw, err := pdgram.MarshalFragment(frag)
94+ if err != nil {
95+ return
96+ }
97+ pkt := t.codec.Seal(t.sendDir, t.nextCounter(), raw, nil)
98+ t.mu.Lock()
99+ pc := t.pc
100+ peer := t.peer
101+ closed := t.closed
102+ t.mu.Unlock()
103+ if closed || peer == nil {
104+ return
105+ }
106+ // PacketConn permits concurrent callers. Control traffic deliberately
107+ // bypasses data's write lock so a retransmit cannot starve heartbeats.
108+ if _, err := pc.WriteTo(pkt, peer); err != nil {
109+ return
110+ }
111+ }
112+}
M internal/adapters/dgram/transport.go +24 -25
@@ -41,7 +41,7 @@ const (
4141 dataRecordHeaderSize = 12
4242 maxRecvBuffer = defaultMaxRecvBuf
4343 linkEventBufferSize = 16
44- probeReplyBufferSize = 16
44+ probeReplyBufferSize = controlQueueSize // legacy test burst bound
4545 )
4646
4747 var (
@@ -181,7 +181,11 @@ type Transport struct {
181181 linkEvents chan ports.LinkEvent
182182 probeWait map[uint64]chan struct{}
183183 sendWake chan struct{}
184- probeReply chan uint64
184+ control chan controlRecord
185+ ackWake chan struct{}
186+ controlMu sync.Mutex
187+ ackSeq uint64
188+ ackQueued bool
185189 rebind func(net.PacketConn) (net.PacketConn, error)
186190 hoppedOffline bool
187191 outputQueue []queuedSend
@@ -269,17 +273,19 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD
269273 recvBuf: make(map[uint64]ports.Frame),
270274 }
271275 t.sendWake = make(chan struct{})
276+ t.control = make(chan controlRecord, controlQueueSize)
277+ t.ackWake = make(chan struct{}, 1)
272278 t.outputWake = make(chan struct{})
273279 if t.observe != nil {
274280 t.diagnosticCh = make(chan Diagnostic, diagnosticBufferSize)
275281 go t.diagnosticLoop()
276282 }
277- t.probeReply = make(chan uint64, probeReplyBufferSize)
278283 t.deliverCond = sync.NewCond(&t.deliverMu)
279284 go t.readLoop(pc)
280285 go t.resendLoop()
286+ go t.heartbeatLoop()
281287 go t.deliveryLoop()
282- go t.probeReplyLoop()
288+ go t.controlLoop()
283289 go t.outputPaceLoop()
284290 t.emitDiagnostic()
285291 return t, nil
@@ -777,10 +783,7 @@ func (t *Transport) handleRecord(p []byte) {
777783 if len(p) != 9 {
778784 return
779785 }
780- select {
781- case t.probeReply <- binary.BigEndian.Uint64(p[1:]):
782- default:
783- }
786+ t.queueControl(recPong, binary.BigEndian.Uint64(p[1:]))
784787 case recPong:
785788 if len(p) != 9 {
786789 return
@@ -801,7 +804,7 @@ func (t *Transport) handleRecord(p []byte) {
801804 if reliable {
802805 ackSeq, ack, queued := t.enqueueReliable(seq, f)
803806 if ack {
804- t.sendAck(ackSeq)
807+ t.queueACK(ackSeq)
805808 }
806809 if queued {
807810 t.signalDelivery()
@@ -878,17 +881,6 @@ func (t *Transport) deliver(f ports.Frame) {
878881 }
879882 }
880883
881-func (t *Transport) probeReplyLoop() {
882- for {
883- select {
884- case id := <-t.probeReply:
885- _ = t.sendProbe(recPong, id)
886- case <-t.done:
887- return
888- }
889- }
890-}
891-
892884 func (t *Transport) notifySendWaitersLocked() {
893885 if t.sendWake == nil {
894886 return
@@ -900,8 +892,6 @@ func (t *Transport) notifySendWaitersLocked() {
900892 func (t *Transport) resendLoop() {
901893 resend := t.clock.NewTimer(t.resendAfter)
902894 defer resend.Stop()
903- heartbeat := t.clock.NewTimer(t.heartbeat)
904- defer heartbeat.Stop()
905895 for {
906896 select {
907897 case <-resend.C():
@@ -909,10 +899,19 @@ func (t *Transport) resendLoop() {
909899 t.checkSilence()
910900 t.checkSendStall()
911901 resend.Reset(t.resendAfter)
902+ case <-t.done:
903+ return
904+ }
905+ }
906+}
907+
908+func (t *Transport) heartbeatLoop() {
909+ heartbeat := t.clock.NewTimer(t.heartbeat)
910+ defer heartbeat.Stop()
911+ for {
912+ select {
912913 case <-heartbeat.C():
913- _ = t.sendProbe(recProbe, 0)
914- t.checkSilence()
915- t.checkSendStall()
914+ t.queueControl(recProbe, 0)
916915 heartbeat.Reset(t.heartbeat)
917916 case <-t.done:
918917 return
@@ -41,7 +41,7 @@ const (
41 dataRecordHeaderSize = 1241 dataRecordHeaderSize = 12
42 maxRecvBuffer = defaultMaxRecvBuf42 maxRecvBuffer = defaultMaxRecvBuf
43 linkEventBufferSize = 1643 linkEventBufferSize = 16
44- probeReplyBufferSize = 1644+ probeReplyBufferSize = controlQueueSize // legacy test burst bound
45 )45 )
46 46
47 var (47 var (
@@ -181,7 +181,11 @@ type Transport struct {
181 linkEvents chan ports.LinkEvent181 linkEvents chan ports.LinkEvent
182 probeWait map[uint64]chan struct{}182 probeWait map[uint64]chan struct{}
183 sendWake chan struct{}183 sendWake chan struct{}
184- probeReply chan uint64184+ control chan controlRecord
185+ ackWake chan struct{}
186+ controlMu sync.Mutex
187+ ackSeq uint64
188+ ackQueued bool
185 rebind func(net.PacketConn) (net.PacketConn, error)189 rebind func(net.PacketConn) (net.PacketConn, error)
186 hoppedOffline bool190 hoppedOffline bool
187 outputQueue []queuedSend191 outputQueue []queuedSend
@@ -269,17 +273,19 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD
269 recvBuf: make(map[uint64]ports.Frame),273 recvBuf: make(map[uint64]ports.Frame),
270 }274 }
271 t.sendWake = make(chan struct{})275 t.sendWake = make(chan struct{})
276+ t.control = make(chan controlRecord, controlQueueSize)
277+ t.ackWake = make(chan struct{}, 1)
272 t.outputWake = make(chan struct{})278 t.outputWake = make(chan struct{})
273 if t.observe != nil {279 if t.observe != nil {
274 t.diagnosticCh = make(chan Diagnostic, diagnosticBufferSize)280 t.diagnosticCh = make(chan Diagnostic, diagnosticBufferSize)
275 go t.diagnosticLoop()281 go t.diagnosticLoop()
276 }282 }
277- t.probeReply = make(chan uint64, probeReplyBufferSize)
278 t.deliverCond = sync.NewCond(&t.deliverMu)283 t.deliverCond = sync.NewCond(&t.deliverMu)
279 go t.readLoop(pc)284 go t.readLoop(pc)
280 go t.resendLoop()285 go t.resendLoop()
286+ go t.heartbeatLoop()
281 go t.deliveryLoop()287 go t.deliveryLoop()
282- go t.probeReplyLoop()288+ go t.controlLoop()
283 go t.outputPaceLoop()289 go t.outputPaceLoop()
284 t.emitDiagnostic()290 t.emitDiagnostic()
285 return t, nil291 return t, nil
@@ -777,10 +783,7 @@ func (t *Transport) handleRecord(p []byte) {
777 if len(p) != 9 {783 if len(p) != 9 {
778 return784 return
779 }785 }
780- select {786+ t.queueControl(recPong, binary.BigEndian.Uint64(p[1:]))
781- case t.probeReply <- binary.BigEndian.Uint64(p[1:]):
782- default:
783- }
784 case recPong:787 case recPong:
785 if len(p) != 9 {788 if len(p) != 9 {
786 return789 return
@@ -801,7 +804,7 @@ func (t *Transport) handleRecord(p []byte) {
801 if reliable {804 if reliable {
802 ackSeq, ack, queued := t.enqueueReliable(seq, f)805 ackSeq, ack, queued := t.enqueueReliable(seq, f)
803 if ack {806 if ack {
804- t.sendAck(ackSeq)807+ t.queueACK(ackSeq)
805 }808 }
806 if queued {809 if queued {
807 t.signalDelivery()810 t.signalDelivery()
@@ -878,17 +881,6 @@ func (t *Transport) deliver(f ports.Frame) {
878 }881 }
879 }882 }
880 883
881-func (t *Transport) probeReplyLoop() {
882- for {
883- select {
884- case id := <-t.probeReply:
885- _ = t.sendProbe(recPong, id)
886- case <-t.done:
887- return
888- }
889- }
890-}
891-
892 func (t *Transport) notifySendWaitersLocked() {884 func (t *Transport) notifySendWaitersLocked() {
893 if t.sendWake == nil {885 if t.sendWake == nil {
894 return886 return
@@ -900,8 +892,6 @@ func (t *Transport) notifySendWaitersLocked() {
900 func (t *Transport) resendLoop() {892 func (t *Transport) resendLoop() {
901 resend := t.clock.NewTimer(t.resendAfter)893 resend := t.clock.NewTimer(t.resendAfter)
902 defer resend.Stop()894 defer resend.Stop()
903- heartbeat := t.clock.NewTimer(t.heartbeat)
904- defer heartbeat.Stop()
905 for {895 for {
906 select {896 select {
907 case <-resend.C():897 case <-resend.C():
@@ -909,10 +899,19 @@ func (t *Transport) resendLoop() {
909 t.checkSilence()899 t.checkSilence()
910 t.checkSendStall()900 t.checkSendStall()
911 resend.Reset(t.resendAfter)901 resend.Reset(t.resendAfter)
902+ case <-t.done:
903+ return
904+ }
905+ }
906+}
907+
908+func (t *Transport) heartbeatLoop() {
909+ heartbeat := t.clock.NewTimer(t.heartbeat)
910+ defer heartbeat.Stop()
911+ for {
912+ select {
912 case <-heartbeat.C():913 case <-heartbeat.C():
913- _ = t.sendProbe(recProbe, 0)914+ t.queueControl(recProbe, 0)
914- t.checkSilence()
915- t.checkSendStall()
916 heartbeat.Reset(t.heartbeat)915 heartbeat.Reset(t.heartbeat)
917 case <-t.done:916 case <-t.done:
918 return917 return
M internal/adapters/dgram/transport_test.go +3 -2
@@ -790,9 +790,10 @@ func TestReliableDuplicateAckedButNotDeliveredTwice(t *testing.T) {
790790 t.Fatal(err)
791791 }
792792 got := recvWithin(t, b, time.Second)
793- if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !droppedAck.Load() {
794- t.Fatalf("got=%+v droppedAck=%v", got, droppedAck.Load())
793+ if got.Type != ports.MsgInput || string(got.Payload) != "typed" {
794+ t.Fatalf("got=%+v", got)
795795 }
796+ eventually(t, time.Second, droppedAck.Load)
796797 if got, ok := recvMaybe(b, 3*defaultResend); ok {
797798 t.Fatalf("duplicate reliable frame delivered: %+v", got)
798799 }
@@ -790,9 +790,10 @@ func TestReliableDuplicateAckedButNotDeliveredTwice(t *testing.T) {
790 t.Fatal(err)790 t.Fatal(err)
791 }791 }
792 got := recvWithin(t, b, time.Second)792 got := recvWithin(t, b, time.Second)
793- if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !droppedAck.Load() {793+ if got.Type != ports.MsgInput || string(got.Payload) != "typed" {
794- t.Fatalf("got=%+v droppedAck=%v", got, droppedAck.Load())794+ t.Fatalf("got=%+v", got)
795 }795 }
796+ eventually(t, time.Second, droppedAck.Load)
796 if got, ok := recvMaybe(b, 3*defaultResend); ok {797 if got, ok := recvMaybe(b, 3*defaultResend); ok {
797 t.Fatalf("duplicate reliable frame delivered: %+v", got)798 t.Fatalf("duplicate reliable frame delivered: %+v", got)
798 }799 }

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