detached 3b58b7b

fix(remote): reduce UDP output backlog

commit
3b58b7b7042d182b4cef42547912ffb1f8d996a2
author
brice · · 2026-07-08 09:04 UTC
parent
f08136d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +321 -64
M internal/adapters/dgram/proxy.go +2 -0
@@ -207,6 +207,8 @@ func recoverableClientLink(t ports.Transport, err error) bool {
207207 return false
208208 }
209209 switch reporter.LinkState() {
210+ case ports.LinkStateConnected:
211+ return errors.Is(err, ErrPendingFull)
210212 case ports.LinkStateDegraded, ports.LinkStateProbing, ports.LinkStateOffline:
211213 return true
212214 default:
@@ -207,6 +207,8 @@ func recoverableClientLink(t ports.Transport, err error) bool {
207 return false207 return false
208 }208 }
209 switch reporter.LinkState() {209 switch reporter.LinkState() {
210+ case ports.LinkStateConnected:
211+ return errors.Is(err, ErrPendingFull)
210 case ports.LinkStateDegraded, ports.LinkStateProbing, ports.LinkStateOffline:212 case ports.LinkStateDegraded, ports.LinkStateProbing, ports.LinkStateOffline:
211 return true213 return true
212 default:214 default:
M internal/adapters/dgram/proxy_test.go +39 -25
@@ -116,32 +116,46 @@ func TestProxyRuntimeRestartsRecoverableClientReceiveUntilIdleTTL(t *testing.T)
116116 }
117117
118118 func TestProxyRuntimeRetriesRecoverableClientSendWithoutDroppingFrame(t *testing.T) {
119- client := newFakeTransport()
120- daemon := newFakeTransport()
121- client.linkState = ports.LinkStateOffline
122- client.linkEvents <- ports.LinkEvent{State: ports.LinkStateOffline}
123- errCh := make(chan error, 1)
124- go func() {
125- errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: 50 * time.Millisecond, RetryBackoff: time.Nanosecond}.Run(t.Context())
126- }()
127- client.sendErr <- ErrPendingFull
128- daemon.recv <- recvResult{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("preserved during outage")}}
129- select {
130- case got := <-client.sent:
131- if got.Type != ports.MsgOutput || string(got.Payload) != "preserved during outage" {
132- t.Fatalf("client got %+v, want retried output", got)
133- }
134- case <-time.After(time.Second):
135- t.Fatal("timeout waiting for recovered daemon output")
119+ tests := []struct {
120+ name string
121+ state ports.LinkState
122+ linkEvent bool
123+ }{
124+ {name: "connected pending full", state: ports.LinkStateConnected},
125+ {name: "offline pending full", state: ports.LinkStateOffline, linkEvent: true},
136126 }
137- daemon.recv <- recvResult{err: io.EOF}
138- select {
139- case err := <-errCh:
140- if err != nil {
141- t.Fatalf("Run err=%v, want nil", err)
142- }
143- case <-time.After(time.Second):
144- t.Fatal("timeout waiting for daemon EOF")
127+ for _, tt := range tests {
128+ t.Run(tt.name, func(t *testing.T) {
129+ client := newFakeTransport()
130+ daemon := newFakeTransport()
131+ client.linkState = tt.state
132+ if tt.linkEvent {
133+ client.linkEvents <- ports.LinkEvent{State: tt.state}
134+ }
135+ errCh := make(chan error, 1)
136+ go func() {
137+ errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: 50 * time.Millisecond, RetryBackoff: time.Nanosecond}.Run(t.Context())
138+ }()
139+ client.sendErr <- ErrPendingFull
140+ daemon.recv <- recvResult{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("preserved during outage")}}
141+ select {
142+ case got := <-client.sent:
143+ if got.Type != ports.MsgOutput || string(got.Payload) != "preserved during outage" {
144+ t.Fatalf("client got %+v, want retried output", got)
145+ }
146+ case <-time.After(time.Second):
147+ t.Fatal("timeout waiting for recovered daemon output")
148+ }
149+ daemon.recv <- recvResult{err: io.EOF}
150+ select {
151+ case err := <-errCh:
152+ if err != nil {
153+ t.Fatalf("Run err=%v, want nil", err)
154+ }
155+ case <-time.After(time.Second):
156+ t.Fatal("timeout waiting for daemon EOF")
157+ }
158+ })
145159 }
146160 }
147161
@@ -116,32 +116,46 @@ func TestProxyRuntimeRestartsRecoverableClientReceiveUntilIdleTTL(t *testing.T)
116 }116 }
117 117
118 func TestProxyRuntimeRetriesRecoverableClientSendWithoutDroppingFrame(t *testing.T) {118 func TestProxyRuntimeRetriesRecoverableClientSendWithoutDroppingFrame(t *testing.T) {
119- client := newFakeTransport()119+ tests := []struct {
120- daemon := newFakeTransport()120+ name string
121- client.linkState = ports.LinkStateOffline121+ state ports.LinkState
122- client.linkEvents <- ports.LinkEvent{State: ports.LinkStateOffline}122+ linkEvent bool
123- errCh := make(chan error, 1)123+ }{
124- go func() {124+ {name: "connected pending full", state: ports.LinkStateConnected},
125- errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: 50 * time.Millisecond, RetryBackoff: time.Nanosecond}.Run(t.Context())125+ {name: "offline pending full", state: ports.LinkStateOffline, linkEvent: true},
126- }()
127- client.sendErr <- ErrPendingFull
128- daemon.recv <- recvResult{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("preserved during outage")}}
129- select {
130- case got := <-client.sent:
131- if got.Type != ports.MsgOutput || string(got.Payload) != "preserved during outage" {
132- t.Fatalf("client got %+v, want retried output", got)
133- }
134- case <-time.After(time.Second):
135- t.Fatal("timeout waiting for recovered daemon output")
136 }126 }
137- daemon.recv <- recvResult{err: io.EOF}127+ for _, tt := range tests {
138- select {128+ t.Run(tt.name, func(t *testing.T) {
139- case err := <-errCh:129+ client := newFakeTransport()
140- if err != nil {130+ daemon := newFakeTransport()
141- t.Fatalf("Run err=%v, want nil", err)131+ client.linkState = tt.state
142- }132+ if tt.linkEvent {
143- case <-time.After(time.Second):133+ client.linkEvents <- ports.LinkEvent{State: tt.state}
144- t.Fatal("timeout waiting for daemon EOF")134+ }
135+ errCh := make(chan error, 1)
136+ go func() {
137+ errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: 50 * time.Millisecond, RetryBackoff: time.Nanosecond}.Run(t.Context())
138+ }()
139+ client.sendErr <- ErrPendingFull
140+ daemon.recv <- recvResult{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("preserved during outage")}}
141+ select {
142+ case got := <-client.sent:
143+ if got.Type != ports.MsgOutput || string(got.Payload) != "preserved during outage" {
144+ t.Fatalf("client got %+v, want retried output", got)
145+ }
146+ case <-time.After(time.Second):
147+ t.Fatal("timeout waiting for recovered daemon output")
148+ }
149+ daemon.recv <- recvResult{err: io.EOF}
150+ select {
151+ case err := <-errCh:
152+ if err != nil {
153+ t.Fatalf("Run err=%v, want nil", err)
154+ }
155+ case <-time.After(time.Second):
156+ t.Fatal("timeout waiting for daemon EOF")
157+ }
158+ })
145 }159 }
146 }160 }
147 161
M internal/adapters/dgram/transport.go +65 -16
@@ -6,7 +6,7 @@ import (
66 "encoding/binary"
77 "errors"
88 "net"
9- "sort"
9+ "slices"
1010 "sync"
1111 "time"
1212
@@ -31,9 +31,11 @@ const (
3131 defaultOffline = 30 * time.Second
3232 defaultDead = 60 * time.Second
3333 defaultMaxPending = 1024
34+ defaultMaxPendingWait = 50 * time.Millisecond
3435 defaultMaxRecvBuf = 1024
3536 maxRecvBuffer = defaultMaxRecvBuf
3637 linkEventBufferSize = 16
38+ probeReplyBufferSize = 16
3739 )
3840
3941 var (
@@ -53,6 +55,7 @@ type Options struct {
5355 OfflineAfter time.Duration
5456 DeadAfter time.Duration
5557 MaxPending int
58+ MaxPendingWait time.Duration
5659 MaxRecvBuffer int
5760 Clock ports.Clock
5861 // RebindPacketConn is an optional client-side hook used to hop the local UDP
@@ -110,6 +113,9 @@ func normalizeOptions(opts Options) Options {
110113 if opts.MaxPending <= 0 {
111114 opts.MaxPending = defaultMaxPending
112115 }
116+ if opts.MaxPendingWait <= 0 {
117+ opts.MaxPendingWait = defaultMaxPendingWait
118+ }
113119 if opts.MaxRecvBuffer <= 0 {
114120 opts.MaxRecvBuffer = defaultMaxRecvBuf
115121 }
@@ -144,12 +150,14 @@ type Transport struct {
144150 offlineAfter time.Duration
145151 deadAfter time.Duration
146152 maxPending int
153+ maxPendingWait time.Duration
147154 maxRecvBuffer int
148155 clock ports.Clock
149156 linkState ports.LinkState
150157 linkEvents chan ports.LinkEvent
151158 probeWait map[uint64]chan struct{}
152- sendCond *sync.Cond
159+ sendWake chan struct{}
160+ probeReply chan uint64
153161 rebind func(net.PacketConn) (net.PacketConn, error)
154162 hoppedOffline bool
155163
@@ -183,12 +191,14 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD
183191 return nil, err
184192 }
185193 opts = normalizeOptions(opts)
186- t := &Transport{pc: pc, codec: c, sendDir: sendDir, recvDir: recvDir, mtu: opts.MTU, peer: peer, pending: make(map[uint64]*pending), replay: pdgram.NewReplayWindow(), reasm: pdgram.NewReassembler(), in: make(chan ports.Frame, 32), done: make(chan struct{}), lastHeard: opts.Clock.Now(), heartbeat: opts.Heartbeat, resendAfter: opts.ResendAfter, maxResendAfter: opts.MaxResendAfter, maxResendPerTick: opts.MaxResendPerTick, writeTimeout: opts.WriteTimeout, degradedAfter: opts.DegradedAfter, probeAfter: opts.ProbeAfter, offlineAfter: opts.OfflineAfter, deadAfter: opts.DeadAfter, maxPending: opts.MaxPending, maxRecvBuffer: opts.MaxRecvBuffer, clock: opts.Clock, linkState: ports.LinkStateConnected, linkEvents: make(chan ports.LinkEvent, linkEventBufferSize), probeWait: make(map[uint64]chan struct{}), rebind: opts.RebindPacketConn, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
194+ t := &Transport{pc: pc, codec: c, sendDir: sendDir, recvDir: recvDir, mtu: opts.MTU, peer: peer, pending: make(map[uint64]*pending), replay: pdgram.NewReplayWindow(), reasm: pdgram.NewReassembler(), in: make(chan ports.Frame, 32), done: make(chan struct{}), lastHeard: opts.Clock.Now(), heartbeat: opts.Heartbeat, resendAfter: opts.ResendAfter, maxResendAfter: opts.MaxResendAfter, maxResendPerTick: opts.MaxResendPerTick, writeTimeout: opts.WriteTimeout, degradedAfter: opts.DegradedAfter, probeAfter: opts.ProbeAfter, offlineAfter: opts.OfflineAfter, deadAfter: opts.DeadAfter, maxPending: opts.MaxPending, maxPendingWait: opts.MaxPendingWait, maxRecvBuffer: opts.MaxRecvBuffer, clock: opts.Clock, linkState: ports.LinkStateConnected, linkEvents: make(chan ports.LinkEvent, linkEventBufferSize), probeWait: make(map[uint64]chan struct{}), rebind: opts.RebindPacketConn, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
195+ t.sendWake = make(chan struct{}, 1)
196+ t.probeReply = make(chan uint64, probeReplyBufferSize)
187197 t.deliverCond = sync.NewCond(&t.deliverMu)
188- t.sendCond = sync.NewCond(&t.mu)
189198 go t.readLoop(pc)
190199 go t.resendLoop()
191200 go t.deliveryLoop()
201+ go t.probeReplyLoop()
192202 return t, nil
193203 }
194204
@@ -197,12 +207,29 @@ func (t *Transport) Send(f ports.Frame) error {
197207 // dropping MsgOutput can permanently desynchronize the client screen.
198208 reliable := true
199209 t.mu.Lock()
210+ var pendingWaitStarted time.Time
200211 for reliable && len(t.pending) >= t.maxPending && !t.closed {
201212 if t.linkState != ports.LinkStateConnected {
202213 t.mu.Unlock()
203214 return ErrPendingFull
204215 }
205- t.sendCond.Wait()
216+ if pendingWaitStarted.IsZero() {
217+ pendingWaitStarted = t.clock.Now()
218+ }
219+ remaining := t.maxPendingWait - t.clock.Now().Sub(pendingWaitStarted)
220+ if remaining <= 0 {
221+ t.mu.Unlock()
222+ return ErrPendingFull
223+ }
224+ timer := t.clock.NewTimer(remaining)
225+ t.mu.Unlock()
226+ select {
227+ case <-timer.C():
228+ case <-t.sendWake:
229+ case <-t.done:
230+ }
231+ timer.Stop()
232+ t.mu.Lock()
206233 }
207234 if t.closed {
208235 err := t.closeErr
@@ -360,7 +387,7 @@ func (t *Transport) writePacket(pkt []byte, peer net.Addr) error {
360387 t.mu.Unlock()
361388
362389 if writeTimeout > 0 {
363- if err := pc.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil {
390+ if err := pc.SetWriteDeadline(t.clock.Now().Add(writeTimeout)); err != nil {
364391 return err
365392 }
366393 defer func() { _ = pc.SetWriteDeadline(time.Time{}) }()
@@ -428,16 +455,17 @@ func (t *Transport) handleRecord(p []byte) {
428455 t.mu.Lock()
429456 if _, ok := t.pending[seq]; ok {
430457 delete(t.pending, seq)
431- if t.sendCond != nil {
432- t.sendCond.Signal()
433- }
458+ t.notifySendWaiter()
434459 }
435460 t.mu.Unlock()
436461 case recProbe:
437462 if len(p) != 9 {
438463 return
439464 }
440- _ = t.sendProbe(recPong, binary.BigEndian.Uint64(p[1:]))
465+ select {
466+ case t.probeReply <- binary.BigEndian.Uint64(p[1:]):
467+ default:
468+ }
441469 case recPong:
442470 if len(p) != 9 {
443471 return
@@ -523,6 +551,24 @@ func (t *Transport) deliver(f ports.Frame) {
523551 }
524552 }
525553
554+func (t *Transport) probeReplyLoop() {
555+ for {
556+ select {
557+ case id := <-t.probeReply:
558+ _ = t.sendProbe(recPong, id)
559+ case <-t.done:
560+ return
561+ }
562+ }
563+}
564+
565+func (t *Transport) notifySendWaiter() {
566+ select {
567+ case t.sendWake <- struct{}{}:
568+ default:
569+ }
570+}
571+
526572 func (t *Transport) resendLoop() {
527573 resend := t.clock.NewTimer(t.resendAfter)
528574 defer resend.Stop()
@@ -555,7 +601,7 @@ func (t *Transport) resendPending() {
555601 for seq := range t.pending {
556602 seqs = append(seqs, seq)
557603 }
558- sort.Slice(seqs, func(i, j int) bool { return seqs[i] < seqs[j] })
604+ slices.Sort(seqs)
559605 limit := t.maxResendPerTick
560606 for _, seq := range seqs {
561607 if limit <= 0 {
@@ -657,9 +703,14 @@ func (t *Transport) setLinkState(state ports.LinkState, err error) {
657703 return
658704 }
659705 t.linkState = state
660- if t.sendCond != nil {
661- t.sendCond.Broadcast()
706+ if state == ports.LinkStateConnected {
707+ for _, p := range t.pending {
708+ if p != nil {
709+ p.attempts = 0
710+ }
711+ }
662712 }
713+ t.notifySendWaiter()
663714 t.mu.Unlock()
664715 event := ports.LinkEvent{State: state, At: now, Err: err}
665716 select {
@@ -678,9 +729,7 @@ func (t *Transport) closeWithError(err error) {
678729 t.closeErr = err
679730 t.probeWait = make(map[uint64]chan struct{})
680731 close(t.done)
681- if t.sendCond != nil {
682- t.sendCond.Broadcast()
683- }
732+ t.notifySendWaiter()
684733 t.mu.Unlock()
685734 t.deliverMu.Lock()
686735 t.deliverCond.Broadcast()
@@ -6,7 +6,7 @@ import (
6 "encoding/binary"6 "encoding/binary"
7 "errors"7 "errors"
8 "net"8 "net"
9- "sort"9+ "slices"
10 "sync"10 "sync"
11 "time"11 "time"
12 12
@@ -31,9 +31,11 @@ const (
31 defaultOffline = 30 * time.Second31 defaultOffline = 30 * time.Second
32 defaultDead = 60 * time.Second32 defaultDead = 60 * time.Second
33 defaultMaxPending = 102433 defaultMaxPending = 1024
34+ defaultMaxPendingWait = 50 * time.Millisecond
34 defaultMaxRecvBuf = 102435 defaultMaxRecvBuf = 1024
35 maxRecvBuffer = defaultMaxRecvBuf36 maxRecvBuffer = defaultMaxRecvBuf
36 linkEventBufferSize = 1637 linkEventBufferSize = 16
38+ probeReplyBufferSize = 16
37 )39 )
38 40
39 var (41 var (
@@ -53,6 +55,7 @@ type Options struct {
53 OfflineAfter time.Duration55 OfflineAfter time.Duration
54 DeadAfter time.Duration56 DeadAfter time.Duration
55 MaxPending int57 MaxPending int
58+ MaxPendingWait time.Duration
56 MaxRecvBuffer int59 MaxRecvBuffer int
57 Clock ports.Clock60 Clock ports.Clock
58 // RebindPacketConn is an optional client-side hook used to hop the local UDP61 // RebindPacketConn is an optional client-side hook used to hop the local UDP
@@ -110,6 +113,9 @@ func normalizeOptions(opts Options) Options {
110 if opts.MaxPending <= 0 {113 if opts.MaxPending <= 0 {
111 opts.MaxPending = defaultMaxPending114 opts.MaxPending = defaultMaxPending
112 }115 }
116+ if opts.MaxPendingWait <= 0 {
117+ opts.MaxPendingWait = defaultMaxPendingWait
118+ }
113 if opts.MaxRecvBuffer <= 0 {119 if opts.MaxRecvBuffer <= 0 {
114 opts.MaxRecvBuffer = defaultMaxRecvBuf120 opts.MaxRecvBuffer = defaultMaxRecvBuf
115 }121 }
@@ -144,12 +150,14 @@ type Transport struct {
144 offlineAfter time.Duration150 offlineAfter time.Duration
145 deadAfter time.Duration151 deadAfter time.Duration
146 maxPending int152 maxPending int
153+ maxPendingWait time.Duration
147 maxRecvBuffer int154 maxRecvBuffer int
148 clock ports.Clock155 clock ports.Clock
149 linkState ports.LinkState156 linkState ports.LinkState
150 linkEvents chan ports.LinkEvent157 linkEvents chan ports.LinkEvent
151 probeWait map[uint64]chan struct{}158 probeWait map[uint64]chan struct{}
152- sendCond *sync.Cond159+ sendWake chan struct{}
160+ probeReply chan uint64
153 rebind func(net.PacketConn) (net.PacketConn, error)161 rebind func(net.PacketConn) (net.PacketConn, error)
154 hoppedOffline bool162 hoppedOffline bool
155 163
@@ -183,12 +191,14 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD
183 return nil, err191 return nil, err
184 }192 }
185 opts = normalizeOptions(opts)193 opts = normalizeOptions(opts)
186- t := &Transport{pc: pc, codec: c, sendDir: sendDir, recvDir: recvDir, mtu: opts.MTU, peer: peer, pending: make(map[uint64]*pending), replay: pdgram.NewReplayWindow(), reasm: pdgram.NewReassembler(), in: make(chan ports.Frame, 32), done: make(chan struct{}), lastHeard: opts.Clock.Now(), heartbeat: opts.Heartbeat, resendAfter: opts.ResendAfter, maxResendAfter: opts.MaxResendAfter, maxResendPerTick: opts.MaxResendPerTick, writeTimeout: opts.WriteTimeout, degradedAfter: opts.DegradedAfter, probeAfter: opts.ProbeAfter, offlineAfter: opts.OfflineAfter, deadAfter: opts.DeadAfter, maxPending: opts.MaxPending, maxRecvBuffer: opts.MaxRecvBuffer, clock: opts.Clock, linkState: ports.LinkStateConnected, linkEvents: make(chan ports.LinkEvent, linkEventBufferSize), probeWait: make(map[uint64]chan struct{}), rebind: opts.RebindPacketConn, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}194+ t := &Transport{pc: pc, codec: c, sendDir: sendDir, recvDir: recvDir, mtu: opts.MTU, peer: peer, pending: make(map[uint64]*pending), replay: pdgram.NewReplayWindow(), reasm: pdgram.NewReassembler(), in: make(chan ports.Frame, 32), done: make(chan struct{}), lastHeard: opts.Clock.Now(), heartbeat: opts.Heartbeat, resendAfter: opts.ResendAfter, maxResendAfter: opts.MaxResendAfter, maxResendPerTick: opts.MaxResendPerTick, writeTimeout: opts.WriteTimeout, degradedAfter: opts.DegradedAfter, probeAfter: opts.ProbeAfter, offlineAfter: opts.OfflineAfter, deadAfter: opts.DeadAfter, maxPending: opts.MaxPending, maxPendingWait: opts.MaxPendingWait, maxRecvBuffer: opts.MaxRecvBuffer, clock: opts.Clock, linkState: ports.LinkStateConnected, linkEvents: make(chan ports.LinkEvent, linkEventBufferSize), probeWait: make(map[uint64]chan struct{}), rebind: opts.RebindPacketConn, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
195+ t.sendWake = make(chan struct{}, 1)
196+ t.probeReply = make(chan uint64, probeReplyBufferSize)
187 t.deliverCond = sync.NewCond(&t.deliverMu)197 t.deliverCond = sync.NewCond(&t.deliverMu)
188- t.sendCond = sync.NewCond(&t.mu)
189 go t.readLoop(pc)198 go t.readLoop(pc)
190 go t.resendLoop()199 go t.resendLoop()
191 go t.deliveryLoop()200 go t.deliveryLoop()
201+ go t.probeReplyLoop()
192 return t, nil202 return t, nil
193 }203 }
194 204
@@ -197,12 +207,29 @@ func (t *Transport) Send(f ports.Frame) error {
197 // dropping MsgOutput can permanently desynchronize the client screen.207 // dropping MsgOutput can permanently desynchronize the client screen.
198 reliable := true208 reliable := true
199 t.mu.Lock()209 t.mu.Lock()
210+ var pendingWaitStarted time.Time
200 for reliable && len(t.pending) >= t.maxPending && !t.closed {211 for reliable && len(t.pending) >= t.maxPending && !t.closed {
201 if t.linkState != ports.LinkStateConnected {212 if t.linkState != ports.LinkStateConnected {
202 t.mu.Unlock()213 t.mu.Unlock()
203 return ErrPendingFull214 return ErrPendingFull
204 }215 }
205- t.sendCond.Wait()216+ if pendingWaitStarted.IsZero() {
217+ pendingWaitStarted = t.clock.Now()
218+ }
219+ remaining := t.maxPendingWait - t.clock.Now().Sub(pendingWaitStarted)
220+ if remaining <= 0 {
221+ t.mu.Unlock()
222+ return ErrPendingFull
223+ }
224+ timer := t.clock.NewTimer(remaining)
225+ t.mu.Unlock()
226+ select {
227+ case <-timer.C():
228+ case <-t.sendWake:
229+ case <-t.done:
230+ }
231+ timer.Stop()
232+ t.mu.Lock()
206 }233 }
207 if t.closed {234 if t.closed {
208 err := t.closeErr235 err := t.closeErr
@@ -360,7 +387,7 @@ func (t *Transport) writePacket(pkt []byte, peer net.Addr) error {
360 t.mu.Unlock()387 t.mu.Unlock()
361 388
362 if writeTimeout > 0 {389 if writeTimeout > 0 {
363- if err := pc.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil {390+ if err := pc.SetWriteDeadline(t.clock.Now().Add(writeTimeout)); err != nil {
364 return err391 return err
365 }392 }
366 defer func() { _ = pc.SetWriteDeadline(time.Time{}) }()393 defer func() { _ = pc.SetWriteDeadline(time.Time{}) }()
@@ -428,16 +455,17 @@ func (t *Transport) handleRecord(p []byte) {
428 t.mu.Lock()455 t.mu.Lock()
429 if _, ok := t.pending[seq]; ok {456 if _, ok := t.pending[seq]; ok {
430 delete(t.pending, seq)457 delete(t.pending, seq)
431- if t.sendCond != nil {458+ t.notifySendWaiter()
432- t.sendCond.Signal()
433- }
434 }459 }
435 t.mu.Unlock()460 t.mu.Unlock()
436 case recProbe:461 case recProbe:
437 if len(p) != 9 {462 if len(p) != 9 {
438 return463 return
439 }464 }
440- _ = t.sendProbe(recPong, binary.BigEndian.Uint64(p[1:]))465+ select {
466+ case t.probeReply <- binary.BigEndian.Uint64(p[1:]):
467+ default:
468+ }
441 case recPong:469 case recPong:
442 if len(p) != 9 {470 if len(p) != 9 {
443 return471 return
@@ -523,6 +551,24 @@ func (t *Transport) deliver(f ports.Frame) {
523 }551 }
524 }552 }
525 553
554+func (t *Transport) probeReplyLoop() {
555+ for {
556+ select {
557+ case id := <-t.probeReply:
558+ _ = t.sendProbe(recPong, id)
559+ case <-t.done:
560+ return
561+ }
562+ }
563+}
564+
565+func (t *Transport) notifySendWaiter() {
566+ select {
567+ case t.sendWake <- struct{}{}:
568+ default:
569+ }
570+}
571+
526 func (t *Transport) resendLoop() {572 func (t *Transport) resendLoop() {
527 resend := t.clock.NewTimer(t.resendAfter)573 resend := t.clock.NewTimer(t.resendAfter)
528 defer resend.Stop()574 defer resend.Stop()
@@ -555,7 +601,7 @@ func (t *Transport) resendPending() {
555 for seq := range t.pending {601 for seq := range t.pending {
556 seqs = append(seqs, seq)602 seqs = append(seqs, seq)
557 }603 }
558- sort.Slice(seqs, func(i, j int) bool { return seqs[i] < seqs[j] })604+ slices.Sort(seqs)
559 limit := t.maxResendPerTick605 limit := t.maxResendPerTick
560 for _, seq := range seqs {606 for _, seq := range seqs {
561 if limit <= 0 {607 if limit <= 0 {
@@ -657,9 +703,14 @@ func (t *Transport) setLinkState(state ports.LinkState, err error) {
657 return703 return
658 }704 }
659 t.linkState = state705 t.linkState = state
660- if t.sendCond != nil {706+ if state == ports.LinkStateConnected {
661- t.sendCond.Broadcast()707+ for _, p := range t.pending {
708+ if p != nil {
709+ p.attempts = 0
710+ }
711+ }
662 }712 }
713+ t.notifySendWaiter()
663 t.mu.Unlock()714 t.mu.Unlock()
664 event := ports.LinkEvent{State: state, At: now, Err: err}715 event := ports.LinkEvent{State: state, At: now, Err: err}
665 select {716 select {
@@ -678,9 +729,7 @@ func (t *Transport) closeWithError(err error) {
678 t.closeErr = err729 t.closeErr = err
679 t.probeWait = make(map[uint64]chan struct{})730 t.probeWait = make(map[uint64]chan struct{})
680 close(t.done)731 close(t.done)
681- if t.sendCond != nil {732+ t.notifySendWaiter()
682- t.sendCond.Broadcast()
683- }
684 t.mu.Unlock()733 t.mu.Unlock()
685 t.deliverMu.Lock()734 t.deliverMu.Lock()
686 t.deliverCond.Broadcast()735 t.deliverCond.Broadcast()
M internal/adapters/dgram/transport_test.go +194 -0
@@ -161,6 +161,100 @@ func (p *blockingWritePC) SetWriteDeadline(t time.Time) error {
161161 return nil
162162 }
163163
164+type fixedClock struct{ now time.Time }
165+
166+func (c fixedClock) Now() time.Time { return c.now }
167+func (c fixedClock) NewTimer(d time.Duration) ports.Timer {
168+ return realTimer{t: time.NewTimer(d)}
169+}
170+
171+type manualClock struct {
172+ mu sync.Mutex
173+ now time.Time
174+ timers chan *manualTimer
175+}
176+
177+func newManualClock(now time.Time) *manualClock {
178+ return &manualClock{now: now, timers: make(chan *manualTimer, 16)}
179+}
180+
181+func (c *manualClock) Now() time.Time {
182+ c.mu.Lock()
183+ defer c.mu.Unlock()
184+ return c.now
185+}
186+
187+func (c *manualClock) NewTimer(d time.Duration) ports.Timer {
188+ tm := &manualTimer{c: make(chan time.Time, 1), d: d}
189+ c.timers <- tm
190+ return tm
191+}
192+
193+func (c *manualClock) advance(d time.Duration) {
194+ c.mu.Lock()
195+ c.now = c.now.Add(d)
196+ now := c.now
197+ c.mu.Unlock()
198+ for {
199+ select {
200+ case tm := <-c.timers:
201+ tm.fire(now)
202+ default:
203+ return
204+ }
205+ }
206+}
207+
208+type manualTimer struct {
209+ c chan time.Time
210+ d time.Duration
211+}
212+
213+func (t *manualTimer) C() <-chan time.Time { return t.c }
214+func (t *manualTimer) Reset(d time.Duration) bool {
215+ t.d = d
216+ return true
217+}
218+func (t *manualTimer) Stop() bool { return true }
219+func (t *manualTimer) fire(now time.Time) {
220+ select {
221+ case t.c <- now:
222+ default:
223+ }
224+}
225+
226+func waitForManualTimers(t *testing.T, clk *manualClock, n int) {
227+ t.Helper()
228+ deadline := time.Now().Add(time.Second)
229+ for time.Now().Before(deadline) {
230+ if len(clk.timers) >= n {
231+ return
232+ }
233+ time.Sleep(time.Millisecond)
234+ }
235+ t.Fatalf("manual timers=%d, want at least %d", len(clk.timers), n)
236+}
237+
238+type deadlineCapturePC struct {
239+ addr net.Addr
240+ deadline atomic.Value
241+}
242+
243+func (p *deadlineCapturePC) ReadFrom([]byte) (int, net.Addr, error) {
244+ return 0, nil, errors.New("closed")
245+}
246+func (p *deadlineCapturePC) WriteTo(b []byte, _ net.Addr) (int, error) { return len(b), nil }
247+func (p *deadlineCapturePC) Close() error { return nil }
248+func (p *deadlineCapturePC) LocalAddr() net.Addr { return p.addr }
249+func (p *deadlineCapturePC) SetDeadline(time.Time) error { return nil }
250+func (p *deadlineCapturePC) SetReadDeadline(time.Time) error { return nil }
251+func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error {
252+ if !t.IsZero() {
253+ p.deadline.Store(t)
254+ }
255+ return nil
256+}
257+
164258 func TestSendWriteTimeoutBoundsBlockedPacketConn(t *testing.T) {
165259 pc := newBlockingWritePC()
166260 tr, err := NewTransportWithOptions(pc, testAddr("peer"), key(), 1, 2, Options{
@@ -628,6 +722,99 @@ func TestPendingReliableQueueBackpressuresUntilAck(t *testing.T) {
628722 }
629723 }
630724
725+func TestPendingReliableQueueReturnsErrPendingFullWhileConnected(t *testing.T) {
726+ aPC, bPC := newPair()
727+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
728+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
729+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1, MaxPendingWait: 50 * time.Millisecond, Clock: clk})
730+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
731+ defer func() { _ = a.Close() }()
732+ defer func() { _ = b.Close() }()
733+
734+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
735+ t.Fatal(err)
736+ }
737+ done := make(chan error, 1)
738+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
739+ waitForManualTimers(t, clk, 3)
740+
741+ clk.advance(50 * time.Millisecond)
742+ select {
743+ case err := <-done:
744+ if !errors.Is(err, ErrPendingFull) {
745+ t.Fatalf("send err=%v, want ErrPendingFull", err)
746+ }
747+ case <-time.After(time.Second):
748+ t.Fatal("send did not return after clock advanced past MaxPendingWait")
749+ }
750+}
751+
752+func TestPendingAttemptsResetWhenLinkRecovers(t *testing.T) {
753+ tr, _ := newResendTestTransport(t, 10)
754+ tr.linkState = ports.LinkStateDegraded
755+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stale")}, attempts: 4}
756+ tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stale")}, attempts: 7}
757+
758+ tr.setLinkState(ports.LinkStateConnected, nil)
759+
760+ for seq, p := range tr.pending {
761+ if p.attempts != 0 {
762+ t.Fatalf("pending[%d] attempts=%d, want reset on connected recovery", seq, p.attempts)
763+ }
764+ }
765+}
766+
767+func TestWritePacketDeadlineUsesTransportClock(t *testing.T) {
768+ wantNow := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
769+ pc := &deadlineCapturePC{addr: testAddr("a")}
770+ tr, err := NewTransportWithOptions(pc, testAddr("b"), key(), 1, 2, Options{
771+ Clock: fixedClock{now: wantNow},
772+ WriteTimeout: 250 * time.Millisecond,
773+ ResendAfter: time.Hour,
774+ Heartbeat: time.Hour,
775+ })
776+ if err != nil {
777+ t.Fatal(err)
778+ }
779+ defer func() { _ = tr.Close() }()
780+
781+ if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("deadline")}); err != nil {
782+ t.Fatal(err)
783+ }
784+ got, _ := pc.deadline.Load().(time.Time)
785+ if want := wantNow.Add(250 * time.Millisecond); !got.Equal(want) {
786+ t.Fatalf("write deadline=%v, want transport clock deadline %v", got, want)
787+ }
788+}
789+
790+func TestProbeReplyDoesNotBlockOnWriteMu(t *testing.T) {
791+ aPC, bPC := newPair()
792+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: time.Hour, Heartbeat: time.Hour})
793+ if err != nil {
794+ t.Fatal(err)
795+ }
796+ defer func() { _ = tr.Close() }()
797+ defer func() { _ = bPC.Close() }()
798+
799+ tr.writeMu.Lock()
800+ done := make(chan struct{})
801+ go func() {
802+ for id := uint64(1); id <= probeReplyBufferSize*4; id++ {
803+ tr.handleRecord(probeRecord(recProbe, id))
804+ }
805+ close(done)
806+ }()
807+
808+ select {
809+ case <-done:
810+ case <-time.After(time.Second):
811+ tr.writeMu.Unlock()
812+ <-done
813+ t.Fatal("probe reply burst blocked behind writeMu")
814+ }
815+ tr.writeMu.Unlock()
816+}
817+
631818 func TestPendingReliableQueueReturnsWhenLinkDegrades(t *testing.T) {
632819 aPC, bPC := newPair()
633820 aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
@@ -665,6 +852,13 @@ func ackRecord(seq uint64) []byte {
665852 return b[:]
666853 }
667854
855+func probeRecord(kind byte, id uint64) []byte {
856+ var b [9]byte
857+ b[0] = kind
858+ binary.BigEndian.PutUint64(b[1:], id)
859+ return b[:]
860+}
861+
668862 func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) {
669863 tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, done: make(chan struct{})}
670864 tr.deliverCond = sync.NewCond(&tr.deliverMu)
@@ -161,6 +161,100 @@ func (p *blockingWritePC) SetWriteDeadline(t time.Time) error {
161 return nil161 return nil
162 }162 }
163 163
164+type fixedClock struct{ now time.Time }
165+
166+func (c fixedClock) Now() time.Time { return c.now }
167+func (c fixedClock) NewTimer(d time.Duration) ports.Timer {
168+ return realTimer{t: time.NewTimer(d)}
169+}
170+
171+type manualClock struct {
172+ mu sync.Mutex
173+ now time.Time
174+ timers chan *manualTimer
175+}
176+
177+func newManualClock(now time.Time) *manualClock {
178+ return &manualClock{now: now, timers: make(chan *manualTimer, 16)}
179+}
180+
181+func (c *manualClock) Now() time.Time {
182+ c.mu.Lock()
183+ defer c.mu.Unlock()
184+ return c.now
185+}
186+
187+func (c *manualClock) NewTimer(d time.Duration) ports.Timer {
188+ tm := &manualTimer{c: make(chan time.Time, 1), d: d}
189+ c.timers <- tm
190+ return tm
191+}
192+
193+func (c *manualClock) advance(d time.Duration) {
194+ c.mu.Lock()
195+ c.now = c.now.Add(d)
196+ now := c.now
197+ c.mu.Unlock()
198+ for {
199+ select {
200+ case tm := <-c.timers:
201+ tm.fire(now)
202+ default:
203+ return
204+ }
205+ }
206+}
207+
208+type manualTimer struct {
209+ c chan time.Time
210+ d time.Duration
211+}
212+
213+func (t *manualTimer) C() <-chan time.Time { return t.c }
214+func (t *manualTimer) Reset(d time.Duration) bool {
215+ t.d = d
216+ return true
217+}
218+func (t *manualTimer) Stop() bool { return true }
219+func (t *manualTimer) fire(now time.Time) {
220+ select {
221+ case t.c <- now:
222+ default:
223+ }
224+}
225+
226+func waitForManualTimers(t *testing.T, clk *manualClock, n int) {
227+ t.Helper()
228+ deadline := time.Now().Add(time.Second)
229+ for time.Now().Before(deadline) {
230+ if len(clk.timers) >= n {
231+ return
232+ }
233+ time.Sleep(time.Millisecond)
234+ }
235+ t.Fatalf("manual timers=%d, want at least %d", len(clk.timers), n)
236+}
237+
238+type deadlineCapturePC struct {
239+ addr net.Addr
240+ deadline atomic.Value
241+}
242+
243+func (p *deadlineCapturePC) ReadFrom([]byte) (int, net.Addr, error) {
244+ return 0, nil, errors.New("closed")
245+}
246+func (p *deadlineCapturePC) WriteTo(b []byte, _ net.Addr) (int, error) { return len(b), nil }
247+func (p *deadlineCapturePC) Close() error { return nil }
248+func (p *deadlineCapturePC) LocalAddr() net.Addr { return p.addr }
249+func (p *deadlineCapturePC) SetDeadline(time.Time) error { return nil }
250+func (p *deadlineCapturePC) SetReadDeadline(time.Time) error { return nil }
251+func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error {
252+ if !t.IsZero() {
253+ p.deadline.Store(t)
254+ }
255+ return nil
256+}
257+
164 func TestSendWriteTimeoutBoundsBlockedPacketConn(t *testing.T) {258 func TestSendWriteTimeoutBoundsBlockedPacketConn(t *testing.T) {
165 pc := newBlockingWritePC()259 pc := newBlockingWritePC()
166 tr, err := NewTransportWithOptions(pc, testAddr("peer"), key(), 1, 2, Options{260 tr, err := NewTransportWithOptions(pc, testAddr("peer"), key(), 1, 2, Options{
@@ -628,6 +722,99 @@ func TestPendingReliableQueueBackpressuresUntilAck(t *testing.T) {
628 }722 }
629 }723 }
630 724
725+func TestPendingReliableQueueReturnsErrPendingFullWhileConnected(t *testing.T) {
726+ aPC, bPC := newPair()
727+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
728+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
729+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1, MaxPendingWait: 50 * time.Millisecond, Clock: clk})
730+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
731+ defer func() { _ = a.Close() }()
732+ defer func() { _ = b.Close() }()
733+
734+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
735+ t.Fatal(err)
736+ }
737+ done := make(chan error, 1)
738+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
739+ waitForManualTimers(t, clk, 3)
740+
741+ clk.advance(50 * time.Millisecond)
742+ select {
743+ case err := <-done:
744+ if !errors.Is(err, ErrPendingFull) {
745+ t.Fatalf("send err=%v, want ErrPendingFull", err)
746+ }
747+ case <-time.After(time.Second):
748+ t.Fatal("send did not return after clock advanced past MaxPendingWait")
749+ }
750+}
751+
752+func TestPendingAttemptsResetWhenLinkRecovers(t *testing.T) {
753+ tr, _ := newResendTestTransport(t, 10)
754+ tr.linkState = ports.LinkStateDegraded
755+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stale")}, attempts: 4}
756+ tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stale")}, attempts: 7}
757+
758+ tr.setLinkState(ports.LinkStateConnected, nil)
759+
760+ for seq, p := range tr.pending {
761+ if p.attempts != 0 {
762+ t.Fatalf("pending[%d] attempts=%d, want reset on connected recovery", seq, p.attempts)
763+ }
764+ }
765+}
766+
767+func TestWritePacketDeadlineUsesTransportClock(t *testing.T) {
768+ wantNow := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
769+ pc := &deadlineCapturePC{addr: testAddr("a")}
770+ tr, err := NewTransportWithOptions(pc, testAddr("b"), key(), 1, 2, Options{
771+ Clock: fixedClock{now: wantNow},
772+ WriteTimeout: 250 * time.Millisecond,
773+ ResendAfter: time.Hour,
774+ Heartbeat: time.Hour,
775+ })
776+ if err != nil {
777+ t.Fatal(err)
778+ }
779+ defer func() { _ = tr.Close() }()
780+
781+ if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("deadline")}); err != nil {
782+ t.Fatal(err)
783+ }
784+ got, _ := pc.deadline.Load().(time.Time)
785+ if want := wantNow.Add(250 * time.Millisecond); !got.Equal(want) {
786+ t.Fatalf("write deadline=%v, want transport clock deadline %v", got, want)
787+ }
788+}
789+
790+func TestProbeReplyDoesNotBlockOnWriteMu(t *testing.T) {
791+ aPC, bPC := newPair()
792+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: time.Hour, Heartbeat: time.Hour})
793+ if err != nil {
794+ t.Fatal(err)
795+ }
796+ defer func() { _ = tr.Close() }()
797+ defer func() { _ = bPC.Close() }()
798+
799+ tr.writeMu.Lock()
800+ done := make(chan struct{})
801+ go func() {
802+ for id := uint64(1); id <= probeReplyBufferSize*4; id++ {
803+ tr.handleRecord(probeRecord(recProbe, id))
804+ }
805+ close(done)
806+ }()
807+
808+ select {
809+ case <-done:
810+ case <-time.After(time.Second):
811+ tr.writeMu.Unlock()
812+ <-done
813+ t.Fatal("probe reply burst blocked behind writeMu")
814+ }
815+ tr.writeMu.Unlock()
816+}
817+
631 func TestPendingReliableQueueReturnsWhenLinkDegrades(t *testing.T) {818 func TestPendingReliableQueueReturnsWhenLinkDegrades(t *testing.T) {
632 aPC, bPC := newPair()819 aPC, bPC := newPair()
633 aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }820 aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
@@ -665,6 +852,13 @@ func ackRecord(seq uint64) []byte {
665 return b[:]852 return b[:]
666 }853 }
667 854
855+func probeRecord(kind byte, id uint64) []byte {
856+ var b [9]byte
857+ b[0] = kind
858+ binary.BigEndian.PutUint64(b[1:], id)
859+ return b[:]
860+}
861+
668 func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) {862 func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) {
669 tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, done: make(chan struct{})}863 tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, done: make(chan struct{})}
670 tr.deliverCond = sync.NewCond(&tr.deliverMu)864 tr.deliverCond = sync.NewCond(&tr.deliverMu)
M internal/app/run.go +3 -1
@@ -730,7 +730,7 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
730730 return err
731731 }
732732 defer func() { _ = daemonTr.Close() }()
733- dg, err := dgram.NewTransport(conn, nil, key, 2, 1)
733+ dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpProxyClientTransportOptions)
734734 if err != nil {
735735 return err
736736 }
@@ -758,6 +758,8 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
758758 return dgram.ProxyRuntime{Client: dgramTr, Daemon: daemonTr, Log: log}.Run(ctx)
759759 }
760760
761+var udpProxyClientTransportOptions = dgram.Options{MaxPending: 32}
762+
761763 const (
762764 // envUDPPortRange configures the remote UDP proxy's listen port range so a
763765 // host firewall can allow a known range instead of a random ephemeral port.
@@ -730,7 +730,7 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
730 return err730 return err
731 }731 }
732 defer func() { _ = daemonTr.Close() }()732 defer func() { _ = daemonTr.Close() }()
733- dg, err := dgram.NewTransport(conn, nil, key, 2, 1)733+ dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpProxyClientTransportOptions)
734 if err != nil {734 if err != nil {
735 return err735 return err
736 }736 }
@@ -758,6 +758,8 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
758 return dgram.ProxyRuntime{Client: dgramTr, Daemon: daemonTr, Log: log}.Run(ctx)758 return dgram.ProxyRuntime{Client: dgramTr, Daemon: daemonTr, Log: log}.Run(ctx)
759 }759 }
760 760
761+var udpProxyClientTransportOptions = dgram.Options{MaxPending: 32}
762+
761 const (763 const (
762 // envUDPPortRange configures the remote UDP proxy's listen port range so a764 // envUDPPortRange configures the remote UDP proxy's listen port range so a
763 // host firewall can allow a known range instead of a random ephemeral port.765 // host firewall can allow a known range instead of a random ephemeral port.
M internal/app/run_test.go +4 -0
@@ -25,6 +25,10 @@ import (
2525 "github.com/stretchr/testify/require"
2626 )
2727
28+func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) {
29+ require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending)
30+}
31+
2832 func TestParseArgs(t *testing.T) {
2933 tests := []struct {
3034 name string
@@ -25,6 +25,10 @@ import (
25 "github.com/stretchr/testify/require"25 "github.com/stretchr/testify/require"
26 )26 )
27 27
28+func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) {
29+ require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending)
30+}
31+
28 func TestParseArgs(t *testing.T) {32 func TestParseArgs(t *testing.T) {
29 tests := []struct {33 tests := []struct {
30 name string34 name string
M internal/usecase/daemon/client.go +3 -11
@@ -202,18 +202,10 @@ func (ac *attachedClient) nextOutputFrameLocked(b []byte, reset bool) ports.Fram
202202 return frameOutputState(b, baseStateNum, ac.nextStateNum, ac.echoAck.Load())
203203 }
204204
205-// shouldResetOutputState reads nextStateNum and must only be called while
206-// sendMu is held, matching nextOutputFrameLocked.
205+// shouldResetOutputState honors explicit invalidations; reliable transport ack
206+// lag alone must not force a full output-state repaint.
207207 func (ac *attachedClient) shouldResetOutputState(reset bool) bool {
208- if reset {
209- return true
210- }
211- latest := ac.nextStateNum
212- if latest == 0 {
213- return false
214- }
215- acked := ac.outputAck.Load()
216- return acked+1 < latest
208+ return reset
217209 }
218210
219211 func (ac *attachedClient) advanceOutputAck(state uint64) {
@@ -202,18 +202,10 @@ func (ac *attachedClient) nextOutputFrameLocked(b []byte, reset bool) ports.Fram
202 return frameOutputState(b, baseStateNum, ac.nextStateNum, ac.echoAck.Load())202 return frameOutputState(b, baseStateNum, ac.nextStateNum, ac.echoAck.Load())
203 }203 }
204 204
205-// shouldResetOutputState reads nextStateNum and must only be called while205+// shouldResetOutputState honors explicit invalidations; reliable transport ack
206-// sendMu is held, matching nextOutputFrameLocked.206+// lag alone must not force a full output-state repaint.
207 func (ac *attachedClient) shouldResetOutputState(reset bool) bool {207 func (ac *attachedClient) shouldResetOutputState(reset bool) bool {
208- if reset {208+ return reset
209- return true
210- }
211- latest := ac.nextStateNum
212- if latest == 0 {
213- return false
214- }
215- acked := ac.outputAck.Load()
216- return acked+1 < latest
217 }209 }
218 210
219 func (ac *attachedClient) advanceOutputAck(state uint64) {211 func (ac *attachedClient) advanceOutputAck(state uint64) {
M internal/usecase/daemon/resume_test.go +11 -11
@@ -126,35 +126,35 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
126126 require.Same(t, resumedAC, sess.client)
127127 }
128128
129-func TestOutputAckGapForcesFullStateRepaint(t *testing.T) {
129+func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
130130 ac := &attachedClient{}
131131 ac.nextStateNum = 3
132132 ac.advanceOutputAck(3)
133133 ac.advanceOutputAck(2)
134134 ac.advanceOutputAck(4)
135135 require.Equal(t, uint64(3), ac.outputAck.Load(), "stale or future ACKs must not move output state incorrectly")
136+
136137 ac.sendMu.Lock()
137138 ac.nextStateNum = 5
138139 reset := ac.shouldResetOutputState(false)
139- require.True(t, reset, "client ack gap should force dependency-free full repaint")
140+ require.False(t, reset, "reliable output ack lag alone must not force dependency-free full repaint")
140141
141- f := ac.nextOutputFrameLocked([]byte("full repaint"), reset)
142+ f := ac.nextOutputFrameLocked([]byte("incremental while reliable backlog drains"), reset)
142143 ac.sendMu.Unlock()
143144 out, err := ports.UnmarshalOutput(f.Payload)
144145 require.NoError(t, err)
145- require.Equal(t, uint64(0), out.BaseStateNum, "full repaint must not depend on dropped prior output states")
146+ require.Equal(t, uint64(5), out.BaseStateNum, "output should remain incremental unless an explicit reset is requested")
146147 require.Equal(t, uint64(6), out.NewStateNum)
147148
148- ac.outputAck.Store(out.NewStateNum)
149149 ac.sendMu.Lock()
150- reset = ac.shouldResetOutputState(false)
151- require.False(t, reset, "acked current state can resume incremental output")
152- incremental := ac.nextOutputFrameLocked([]byte("incremental"), reset)
150+ reset = ac.shouldResetOutputState(true)
151+ require.True(t, reset, "explicit reset should still force full repaint")
152+ full := ac.nextOutputFrameLocked([]byte("explicit full repaint"), reset)
153153 ac.sendMu.Unlock()
154- incrementalOut, err := ports.UnmarshalOutput(incremental.Payload)
154+ fullOut, err := ports.UnmarshalOutput(full.Payload)
155155 require.NoError(t, err)
156- require.Equal(t, uint64(6), incrementalOut.BaseStateNum)
157- require.Equal(t, uint64(7), incrementalOut.NewStateNum)
156+ require.Equal(t, uint64(0), fullOut.BaseStateNum)
157+ require.Equal(t, uint64(7), fullOut.NewStateNum)
158158 }
159159
160160 func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) {
@@ -126,35 +126,35 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
126 require.Same(t, resumedAC, sess.client)126 require.Same(t, resumedAC, sess.client)
127 }127 }
128 128
129-func TestOutputAckGapForcesFullStateRepaint(t *testing.T) {129+func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
130 ac := &attachedClient{}130 ac := &attachedClient{}
131 ac.nextStateNum = 3131 ac.nextStateNum = 3
132 ac.advanceOutputAck(3)132 ac.advanceOutputAck(3)
133 ac.advanceOutputAck(2)133 ac.advanceOutputAck(2)
134 ac.advanceOutputAck(4)134 ac.advanceOutputAck(4)
135 require.Equal(t, uint64(3), ac.outputAck.Load(), "stale or future ACKs must not move output state incorrectly")135 require.Equal(t, uint64(3), ac.outputAck.Load(), "stale or future ACKs must not move output state incorrectly")
136+
136 ac.sendMu.Lock()137 ac.sendMu.Lock()
137 ac.nextStateNum = 5138 ac.nextStateNum = 5
138 reset := ac.shouldResetOutputState(false)139 reset := ac.shouldResetOutputState(false)
139- require.True(t, reset, "client ack gap should force dependency-free full repaint")140+ require.False(t, reset, "reliable output ack lag alone must not force dependency-free full repaint")
140 141
141- f := ac.nextOutputFrameLocked([]byte("full repaint"), reset)142+ f := ac.nextOutputFrameLocked([]byte("incremental while reliable backlog drains"), reset)
142 ac.sendMu.Unlock()143 ac.sendMu.Unlock()
143 out, err := ports.UnmarshalOutput(f.Payload)144 out, err := ports.UnmarshalOutput(f.Payload)
144 require.NoError(t, err)145 require.NoError(t, err)
145- require.Equal(t, uint64(0), out.BaseStateNum, "full repaint must not depend on dropped prior output states")146+ require.Equal(t, uint64(5), out.BaseStateNum, "output should remain incremental unless an explicit reset is requested")
146 require.Equal(t, uint64(6), out.NewStateNum)147 require.Equal(t, uint64(6), out.NewStateNum)
147 148
148- ac.outputAck.Store(out.NewStateNum)
149 ac.sendMu.Lock()149 ac.sendMu.Lock()
150- reset = ac.shouldResetOutputState(false)150+ reset = ac.shouldResetOutputState(true)
151- require.False(t, reset, "acked current state can resume incremental output")151+ require.True(t, reset, "explicit reset should still force full repaint")
152- incremental := ac.nextOutputFrameLocked([]byte("incremental"), reset)152+ full := ac.nextOutputFrameLocked([]byte("explicit full repaint"), reset)
153 ac.sendMu.Unlock()153 ac.sendMu.Unlock()
154- incrementalOut, err := ports.UnmarshalOutput(incremental.Payload)154+ fullOut, err := ports.UnmarshalOutput(full.Payload)
155 require.NoError(t, err)155 require.NoError(t, err)
156- require.Equal(t, uint64(6), incrementalOut.BaseStateNum)156+ require.Equal(t, uint64(0), fullOut.BaseStateNum)
157- require.Equal(t, uint64(7), incrementalOut.NewStateNum)157+ require.Equal(t, uint64(7), fullOut.NewStateNum)
158 }158 }
159 159
160 func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) {160 func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) {

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