improve(dgram): expose privacy-safe UDP health diagnostics
- commit
- 8c4b9b1df575554338f0c81b6a6e2ba44f2b7d2e
- author
- brice · · 2026-07-09 23:52 UTC
- parent
- f2100d6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +277 -70
A internal/adapters/dgram/diagnostics.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | package dgram | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "log/slog" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | const diagnosticBufferSize = 16 | |
| 11 | + | ||
| 12 | + | // Diagnostic is an immutable, privacy-safe snapshot of UDP link health. | |
| 13 | + | // It intentionally contains no packet payloads, addresses, keys, or identities. | |
| 14 | + | type Diagnostic struct { | |
| 15 | + | At time.Time | |
| 16 | + | State ports.LinkState | |
| 17 | + | SinceAuthenticatedPacket time.Duration | |
| 18 | + | SinceCompleteRecord time.Duration | |
| 19 | + | SinceACKProgress time.Duration | |
| 20 | + | PendingRecords int | |
| 21 | + | PendingBytes int | |
| 22 | + | Retransmits uint64 | |
| 23 | + | ReassemblyInflight int | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // DiagnosticObserver receives bounded asynchronous UDP health snapshots. | |
| 27 | + | type DiagnosticObserver func(Diagnostic) | |
| 28 | + | ||
| 29 | + | // DiagnosticLogObserver returns an observer suitable for composition roots. It | |
| 30 | + | // logs only the privacy-safe diagnostic counters and ages. | |
| 31 | + | func DiagnosticLogObserver(log *slog.Logger) DiagnosticObserver { | |
| 32 | + | if log == nil { | |
| 33 | + | return nil | |
| 34 | + | } | |
| 35 | + | return func(d Diagnostic) { | |
| 36 | + | log.Debug("udp health", | |
| 37 | + | "state", d.State, | |
| 38 | + | "since_authenticated_packet", d.SinceAuthenticatedPacket, | |
| 39 | + | "since_complete_record", d.SinceCompleteRecord, | |
| 40 | + | "since_ack_progress", d.SinceACKProgress, | |
| 41 | + | "pending_records", d.PendingRecords, | |
| 42 | + | "pending_bytes", d.PendingBytes, | |
| 43 | + | "retransmits", d.Retransmits, | |
| 44 | + | "reassembly_inflight", d.ReassemblyInflight, | |
| 45 | + | ) | |
| 46 | + | } | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | package dgram | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "log/slog" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | const diagnosticBufferSize = 16 | |||
| 11 | + | ||||
| 12 | + | // Diagnostic is an immutable, privacy-safe snapshot of UDP link health. | |||
| 13 | + | // It intentionally contains no packet payloads, addresses, keys, or identities. | |||
| 14 | + | type Diagnostic struct { | |||
| 15 | + | At time.Time | |||
| 16 | + | State ports.LinkState | |||
| 17 | + | SinceAuthenticatedPacket time.Duration | |||
| 18 | + | SinceCompleteRecord time.Duration | |||
| 19 | + | SinceACKProgress time.Duration | |||
| 20 | + | PendingRecords int | |||
| 21 | + | PendingBytes int | |||
| 22 | + | Retransmits uint64 | |||
| 23 | + | ReassemblyInflight int | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // DiagnosticObserver receives bounded asynchronous UDP health snapshots. | |||
| 27 | + | type DiagnosticObserver func(Diagnostic) | |||
| 28 | + | ||||
| 29 | + | // DiagnosticLogObserver returns an observer suitable for composition roots. It | |||
| 30 | + | // logs only the privacy-safe diagnostic counters and ages. | |||
| 31 | + | func DiagnosticLogObserver(log *slog.Logger) DiagnosticObserver { | |||
| 32 | + | if log == nil { | |||
| 33 | + | return nil | |||
| 34 | + | } | |||
| 35 | + | return func(d Diagnostic) { | |||
| 36 | + | log.Debug("udp health", | |||
| 37 | + | "state", d.State, | |||
| 38 | + | "since_authenticated_packet", d.SinceAuthenticatedPacket, | |||
| 39 | + | "since_complete_record", d.SinceCompleteRecord, | |||
| 40 | + | "since_ack_progress", d.SinceACKProgress, | |||
| 41 | + | "pending_records", d.PendingRecords, | |||
| 42 | + | "pending_bytes", d.PendingBytes, | |||
| 43 | + | "retransmits", d.Retransmits, | |||
| 44 | + | "reassembly_inflight", d.ReassemblyInflight, | |||
| 45 | + | ) | |||
| 46 | + | } | |||
| 47 | + | } | |||
A internal/adapters/dgram/diagnostics_test.go +67 -0
| @@ -0,0 +1,67 @@ | |||
| 1 | + | package dgram | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestDiagnosticSnapshotSeparatesProgressAges(t *testing.T) { | |
| 11 | + | now := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) | |
| 12 | + | got := make(chan Diagnostic, 1) | |
| 13 | + | tr := &Transport{ | |
| 14 | + | clock: fixedClock{now: now}, | |
| 15 | + | linkState: ports.LinkStateDegraded, | |
| 16 | + | lastAuthenticatedPacket: now.Add(-time.Second), | |
| 17 | + | lastCompleteRecord: now.Add(-2 * time.Second), | |
| 18 | + | lastACKProgress: now.Add(-3 * time.Second), | |
| 19 | + | retransmits: 64, | |
| 20 | + | reassemblyInflight: 2, | |
| 21 | + | pending: map[uint64]*pending{ | |
| 22 | + | 1: {frame: ports.Frame{Payload: make([]byte, 7)}}, | |
| 23 | + | 2: {frame: ports.Frame{Payload: make([]byte, 11)}}, | |
| 24 | + | }, | |
| 25 | + | diagnosticCh: make(chan Diagnostic, diagnosticBufferSize), | |
| 26 | + | done: make(chan struct{}), | |
| 27 | + | } | |
| 28 | + | tr.observe = func(d Diagnostic) { | |
| 29 | + | // This would deadlock if the observer were invoked under Transport.mu. | |
| 30 | + | _ = tr.LinkState() | |
| 31 | + | got <- d | |
| 32 | + | } | |
| 33 | + | go tr.diagnosticLoop() | |
| 34 | + | defer close(tr.done) | |
| 35 | + | ||
| 36 | + | tr.emitDiagnostic() | |
| 37 | + | select { | |
| 38 | + | case d := <-got: | |
| 39 | + | if d.At != now || d.State != ports.LinkStateDegraded { | |
| 40 | + | t.Fatalf("identity = %+v", d) | |
| 41 | + | } | |
| 42 | + | if d.SinceAuthenticatedPacket != time.Second || d.SinceCompleteRecord != 2*time.Second || d.SinceACKProgress != 3*time.Second { | |
| 43 | + | t.Fatalf("ages = packet %v, record %v, ack %v", d.SinceAuthenticatedPacket, d.SinceCompleteRecord, d.SinceACKProgress) | |
| 44 | + | } | |
| 45 | + | if d.PendingRecords != 2 || d.PendingBytes != 18 || d.Retransmits != 64 || d.ReassemblyInflight != 2 { | |
| 46 | + | t.Fatalf("counters = %+v", d) | |
| 47 | + | } | |
| 48 | + | case <-time.After(time.Second): | |
| 49 | + | t.Fatal("diagnostic observer was not called") | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestDiagnosticObserverIsBounded(t *testing.T) { | |
| 54 | + | tr := &Transport{ | |
| 55 | + | clock: fixedClock{now: time.Now()}, | |
| 56 | + | pending: make(map[uint64]*pending), | |
| 57 | + | diagnosticCh: make(chan Diagnostic, 1), | |
| 58 | + | done: make(chan struct{}), | |
| 59 | + | } | |
| 60 | + | for range 100 { | |
| 61 | + | tr.emitDiagnostic() | |
| 62 | + | } | |
| 63 | + | if got := len(tr.diagnosticCh); got != 1 { | |
| 64 | + | t.Fatalf("queued diagnostics = %d, want bounded buffer", got) | |
| 65 | + | } | |
| 66 | + | close(tr.done) | |
| 67 | + | } | |
| @@ -0,0 +1,67 @@ | |||||
| 1 | + | package dgram | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestDiagnosticSnapshotSeparatesProgressAges(t *testing.T) { | |||
| 11 | + | now := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) | |||
| 12 | + | got := make(chan Diagnostic, 1) | |||
| 13 | + | tr := &Transport{ | |||
| 14 | + | clock: fixedClock{now: now}, | |||
| 15 | + | linkState: ports.LinkStateDegraded, | |||
| 16 | + | lastAuthenticatedPacket: now.Add(-time.Second), | |||
| 17 | + | lastCompleteRecord: now.Add(-2 * time.Second), | |||
| 18 | + | lastACKProgress: now.Add(-3 * time.Second), | |||
| 19 | + | retransmits: 64, | |||
| 20 | + | reassemblyInflight: 2, | |||
| 21 | + | pending: map[uint64]*pending{ | |||
| 22 | + | 1: {frame: ports.Frame{Payload: make([]byte, 7)}}, | |||
| 23 | + | 2: {frame: ports.Frame{Payload: make([]byte, 11)}}, | |||
| 24 | + | }, | |||
| 25 | + | diagnosticCh: make(chan Diagnostic, diagnosticBufferSize), | |||
| 26 | + | done: make(chan struct{}), | |||
| 27 | + | } | |||
| 28 | + | tr.observe = func(d Diagnostic) { | |||
| 29 | + | // This would deadlock if the observer were invoked under Transport.mu. | |||
| 30 | + | _ = tr.LinkState() | |||
| 31 | + | got <- d | |||
| 32 | + | } | |||
| 33 | + | go tr.diagnosticLoop() | |||
| 34 | + | defer close(tr.done) | |||
| 35 | + | ||||
| 36 | + | tr.emitDiagnostic() | |||
| 37 | + | select { | |||
| 38 | + | case d := <-got: | |||
| 39 | + | if d.At != now || d.State != ports.LinkStateDegraded { | |||
| 40 | + | t.Fatalf("identity = %+v", d) | |||
| 41 | + | } | |||
| 42 | + | if d.SinceAuthenticatedPacket != time.Second || d.SinceCompleteRecord != 2*time.Second || d.SinceACKProgress != 3*time.Second { | |||
| 43 | + | t.Fatalf("ages = packet %v, record %v, ack %v", d.SinceAuthenticatedPacket, d.SinceCompleteRecord, d.SinceACKProgress) | |||
| 44 | + | } | |||
| 45 | + | if d.PendingRecords != 2 || d.PendingBytes != 18 || d.Retransmits != 64 || d.ReassemblyInflight != 2 { | |||
| 46 | + | t.Fatalf("counters = %+v", d) | |||
| 47 | + | } | |||
| 48 | + | case <-time.After(time.Second): | |||
| 49 | + | t.Fatal("diagnostic observer was not called") | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestDiagnosticObserverIsBounded(t *testing.T) { | |||
| 54 | + | tr := &Transport{ | |||
| 55 | + | clock: fixedClock{now: time.Now()}, | |||
| 56 | + | pending: make(map[uint64]*pending), | |||
| 57 | + | diagnosticCh: make(chan Diagnostic, 1), | |||
| 58 | + | done: make(chan struct{}), | |||
| 59 | + | } | |||
| 60 | + | for range 100 { | |||
| 61 | + | tr.emitDiagnostic() | |||
| 62 | + | } | |||
| 63 | + | if got := len(tr.diagnosticCh); got != 1 { | |||
| 64 | + | t.Fatalf("queued diagnostics = %d, want bounded buffer", got) | |||
| 65 | + | } | |||
| 66 | + | close(tr.done) | |||
| 67 | + | } | |||
M internal/adapters/dgram/ssh_dialer.go +1 -0
| @@ -176,6 +176,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||
| 176 | 176 | // fresh local UDP socket once the link is silent long enough to probe. The | |
| 177 | 177 | // bind reuses listenUDPPacket so tests can stub it. | |
| 178 | 178 | t, err := NewTransportWithOptions(pc, peer, ready.key, 1, 2, Options{ | |
| 179 | + | Observe: DiagnosticLogObserver(d.Log), | |
| 179 | 180 | RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) { | |
| 180 | 181 | return listenUDPPacket(ctx) | |
| 181 | 182 | }, | |
| @@ -176,6 +176,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||||
| 176 | // fresh local UDP socket once the link is silent long enough to probe. The | 176 | // fresh local UDP socket once the link is silent long enough to probe. The | ||
| 177 | // bind reuses listenUDPPacket so tests can stub it. | 177 | // bind reuses listenUDPPacket so tests can stub it. | ||
| 178 | t, err := NewTransportWithOptions(pc, peer, ready.key, 1, 2, Options{ | 178 | t, err := NewTransportWithOptions(pc, peer, ready.key, 1, 2, Options{ | ||
| 179 | + | Observe: DiagnosticLogObserver(d.Log), | |||
| 179 | RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) { | 180 | RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) { | ||
| 180 | return listenUDPPacket(ctx) | 181 | return listenUDPPacket(ctx) | ||
| 181 | }, | 182 | }, | ||
M internal/adapters/dgram/transport.go +155 -69
| @@ -64,6 +64,8 @@ type Options struct { | |||
| 64 | 64 | MaxPendingWait time.Duration | |
| 65 | 65 | MaxRecvBuffer int | |
| 66 | 66 | Clock ports.Clock | |
| 67 | + | // Observe receives bounded, asynchronous, privacy-safe link health snapshots. | |
| 68 | + | Observe DiagnosticObserver | |
| 67 | 69 | // RebindPacketConn is an optional client-side hook used to hop the local UDP | |
| 68 | 70 | // socket when the peer is offline. Server/proxy transports should leave it nil. | |
| 69 | 71 | RebindPacketConn func(net.PacketConn) (net.PacketConn, error) | |
| @@ -137,41 +139,48 @@ type Transport struct { | |||
| 137 | 139 | sendDir, recvDir uint32 | |
| 138 | 140 | mtu int | |
| 139 | 141 | ||
| 140 | - | mu sync.Mutex | |
| 141 | - | peer net.Addr | |
| 142 | - | ctr uint64 | |
| 143 | - | seq uint64 | |
| 144 | - | probeSeq uint64 | |
| 145 | - | pending map[uint64]*pending | |
| 146 | - | closed bool | |
| 147 | - | closeErr error | |
| 148 | - | lastHeard time.Time | |
| 149 | - | heartbeat time.Duration | |
| 150 | - | resendAfter time.Duration | |
| 151 | - | maxResendAfter time.Duration | |
| 152 | - | maxResendPerTick int | |
| 153 | - | srtt time.Duration | |
| 154 | - | rttvar time.Duration | |
| 155 | - | rto time.Duration | |
| 156 | - | writeTimeout time.Duration | |
| 157 | - | degradedAfter time.Duration | |
| 158 | - | probeAfter time.Duration | |
| 159 | - | offlineAfter time.Duration | |
| 160 | - | deadAfter time.Duration | |
| 161 | - | maxPending int | |
| 162 | - | maxPendingWait time.Duration | |
| 163 | - | maxRecvBuffer int | |
| 164 | - | clock ports.Clock | |
| 165 | - | linkState ports.LinkState | |
| 166 | - | linkEvents chan ports.LinkEvent | |
| 167 | - | probeWait map[uint64]chan struct{} | |
| 168 | - | sendWake chan struct{} | |
| 169 | - | probeReply chan uint64 | |
| 170 | - | rebind func(net.PacketConn) (net.PacketConn, error) | |
| 171 | - | hoppedOffline bool | |
| 172 | - | outputQueue []queuedSend | |
| 173 | - | outputWake chan struct{} | |
| 174 | - | outputNext time.Time | |
| 142 | + | mu sync.Mutex | |
| 143 | + | peer net.Addr | |
| 144 | + | ctr uint64 | |
| 145 | + | seq uint64 | |
| 146 | + | probeSeq uint64 | |
| 147 | + | pending map[uint64]*pending | |
| 148 | + | closed bool | |
| 149 | + | closeErr error | |
| 150 | + | lastHeard time.Time | |
| 151 | + | lastAuthenticatedPacket time.Time | |
| 152 | + | lastCompleteRecord time.Time | |
| 153 | + | lastACKProgress time.Time | |
| 154 | + | retransmits uint64 | |
| 155 | + | reassemblyInflight int | |
| 156 | + | observe DiagnosticObserver | |
| 157 | + | diagnosticCh chan Diagnostic | |
| 158 | + | heartbeat time.Duration | |
| 159 | + | resendAfter time.Duration | |
| 160 | + | maxResendAfter time.Duration | |
| 161 | + | maxResendPerTick int | |
| 162 | + | srtt time.Duration | |
| 163 | + | rttvar time.Duration | |
| 164 | + | rto time.Duration | |
| 165 | + | writeTimeout time.Duration | |
| 166 | + | degradedAfter time.Duration | |
| 167 | + | probeAfter time.Duration | |
| 168 | + | offlineAfter time.Duration | |
| 169 | + | deadAfter time.Duration | |
| 170 | + | maxPending int | |
| 171 | + | maxPendingWait time.Duration | |
| 172 | + | maxRecvBuffer int | |
| 173 | + | clock ports.Clock | |
| 174 | + | linkState ports.LinkState | |
| 175 | + | linkEvents chan ports.LinkEvent | |
| 176 | + | probeWait map[uint64]chan struct{} | |
| 177 | + | sendWake chan struct{} | |
| 178 | + | probeReply chan uint64 | |
| 179 | + | rebind func(net.PacketConn) (net.PacketConn, error) | |
| 180 | + | hoppedOffline bool | |
| 181 | + | outputQueue []queuedSend | |
| 182 | + | outputWake chan struct{} | |
| 183 | + | outputNext time.Time | |
| 175 | 184 | ||
| 176 | 185 | recvMu sync.Mutex | |
| 177 | 186 | replay *pdgram.ReplayWindow | |
| @@ -214,41 +223,49 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD | |||
| 214 | 223 | } | |
| 215 | 224 | opts = normalizeOptions(opts) | |
| 216 | 225 | t := &Transport{ | |
| 217 | - | pc: pc, | |
| 218 | - | codec: c, | |
| 219 | - | sendDir: sendDir, | |
| 220 | - | recvDir: recvDir, | |
| 221 | - | mtu: opts.MTU, | |
| 222 | - | peer: peer, | |
| 223 | - | pending: make(map[uint64]*pending), | |
| 224 | - | replay: pdgram.NewReplayWindow(), | |
| 225 | - | reasm: pdgram.NewReassembler(), | |
| 226 | - | in: make(chan ports.Frame, 32), | |
| 227 | - | done: make(chan struct{}), | |
| 228 | - | lastHeard: opts.Clock.Now(), | |
| 229 | - | heartbeat: opts.Heartbeat, | |
| 230 | - | resendAfter: opts.ResendAfter, | |
| 231 | - | maxResendAfter: opts.MaxResendAfter, | |
| 232 | - | maxResendPerTick: opts.MaxResendPerTick, | |
| 233 | - | rto: opts.ResendAfter, | |
| 234 | - | writeTimeout: opts.WriteTimeout, | |
| 235 | - | degradedAfter: opts.DegradedAfter, | |
| 236 | - | probeAfter: opts.ProbeAfter, | |
| 237 | - | offlineAfter: opts.OfflineAfter, | |
| 238 | - | deadAfter: opts.DeadAfter, | |
| 239 | - | maxPending: opts.MaxPending, | |
| 240 | - | maxPendingWait: opts.MaxPendingWait, | |
| 241 | - | maxRecvBuffer: opts.MaxRecvBuffer, | |
| 242 | - | clock: opts.Clock, | |
| 243 | - | linkState: ports.LinkStateConnected, | |
| 244 | - | linkEvents: make(chan ports.LinkEvent, linkEventBufferSize), | |
| 245 | - | probeWait: make(map[uint64]chan struct{}), | |
| 246 | - | rebind: opts.RebindPacketConn, | |
| 247 | - | nextRecvSeq: 1, | |
| 248 | - | recvBuf: make(map[uint64]ports.Frame), | |
| 226 | + | pc: pc, | |
| 227 | + | codec: c, | |
| 228 | + | sendDir: sendDir, | |
| 229 | + | recvDir: recvDir, | |
| 230 | + | mtu: opts.MTU, | |
| 231 | + | peer: peer, | |
| 232 | + | pending: make(map[uint64]*pending), | |
| 233 | + | replay: pdgram.NewReplayWindow(), | |
| 234 | + | reasm: pdgram.NewReassembler(), | |
| 235 | + | in: make(chan ports.Frame, 32), | |
| 236 | + | done: make(chan struct{}), | |
| 237 | + | lastHeard: opts.Clock.Now(), | |
| 238 | + | lastAuthenticatedPacket: opts.Clock.Now(), | |
| 239 | + | lastCompleteRecord: opts.Clock.Now(), | |
| 240 | + | lastACKProgress: opts.Clock.Now(), | |
| 241 | + | observe: opts.Observe, | |
| 242 | + | heartbeat: opts.Heartbeat, | |
| 243 | + | resendAfter: opts.ResendAfter, | |
| 244 | + | maxResendAfter: opts.MaxResendAfter, | |
| 245 | + | maxResendPerTick: opts.MaxResendPerTick, | |
| 246 | + | rto: opts.ResendAfter, | |
| 247 | + | writeTimeout: opts.WriteTimeout, | |
| 248 | + | degradedAfter: opts.DegradedAfter, | |
| 249 | + | probeAfter: opts.ProbeAfter, | |
| 250 | + | offlineAfter: opts.OfflineAfter, | |
| 251 | + | deadAfter: opts.DeadAfter, | |
| 252 | + | maxPending: opts.MaxPending, | |
| 253 | + | maxPendingWait: opts.MaxPendingWait, | |
| 254 | + | maxRecvBuffer: opts.MaxRecvBuffer, | |
| 255 | + | clock: opts.Clock, | |
| 256 | + | linkState: ports.LinkStateConnected, | |
| 257 | + | linkEvents: make(chan ports.LinkEvent, linkEventBufferSize), | |
| 258 | + | probeWait: make(map[uint64]chan struct{}), | |
| 259 | + | rebind: opts.RebindPacketConn, | |
| 260 | + | nextRecvSeq: 1, | |
| 261 | + | recvBuf: make(map[uint64]ports.Frame), | |
| 249 | 262 | } | |
| 250 | 263 | t.sendWake = make(chan struct{}) | |
| 251 | 264 | t.outputWake = make(chan struct{}) | |
| 265 | + | if t.observe != nil { | |
| 266 | + | t.diagnosticCh = make(chan Diagnostic, diagnosticBufferSize) | |
| 267 | + | go t.diagnosticLoop() | |
| 268 | + | } | |
| 252 | 269 | t.probeReply = make(chan uint64, probeReplyBufferSize) | |
| 253 | 270 | t.deliverCond = sync.NewCond(&t.deliverMu) | |
| 254 | 271 | go t.readLoop(pc) | |
| @@ -256,6 +273,7 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD | |||
| 256 | 273 | go t.deliveryLoop() | |
| 257 | 274 | go t.probeReplyLoop() | |
| 258 | 275 | go t.outputPaceLoop() | |
| 276 | + | t.emitDiagnostic() | |
| 259 | 277 | return t, nil | |
| 260 | 278 | } | |
| 261 | 279 | ||
| @@ -666,16 +684,32 @@ func (t *Transport) readLoop(pc net.PacketConn) { | |||
| 666 | 684 | t.recvMu.Unlock() | |
| 667 | 685 | continue | |
| 668 | 686 | } | |
| 669 | - | payload, ok, err := t.reasm.Add(frag) | |
| 687 | + | payload, complete, err := t.reasm.Add(frag) | |
| 688 | + | inflight := t.reasm.Inflight() | |
| 670 | 689 | t.recvMu.Unlock() | |
| 671 | - | if err != nil || !ok { | |
| 690 | + | t.recordAuthenticatedFragment(inflight) | |
| 691 | + | if err != nil || !complete { | |
| 672 | 692 | continue | |
| 673 | 693 | } | |
| 694 | + | t.recordCompleteRecord() | |
| 674 | 695 | t.updatePeerFromAuthenticated(addr) | |
| 675 | 696 | t.handleRecord(payload) | |
| 676 | 697 | } | |
| 677 | 698 | } | |
| 678 | 699 | ||
| 700 | + | func (t *Transport) recordAuthenticatedFragment(inflight int) { | |
| 701 | + | t.mu.Lock() | |
| 702 | + | t.lastAuthenticatedPacket = t.clock.Now() | |
| 703 | + | t.reassemblyInflight = inflight | |
| 704 | + | t.mu.Unlock() | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | func (t *Transport) recordCompleteRecord() { | |
| 708 | + | t.mu.Lock() | |
| 709 | + | t.lastCompleteRecord = t.clock.Now() | |
| 710 | + | t.mu.Unlock() | |
| 711 | + | } | |
| 712 | + | ||
| 679 | 713 | func (t *Transport) updatePeerFromAuthenticated(addr net.Addr) { | |
| 680 | 714 | t.mu.Lock() | |
| 681 | 715 | t.peer = addr | |
| @@ -708,9 +742,13 @@ func (t *Transport) handleRecord(p []byte) { | |||
| 708 | 742 | acked = true | |
| 709 | 743 | } | |
| 710 | 744 | if acked { | |
| 745 | + | t.lastACKProgress = t.clock.Now() | |
| 711 | 746 | t.notifySendWaitersLocked() | |
| 712 | 747 | } | |
| 713 | 748 | t.mu.Unlock() | |
| 749 | + | if acked { | |
| 750 | + | t.emitDiagnostic() | |
| 751 | + | } | |
| 714 | 752 | case recProbe: | |
| 715 | 753 | if len(p) != 9 { | |
| 716 | 754 | return | |
| @@ -871,6 +909,7 @@ func (t *Transport) resendPending() { | |||
| 871 | 909 | } | |
| 872 | 910 | slices.Sort(seqs) | |
| 873 | 911 | limit := t.maxResendPerTick | |
| 912 | + | emitDiagnostic := false | |
| 874 | 913 | for _, seq := range seqs { | |
| 875 | 914 | if limit <= 0 { | |
| 876 | 915 | break | |
| @@ -882,6 +921,8 @@ func (t *Transport) resendPending() { | |||
| 882 | 921 | p.initialInFlight = true | |
| 883 | 922 | p.attempts++ | |
| 884 | 923 | p.retransmitted = true | |
| 924 | + | t.retransmits++ | |
| 925 | + | emitDiagnostic = emitDiagnostic || t.retransmits%64 == 0 | |
| 885 | 926 | resend = append(resend, struct { | |
| 886 | 927 | seq uint64 | |
| 887 | 928 | f ports.Frame | |
| @@ -889,6 +930,9 @@ func (t *Transport) resendPending() { | |||
| 889 | 930 | limit-- | |
| 890 | 931 | } | |
| 891 | 932 | t.mu.Unlock() | |
| 933 | + | if emitDiagnostic { | |
| 934 | + | t.emitDiagnostic() | |
| 935 | + | } | |
| 892 | 936 | for _, r := range resend { | |
| 893 | 937 | if err := t.sendData(r.seq, true, r.f); err == nil { | |
| 894 | 938 | t.markPendingReady(r.seq, true) | |
| @@ -1057,6 +1101,7 @@ func (t *Transport) setLinkState(state ports.LinkState, err error) { | |||
| 1057 | 1101 | } | |
| 1058 | 1102 | t.notifySendWaitersLocked() | |
| 1059 | 1103 | t.mu.Unlock() | |
| 1104 | + | t.emitDiagnostic() | |
| 1060 | 1105 | event := ports.LinkEvent{State: state, At: now, Err: err} | |
| 1061 | 1106 | select { | |
| 1062 | 1107 | case t.linkEvents <- event: | |
| @@ -1064,6 +1109,47 @@ func (t *Transport) setLinkState(state ports.LinkState, err error) { | |||
| 1064 | 1109 | } | |
| 1065 | 1110 | } | |
| 1066 | 1111 | ||
| 1112 | + | func (t *Transport) diagnosticLoop() { | |
| 1113 | + | for { | |
| 1114 | + | select { | |
| 1115 | + | case d := <-t.diagnosticCh: | |
| 1116 | + | t.observe(d) | |
| 1117 | + | case <-t.done: | |
| 1118 | + | return | |
| 1119 | + | } | |
| 1120 | + | } | |
| 1121 | + | } | |
| 1122 | + | ||
| 1123 | + | func (t *Transport) emitDiagnostic() { | |
| 1124 | + | if t.diagnosticCh == nil { | |
| 1125 | + | return | |
| 1126 | + | } | |
| 1127 | + | now := t.clock.Now() | |
| 1128 | + | t.mu.Lock() | |
| 1129 | + | pendingBytes := 0 | |
| 1130 | + | for _, p := range t.pending { | |
| 1131 | + | if p != nil { | |
| 1132 | + | pendingBytes += len(p.frame.Payload) | |
| 1133 | + | } | |
| 1134 | + | } | |
| 1135 | + | d := Diagnostic{ | |
| 1136 | + | At: now, | |
| 1137 | + | State: t.linkState, | |
| 1138 | + | SinceAuthenticatedPacket: now.Sub(t.lastAuthenticatedPacket), | |
| 1139 | + | SinceCompleteRecord: now.Sub(t.lastCompleteRecord), | |
| 1140 | + | SinceACKProgress: now.Sub(t.lastACKProgress), | |
| 1141 | + | PendingRecords: len(t.pending), | |
| 1142 | + | PendingBytes: pendingBytes, | |
| 1143 | + | Retransmits: t.retransmits, | |
| 1144 | + | ReassemblyInflight: t.reassemblyInflight, | |
| 1145 | + | } | |
| 1146 | + | t.mu.Unlock() | |
| 1147 | + | select { | |
| 1148 | + | case t.diagnosticCh <- d: | |
| 1149 | + | default: | |
| 1150 | + | } | |
| 1151 | + | } | |
| 1152 | + | ||
| 1067 | 1153 | func (t *Transport) closeWithError(err error) { | |
| 1068 | 1154 | t.mu.Lock() | |
| 1069 | 1155 | if t.closed { | |
| @@ -64,6 +64,8 @@ type Options struct { | |||||
| 64 | MaxPendingWait time.Duration | 64 | MaxPendingWait time.Duration | ||
| 65 | MaxRecvBuffer int | 65 | MaxRecvBuffer int | ||
| 66 | Clock ports.Clock | 66 | Clock ports.Clock | ||
| 67 | + | // Observe receives bounded, asynchronous, privacy-safe link health snapshots. | |||
| 68 | + | Observe DiagnosticObserver | |||
| 67 | // RebindPacketConn is an optional client-side hook used to hop the local UDP | 69 | // RebindPacketConn is an optional client-side hook used to hop the local UDP | ||
| 68 | // socket when the peer is offline. Server/proxy transports should leave it nil. | 70 | // socket when the peer is offline. Server/proxy transports should leave it nil. | ||
| 69 | RebindPacketConn func(net.PacketConn) (net.PacketConn, error) | 71 | RebindPacketConn func(net.PacketConn) (net.PacketConn, error) | ||
| @@ -137,41 +139,48 @@ type Transport struct { | |||||
| 137 | sendDir, recvDir uint32 | 139 | sendDir, recvDir uint32 | ||
| 138 | mtu int | 140 | mtu int | ||
| 139 | 141 | ||||
| 140 | - | mu sync.Mutex | 142 | + | mu sync.Mutex |
| 141 | - | peer net.Addr | 143 | + | peer net.Addr |
| 142 | - | ctr uint64 | 144 | + | ctr uint64 |
| 143 | - | seq uint64 | 145 | + | seq uint64 |
| 144 | - | probeSeq uint64 | 146 | + | probeSeq uint64 |
| 145 | - | pending map[uint64]*pending | 147 | + | pending map[uint64]*pending |
| 146 | - | closed bool | 148 | + | closed bool |
| 147 | - | closeErr error | 149 | + | closeErr error |
| 148 | - | lastHeard time.Time | 150 | + | lastHeard time.Time |
| 149 | - | heartbeat time.Duration | 151 | + | lastAuthenticatedPacket time.Time |
| 150 | - | resendAfter time.Duration | 152 | + | lastCompleteRecord time.Time |
| 151 | - | maxResendAfter time.Duration | 153 | + | lastACKProgress time.Time |
| 152 | - | maxResendPerTick int | 154 | + | retransmits uint64 |
| 153 | - | srtt time.Duration | 155 | + | reassemblyInflight int |
| 154 | - | rttvar time.Duration | 156 | + | observe DiagnosticObserver |
| 155 | - | rto time.Duration | 157 | + | diagnosticCh chan Diagnostic |
| 156 | - | writeTimeout time.Duration | 158 | + | heartbeat time.Duration |
| 157 | - | degradedAfter time.Duration | 159 | + | resendAfter time.Duration |
| 158 | - | probeAfter time.Duration | 160 | + | maxResendAfter time.Duration |
| 159 | - | offlineAfter time.Duration | 161 | + | maxResendPerTick int |
| 160 | - | deadAfter time.Duration | 162 | + | srtt time.Duration |
| 161 | - | maxPending int | 163 | + | rttvar time.Duration |
| 162 | - | maxPendingWait time.Duration | 164 | + | rto time.Duration |
| 163 | - | maxRecvBuffer int | 165 | + | writeTimeout time.Duration |
| 164 | - | clock ports.Clock | 166 | + | degradedAfter time.Duration |
| 165 | - | linkState ports.LinkState | 167 | + | probeAfter time.Duration |
| 166 | - | linkEvents chan ports.LinkEvent | 168 | + | offlineAfter time.Duration |
| 167 | - | probeWait map[uint64]chan struct{} | 169 | + | deadAfter time.Duration |
| 168 | - | sendWake chan struct{} | 170 | + | maxPending int |
| 169 | - | probeReply chan uint64 | 171 | + | maxPendingWait time.Duration |
| 170 | - | rebind func(net.PacketConn) (net.PacketConn, error) | 172 | + | maxRecvBuffer int |
| 171 | - | hoppedOffline bool | 173 | + | clock ports.Clock |
| 172 | - | outputQueue []queuedSend | 174 | + | linkState ports.LinkState |
| 173 | - | outputWake chan struct{} | 175 | + | linkEvents chan ports.LinkEvent |
| 174 | - | outputNext time.Time | 176 | + | probeWait map[uint64]chan struct{} |
| 177 | + | sendWake chan struct{} | |||
| 178 | + | probeReply chan uint64 | |||
| 179 | + | rebind func(net.PacketConn) (net.PacketConn, error) | |||
| 180 | + | hoppedOffline bool | |||
| 181 | + | outputQueue []queuedSend | |||
| 182 | + | outputWake chan struct{} | |||
| 183 | + | outputNext time.Time | |||
| 175 | 184 | ||||
| 176 | recvMu sync.Mutex | 185 | recvMu sync.Mutex | ||
| 177 | replay *pdgram.ReplayWindow | 186 | replay *pdgram.ReplayWindow | ||
| @@ -214,41 +223,49 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD | |||||
| 214 | } | 223 | } | ||
| 215 | opts = normalizeOptions(opts) | 224 | opts = normalizeOptions(opts) | ||
| 216 | t := &Transport{ | 225 | t := &Transport{ | ||
| 217 | - | pc: pc, | 226 | + | pc: pc, |
| 218 | - | codec: c, | 227 | + | codec: c, |
| 219 | - | sendDir: sendDir, | 228 | + | sendDir: sendDir, |
| 220 | - | recvDir: recvDir, | 229 | + | recvDir: recvDir, |
| 221 | - | mtu: opts.MTU, | 230 | + | mtu: opts.MTU, |
| 222 | - | peer: peer, | 231 | + | peer: peer, |
| 223 | - | pending: make(map[uint64]*pending), | 232 | + | pending: make(map[uint64]*pending), |
| 224 | - | replay: pdgram.NewReplayWindow(), | 233 | + | replay: pdgram.NewReplayWindow(), |
| 225 | - | reasm: pdgram.NewReassembler(), | 234 | + | reasm: pdgram.NewReassembler(), |
| 226 | - | in: make(chan ports.Frame, 32), | 235 | + | in: make(chan ports.Frame, 32), |
| 227 | - | done: make(chan struct{}), | 236 | + | done: make(chan struct{}), |
| 228 | - | lastHeard: opts.Clock.Now(), | 237 | + | lastHeard: opts.Clock.Now(), |
| 229 | - | heartbeat: opts.Heartbeat, | 238 | + | lastAuthenticatedPacket: opts.Clock.Now(), |
| 230 | - | resendAfter: opts.ResendAfter, | 239 | + | lastCompleteRecord: opts.Clock.Now(), |
| 231 | - | maxResendAfter: opts.MaxResendAfter, | 240 | + | lastACKProgress: opts.Clock.Now(), |
| 232 | - | maxResendPerTick: opts.MaxResendPerTick, | 241 | + | observe: opts.Observe, |
| 233 | - | rto: opts.ResendAfter, | 242 | + | heartbeat: opts.Heartbeat, |
| 234 | - | writeTimeout: opts.WriteTimeout, | 243 | + | resendAfter: opts.ResendAfter, |
| 235 | - | degradedAfter: opts.DegradedAfter, | 244 | + | maxResendAfter: opts.MaxResendAfter, |
| 236 | - | probeAfter: opts.ProbeAfter, | 245 | + | maxResendPerTick: opts.MaxResendPerTick, |
| 237 | - | offlineAfter: opts.OfflineAfter, | 246 | + | rto: opts.ResendAfter, |
| 238 | - | deadAfter: opts.DeadAfter, | 247 | + | writeTimeout: opts.WriteTimeout, |
| 239 | - | maxPending: opts.MaxPending, | 248 | + | degradedAfter: opts.DegradedAfter, |
| 240 | - | maxPendingWait: opts.MaxPendingWait, | 249 | + | probeAfter: opts.ProbeAfter, |
| 241 | - | maxRecvBuffer: opts.MaxRecvBuffer, | 250 | + | offlineAfter: opts.OfflineAfter, |
| 242 | - | clock: opts.Clock, | 251 | + | deadAfter: opts.DeadAfter, |
| 243 | - | linkState: ports.LinkStateConnected, | 252 | + | maxPending: opts.MaxPending, |
| 244 | - | linkEvents: make(chan ports.LinkEvent, linkEventBufferSize), | 253 | + | maxPendingWait: opts.MaxPendingWait, |
| 245 | - | probeWait: make(map[uint64]chan struct{}), | 254 | + | maxRecvBuffer: opts.MaxRecvBuffer, |
| 246 | - | rebind: opts.RebindPacketConn, | 255 | + | clock: opts.Clock, |
| 247 | - | nextRecvSeq: 1, | 256 | + | linkState: ports.LinkStateConnected, |
| 248 | - | recvBuf: make(map[uint64]ports.Frame), | 257 | + | linkEvents: make(chan ports.LinkEvent, linkEventBufferSize), |
| 258 | + | probeWait: make(map[uint64]chan struct{}), | |||
| 259 | + | rebind: opts.RebindPacketConn, | |||
| 260 | + | nextRecvSeq: 1, | |||
| 261 | + | recvBuf: make(map[uint64]ports.Frame), | |||
| 249 | } | 262 | } | ||
| 250 | t.sendWake = make(chan struct{}) | 263 | t.sendWake = make(chan struct{}) | ||
| 251 | t.outputWake = make(chan struct{}) | 264 | t.outputWake = make(chan struct{}) | ||
| 265 | + | if t.observe != nil { | |||
| 266 | + | t.diagnosticCh = make(chan Diagnostic, diagnosticBufferSize) | |||
| 267 | + | go t.diagnosticLoop() | |||
| 268 | + | } | |||
| 252 | t.probeReply = make(chan uint64, probeReplyBufferSize) | 269 | t.probeReply = make(chan uint64, probeReplyBufferSize) | ||
| 253 | t.deliverCond = sync.NewCond(&t.deliverMu) | 270 | t.deliverCond = sync.NewCond(&t.deliverMu) | ||
| 254 | go t.readLoop(pc) | 271 | go t.readLoop(pc) | ||
| @@ -256,6 +273,7 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD | |||||
| 256 | go t.deliveryLoop() | 273 | go t.deliveryLoop() | ||
| 257 | go t.probeReplyLoop() | 274 | go t.probeReplyLoop() | ||
| 258 | go t.outputPaceLoop() | 275 | go t.outputPaceLoop() | ||
| 276 | + | t.emitDiagnostic() | |||
| 259 | return t, nil | 277 | return t, nil | ||
| 260 | } | 278 | } | ||
| 261 | 279 | ||||
| @@ -666,16 +684,32 @@ func (t *Transport) readLoop(pc net.PacketConn) { | |||||
| 666 | t.recvMu.Unlock() | 684 | t.recvMu.Unlock() | ||
| 667 | continue | 685 | continue | ||
| 668 | } | 686 | } | ||
| 669 | - | payload, ok, err := t.reasm.Add(frag) | 687 | + | payload, complete, err := t.reasm.Add(frag) |
| 688 | + | inflight := t.reasm.Inflight() | |||
| 670 | t.recvMu.Unlock() | 689 | t.recvMu.Unlock() | ||
| 671 | - | if err != nil || !ok { | 690 | + | t.recordAuthenticatedFragment(inflight) |
| 691 | + | if err != nil || !complete { | |||
| 672 | continue | 692 | continue | ||
| 673 | } | 693 | } | ||
| 694 | + | t.recordCompleteRecord() | |||
| 674 | t.updatePeerFromAuthenticated(addr) | 695 | t.updatePeerFromAuthenticated(addr) | ||
| 675 | t.handleRecord(payload) | 696 | t.handleRecord(payload) | ||
| 676 | } | 697 | } | ||
| 677 | } | 698 | } | ||
| 678 | 699 | ||||
| 700 | + | func (t *Transport) recordAuthenticatedFragment(inflight int) { | |||
| 701 | + | t.mu.Lock() | |||
| 702 | + | t.lastAuthenticatedPacket = t.clock.Now() | |||
| 703 | + | t.reassemblyInflight = inflight | |||
| 704 | + | t.mu.Unlock() | |||
| 705 | + | } | |||
| 706 | + | ||||
| 707 | + | func (t *Transport) recordCompleteRecord() { | |||
| 708 | + | t.mu.Lock() | |||
| 709 | + | t.lastCompleteRecord = t.clock.Now() | |||
| 710 | + | t.mu.Unlock() | |||
| 711 | + | } | |||
| 712 | + | ||||
| 679 | func (t *Transport) updatePeerFromAuthenticated(addr net.Addr) { | 713 | func (t *Transport) updatePeerFromAuthenticated(addr net.Addr) { | ||
| 680 | t.mu.Lock() | 714 | t.mu.Lock() | ||
| 681 | t.peer = addr | 715 | t.peer = addr | ||
| @@ -708,9 +742,13 @@ func (t *Transport) handleRecord(p []byte) { | |||||
| 708 | acked = true | 742 | acked = true | ||
| 709 | } | 743 | } | ||
| 710 | if acked { | 744 | if acked { | ||
| 745 | + | t.lastACKProgress = t.clock.Now() | |||
| 711 | t.notifySendWaitersLocked() | 746 | t.notifySendWaitersLocked() | ||
| 712 | } | 747 | } | ||
| 713 | t.mu.Unlock() | 748 | t.mu.Unlock() | ||
| 749 | + | if acked { | |||
| 750 | + | t.emitDiagnostic() | |||
| 751 | + | } | |||
| 714 | case recProbe: | 752 | case recProbe: | ||
| 715 | if len(p) != 9 { | 753 | if len(p) != 9 { | ||
| 716 | return | 754 | return | ||
| @@ -871,6 +909,7 @@ func (t *Transport) resendPending() { | |||||
| 871 | } | 909 | } | ||
| 872 | slices.Sort(seqs) | 910 | slices.Sort(seqs) | ||
| 873 | limit := t.maxResendPerTick | 911 | limit := t.maxResendPerTick | ||
| 912 | + | emitDiagnostic := false | |||
| 874 | for _, seq := range seqs { | 913 | for _, seq := range seqs { | ||
| 875 | if limit <= 0 { | 914 | if limit <= 0 { | ||
| 876 | break | 915 | break | ||
| @@ -882,6 +921,8 @@ func (t *Transport) resendPending() { | |||||
| 882 | p.initialInFlight = true | 921 | p.initialInFlight = true | ||
| 883 | p.attempts++ | 922 | p.attempts++ | ||
| 884 | p.retransmitted = true | 923 | p.retransmitted = true | ||
| 924 | + | t.retransmits++ | |||
| 925 | + | emitDiagnostic = emitDiagnostic || t.retransmits%64 == 0 | |||
| 885 | resend = append(resend, struct { | 926 | resend = append(resend, struct { | ||
| 886 | seq uint64 | 927 | seq uint64 | ||
| 887 | f ports.Frame | 928 | f ports.Frame | ||
| @@ -889,6 +930,9 @@ func (t *Transport) resendPending() { | |||||
| 889 | limit-- | 930 | limit-- | ||
| 890 | } | 931 | } | ||
| 891 | t.mu.Unlock() | 932 | t.mu.Unlock() | ||
| 933 | + | if emitDiagnostic { | |||
| 934 | + | t.emitDiagnostic() | |||
| 935 | + | } | |||
| 892 | for _, r := range resend { | 936 | for _, r := range resend { | ||
| 893 | if err := t.sendData(r.seq, true, r.f); err == nil { | 937 | if err := t.sendData(r.seq, true, r.f); err == nil { | ||
| 894 | t.markPendingReady(r.seq, true) | 938 | t.markPendingReady(r.seq, true) | ||
| @@ -1057,6 +1101,7 @@ func (t *Transport) setLinkState(state ports.LinkState, err error) { | |||||
| 1057 | } | 1101 | } | ||
| 1058 | t.notifySendWaitersLocked() | 1102 | t.notifySendWaitersLocked() | ||
| 1059 | t.mu.Unlock() | 1103 | t.mu.Unlock() | ||
| 1104 | + | t.emitDiagnostic() | |||
| 1060 | event := ports.LinkEvent{State: state, At: now, Err: err} | 1105 | event := ports.LinkEvent{State: state, At: now, Err: err} | ||
| 1061 | select { | 1106 | select { | ||
| 1062 | case t.linkEvents <- event: | 1107 | case t.linkEvents <- event: | ||
| @@ -1064,6 +1109,47 @@ func (t *Transport) setLinkState(state ports.LinkState, err error) { | |||||
| 1064 | } | 1109 | } | ||
| 1065 | } | 1110 | } | ||
| 1066 | 1111 | ||||
| 1112 | + | func (t *Transport) diagnosticLoop() { | |||
| 1113 | + | for { | |||
| 1114 | + | select { | |||
| 1115 | + | case d := <-t.diagnosticCh: | |||
| 1116 | + | t.observe(d) | |||
| 1117 | + | case <-t.done: | |||
| 1118 | + | return | |||
| 1119 | + | } | |||
| 1120 | + | } | |||
| 1121 | + | } | |||
| 1122 | + | ||||
| 1123 | + | func (t *Transport) emitDiagnostic() { | |||
| 1124 | + | if t.diagnosticCh == nil { | |||
| 1125 | + | return | |||
| 1126 | + | } | |||
| 1127 | + | now := t.clock.Now() | |||
| 1128 | + | t.mu.Lock() | |||
| 1129 | + | pendingBytes := 0 | |||
| 1130 | + | for _, p := range t.pending { | |||
| 1131 | + | if p != nil { | |||
| 1132 | + | pendingBytes += len(p.frame.Payload) | |||
| 1133 | + | } | |||
| 1134 | + | } | |||
| 1135 | + | d := Diagnostic{ | |||
| 1136 | + | At: now, | |||
| 1137 | + | State: t.linkState, | |||
| 1138 | + | SinceAuthenticatedPacket: now.Sub(t.lastAuthenticatedPacket), | |||
| 1139 | + | SinceCompleteRecord: now.Sub(t.lastCompleteRecord), | |||
| 1140 | + | SinceACKProgress: now.Sub(t.lastACKProgress), | |||
| 1141 | + | PendingRecords: len(t.pending), | |||
| 1142 | + | PendingBytes: pendingBytes, | |||
| 1143 | + | Retransmits: t.retransmits, | |||
| 1144 | + | ReassemblyInflight: t.reassemblyInflight, | |||
| 1145 | + | } | |||
| 1146 | + | t.mu.Unlock() | |||
| 1147 | + | select { | |||
| 1148 | + | case t.diagnosticCh <- d: | |||
| 1149 | + | default: | |||
| 1150 | + | } | |||
| 1151 | + | } | |||
| 1152 | + | ||||
| 1067 | func (t *Transport) closeWithError(err error) { | 1153 | func (t *Transport) closeWithError(err error) { | ||
| 1068 | t.mu.Lock() | 1154 | t.mu.Lock() | ||
| 1069 | if t.closed { | 1155 | if t.closed { | ||
M internal/app/run.go +3 -1
| @@ -745,7 +745,9 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error { | |||
| 745 | 745 | return err | |
| 746 | 746 | } | |
| 747 | 747 | defer func() { _ = daemonTr.Close() }() | |
| 748 | - | dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpProxyClientTransportOptions) | |
| 748 | + | udpOptions := udpProxyClientTransportOptions | |
| 749 | + | udpOptions.Observe = dgram.DiagnosticLogObserver(log) | |
| 750 | + | dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions) | |
| 749 | 751 | if err != nil { | |
| 750 | 752 | return err | |
| 751 | 753 | } | |
| @@ -745,7 +745,9 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error { | |||||
| 745 | return err | 745 | return err | ||
| 746 | } | 746 | } | ||
| 747 | defer func() { _ = daemonTr.Close() }() | 747 | defer func() { _ = daemonTr.Close() }() | ||
| 748 | - | dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpProxyClientTransportOptions) | 748 | + | udpOptions := udpProxyClientTransportOptions |
| 749 | + | udpOptions.Observe = dgram.DiagnosticLogObserver(log) | |||
| 750 | + | dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions) | |||
| 749 | if err != nil { | 751 | if err != nil { | ||
| 750 | return err | 752 | return err | ||
| 751 | } | 753 | } | ||
M pkg/dgram/dgram.go +4 -0
| @@ -191,6 +191,10 @@ type assembly struct { | |||
| 191 | 191 | } | |
| 192 | 192 | ||
| 193 | 193 | func NewReassembler() *Reassembler { return &Reassembler{inflight: make(map[uint64]*assembly)} } | |
| 194 | + | ||
| 195 | + | // Inflight returns the number of incomplete records currently being reassembled. | |
| 196 | + | func (r *Reassembler) Inflight() int { return len(r.inflight) } | |
| 197 | + | ||
| 194 | 198 | func (r *Reassembler) Add(f Fragment) ([]byte, bool, error) { | |
| 195 | 199 | if f.Count == 0 || int(f.Count) > maxFragmentCount || f.Index >= f.Count { | |
| 196 | 200 | return nil, false, ErrFragment | |
| @@ -191,6 +191,10 @@ type assembly struct { | |||||
| 191 | } | 191 | } | ||
| 192 | 192 | ||||
| 193 | func NewReassembler() *Reassembler { return &Reassembler{inflight: make(map[uint64]*assembly)} } | 193 | func NewReassembler() *Reassembler { return &Reassembler{inflight: make(map[uint64]*assembly)} } | ||
| 194 | + | ||||
| 195 | + | // Inflight returns the number of incomplete records currently being reassembled. | |||
| 196 | + | func (r *Reassembler) Inflight() int { return len(r.inflight) } | |||
| 197 | + | ||||
| 194 | func (r *Reassembler) Add(f Fragment) ([]byte, bool, error) { | 198 | func (r *Reassembler) Add(f Fragment) ([]byte, bool, error) { | ||
| 195 | if f.Count == 0 || int(f.Count) > maxFragmentCount || f.Index >= f.Count { | 199 | if f.Count == 0 || int(f.Count) > maxFragmentCount || f.Index >= f.Count { | ||
| 196 | return nil, false, ErrFragment | 200 | return nil, false, ErrFragment | ||