using System.Collections.Concurrent; namespace gatuna_client; sealed class PingTest { readonly TunnelLink _link; readonly byte[] _serverMac; readonly CancellationTokenSource _cts = new(); readonly ConcurrentDictionary _outstanding = new(); readonly ConcurrentQueue _rtts = new(); long _sent; long _received; double _lastRttMs; double _jitterSum; long _jitterCount; public event Action? StatsUpdated; public event Action? Log; public bool Running { get; private set; } public PingTest(TunnelLink link, byte[] serverMac) { _link = link; _serverMac = serverMac; } public void Start() { Running = true; _ = RunLoop(); } public void Stop() { Running = false; _cts.Cancel(); } async Task RunLoop() { var rng = new Random(); var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(10)); while (!_cts.IsCancellationRequested) { var nonce = (ulong)Interlocked.Increment(ref _sent); var ticks = DateTime.UtcNow.Ticks; _outstanding[nonce] = ticks; _link.SendTo(_serverMac, new Frame.Ping(nonce)); var interval = rng.Next(10, 101); try { await Task.Delay(interval, _cts.Token); } catch { break; } } Running = false; } public void HandlePong(ulong nonce) { if (_outstanding.TryRemove(nonce, out var sentTicks)) { var rttMs = (DateTime.UtcNow.Ticks - sentTicks) / (double)TimeSpan.TicksPerMillisecond; _rtts.Enqueue(rttMs); Interlocked.Increment(ref _received); if (_jitterCount > 0) { _jitterSum += Math.Abs(rttMs - _lastRttMs); } _lastRttMs = rttMs; Interlocked.Increment(ref _jitterCount); EmitStats(); } } void EmitStats() { var sent = Interlocked.Read(ref _sent); var recv = Interlocked.Read(ref _received); var loss = sent > 0 ? (1.0 - (double)recv / sent) * 100.0 : 0; var rttList = _rtts.ToArray(); var avg = rttList.Length > 0 ? rttList.Average() : 0; var jitter = _jitterCount > 1 ? _jitterSum / (_jitterCount - 1) : 0; StatsUpdated?.Invoke(new PingStats(sent, recv, loss, avg, jitter)); } } readonly record struct PingStats( long Sent, long Received, double LossPct, double AvgLatencyMs, double JitterMs);