add network test feature (PING/PONG with latency stats)
New frame types PING (0x0B) and PONG (0x0C), each carrying an 8-byte nonce. Server echoes PING nonce verbatim in PONG. Client sends pings at random 10-100ms intervals, correlates nonces to measure RTT. Stats shown live: sent/recv counts, loss %, average latency, jitter (mean absolute delta of consecutive RTTs). Test button toggles on/off. Updated both Rust server (echo in main.rs) and C# client (PingTest.cs, SessionManager routing, MainForm Test button + stats label).
This commit is contained in:
+14
@@ -46,6 +46,8 @@ emitted in v1.
|
||||
| 0x05 | OPEN_NAK | S → C | 0 | `upstream_id:1, reason:1` |
|
||||
| 0x06 | DATA | both | session | raw bytes (≤1480) |
|
||||
| 0x07 | CLOSE | both | session | optional `reason:1` |
|
||||
| 0x0B | PING | C → S | 0 | `nonce:8` |
|
||||
| 0x0C | PONG | S → C | 0 | `nonce:8` (echoed) |
|
||||
|
||||
Reserved (unimplemented in v1; parse returns Err, encode unimplemented):
|
||||
|
||||
@@ -140,6 +142,18 @@ field identifies which session the bytes belong to.
|
||||
|
||||
- **reason** (u8, optional): present iff payload length ≥ 1. See reason codes.
|
||||
|
||||
### PING / PONG payload
|
||||
|
||||
```
|
||||
+ +
|
||||
| nonce (big-endian, 8 bytes) |
|
||||
+ +
|
||||
```
|
||||
|
||||
- **nonce** (u64, big-endian): arbitrary value chosen by the client. The
|
||||
server echoes it verbatim in the PONG reply. Used to correlate RTT
|
||||
measurements.
|
||||
|
||||
## Reason codes
|
||||
|
||||
| Value | Meaning |
|
||||
|
||||
@@ -16,6 +16,8 @@ pub const TYPE_CLOSE: u8 = 0x07;
|
||||
pub const TYPE_UDP_OPEN: u8 = 0x08;
|
||||
pub const TYPE_UDP_DATA: u8 = 0x09;
|
||||
pub const TYPE_UDP_CLOSE: u8 = 0x0A;
|
||||
pub const TYPE_PING: u8 = 0x0B;
|
||||
pub const TYPE_PONG: u8 = 0x0C;
|
||||
|
||||
pub const PROTO_TCP: u8 = 1;
|
||||
pub const PROTO_UDP: u8 = 2;
|
||||
@@ -45,6 +47,8 @@ pub enum Frame {
|
||||
OpenNak { upstream_id: u8, reason: u8 },
|
||||
Data { session_id: u32, payload: Vec<u8> },
|
||||
Close { session_id: u32, reason: Option<u8> },
|
||||
Ping { nonce: u64 },
|
||||
Pong { nonce: u64 },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -120,6 +124,8 @@ impl Frame {
|
||||
};
|
||||
build(TYPE_CLOSE, *session_id, p)
|
||||
}
|
||||
Frame::Ping { nonce } => build(TYPE_PING, 0, nonce.to_be_bytes().to_vec()),
|
||||
Frame::Pong { nonce } => build(TYPE_PONG, 0, nonce.to_be_bytes().to_vec()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +218,20 @@ impl Frame {
|
||||
};
|
||||
Ok(Frame::Close { session_id, reason })
|
||||
}
|
||||
TYPE_PING => {
|
||||
if payload.len() != 8 {
|
||||
return Err(DecodeError::BadPayload("PING payload must be 8 bytes"));
|
||||
}
|
||||
let nonce = u64::from_be_bytes(payload.try_into().unwrap());
|
||||
Ok(Frame::Ping { nonce })
|
||||
}
|
||||
TYPE_PONG => {
|
||||
if payload.len() != 8 {
|
||||
return Err(DecodeError::BadPayload("PONG payload must be 8 bytes"));
|
||||
}
|
||||
let nonce = u64::from_be_bytes(payload.try_into().unwrap());
|
||||
Ok(Frame::Pong { nonce })
|
||||
}
|
||||
TYPE_UDP_OPEN | TYPE_UDP_DATA | TYPE_UDP_CLOSE => {
|
||||
Err(DecodeError::BadPayload("UDP frame types not implemented in v1"))
|
||||
}
|
||||
|
||||
+6
-1
@@ -207,7 +207,12 @@ async fn handle_frame(
|
||||
Frame::Close { session_id, reason: _ } => {
|
||||
store.lock().expect("store poisoned").remove(&session_id);
|
||||
}
|
||||
Frame::Ping { nonce } => {
|
||||
let pong = Frame::Pong { nonce };
|
||||
let _ = tx.send((src, pong.encode())).await;
|
||||
}
|
||||
// Not expected from a client; ignore.
|
||||
Frame::Manifest { .. } | Frame::OpenAck { .. } | Frame::OpenNak { .. } => {}
|
||||
Frame::Manifest { .. } | Frame::OpenAck { .. } | Frame::OpenNak { .. }
|
||||
| Frame::Pong { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ static class Proto
|
||||
public const byte TypeOpenNak = 0x05;
|
||||
public const byte TypeData = 0x06;
|
||||
public const byte TypeClose = 0x07;
|
||||
public const byte TypePing = 0x0B;
|
||||
public const byte TypePong = 0x0C;
|
||||
|
||||
public const byte ProtoTcp = 1;
|
||||
public const byte ProtoUdp = 2;
|
||||
@@ -46,6 +48,8 @@ abstract record Frame
|
||||
internal record OpenNak(byte UpstreamId, byte Reason) : Frame;
|
||||
internal record Data(uint SessionId, byte[] Payload) : Frame;
|
||||
internal record Close(uint SessionId, byte? Reason) : Frame;
|
||||
internal record Ping(ulong Nonce) : Frame;
|
||||
internal record Pong(ulong Nonce) : Frame;
|
||||
}
|
||||
|
||||
static class FrameCodec
|
||||
@@ -86,6 +90,10 @@ static class FrameCodec
|
||||
Frame.Close close =>
|
||||
Build(Proto.TypeClose, close.SessionId,
|
||||
close.Reason.HasValue ? [close.Reason.Value] : []),
|
||||
Frame.Ping ping =>
|
||||
Build(Proto.TypePing, 0, EncodeNonce(ping.Nonce)),
|
||||
Frame.Pong pong =>
|
||||
Build(Proto.TypePong, 0, EncodeNonce(pong.Nonce)),
|
||||
_ => throw new InvalidOperationException($"unknown frame type: {frame.GetType()}"),
|
||||
};
|
||||
}
|
||||
@@ -138,12 +146,34 @@ static class FrameCodec
|
||||
new Frame.Data(sessionId, payload.ToArray()),
|
||||
Proto.TypeClose when payload.Length is 0 or 1 =>
|
||||
new Frame.Close(sessionId, payload.Length == 1 ? payload[0] : null),
|
||||
Proto.TypePong when payload.Length == 8 =>
|
||||
new Frame.Pong(ParseNonce(payload)),
|
||||
Proto.TypePing when payload.Length == 8 =>
|
||||
new Frame.Ping(ParseNonce(payload)),
|
||||
Proto.TypeDiscover when payload.Length == 0 =>
|
||||
new Frame.Discover(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
static ulong ParseNonce(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
ulong nonce = 0;
|
||||
for (int i = 0; i < 8; i++)
|
||||
nonce = (nonce << 8) | payload[i];
|
||||
return nonce;
|
||||
}
|
||||
|
||||
static byte[] EncodeNonce(ulong nonce)
|
||||
{
|
||||
return [
|
||||
(byte)(nonce >> 56), (byte)(nonce >> 48),
|
||||
(byte)(nonce >> 40), (byte)(nonce >> 32),
|
||||
(byte)(nonce >> 24), (byte)(nonce >> 16),
|
||||
(byte)(nonce >> 8), (byte)(nonce & 0xFF),
|
||||
];
|
||||
}
|
||||
|
||||
static Frame.Manifest? ParseManifest(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 1)
|
||||
|
||||
@@ -7,16 +7,19 @@ public partial class MainForm : Form
|
||||
readonly SessionManager _sessions = new();
|
||||
readonly ComboBox _deviceBox = new();
|
||||
readonly Button _discoverBtn = new();
|
||||
readonly Button _testBtn = new();
|
||||
readonly Label _serverLabel = new();
|
||||
readonly Label _pingStatsLabel = new();
|
||||
readonly ListView _listView = new();
|
||||
readonly Label _statusLabel = new();
|
||||
TunnelLink? _link;
|
||||
PingTest? _pingTest;
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
Text = "gatuna";
|
||||
Width = 520;
|
||||
Height = 380;
|
||||
Height = 420;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
Icon = SystemIcons.GetStockIcon(StockIconId.NetworkConnect, 32);
|
||||
InitializeComponents();
|
||||
@@ -54,15 +57,27 @@ public partial class MainForm : Form
|
||||
_discoverBtn.Click += OnDiscover;
|
||||
Controls.Add(_discoverBtn);
|
||||
|
||||
_testBtn.Text = "Test";
|
||||
_testBtn.Left = _discoverBtn.Right + 8; _testBtn.Top = _discoverBtn.Top;
|
||||
_testBtn.Width = 60;
|
||||
_testBtn.Click += OnTest;
|
||||
Controls.Add(_testBtn);
|
||||
|
||||
_serverLabel.Left = pad; _serverLabel.Top = _discoverBtn.Bottom + 8;
|
||||
_serverLabel.Width = ClientSize.Width - pad * 2;
|
||||
_serverLabel.AutoEllipsis = true;
|
||||
_serverLabel.Text = "Server: not connected";
|
||||
Controls.Add(_serverLabel);
|
||||
|
||||
_listView.Left = pad; _listView.Top = _serverLabel.Bottom + 8;
|
||||
_pingStatsLabel.Left = pad; _pingStatsLabel.Top = _serverLabel.Bottom + 4;
|
||||
_pingStatsLabel.Width = ClientSize.Width - pad * 2;
|
||||
_pingStatsLabel.AutoEllipsis = true;
|
||||
_pingStatsLabel.Text = "";
|
||||
Controls.Add(_pingStatsLabel);
|
||||
|
||||
_listView.Left = pad; _listView.Top = _pingStatsLabel.Bottom + 8;
|
||||
_listView.Width = ClientSize.Width - pad * 2;
|
||||
_listView.Height = 200;
|
||||
_listView.Height = 180;
|
||||
_listView.View = View.Details;
|
||||
_listView.FullRowSelect = true;
|
||||
_listView.CheckBoxes = true;
|
||||
@@ -106,6 +121,34 @@ public partial class MainForm : Form
|
||||
_statusLabel.Text = "discovering...";
|
||||
}
|
||||
|
||||
void OnTest(object? s, EventArgs e)
|
||||
{
|
||||
if (_pingTest != null && _pingTest.Running)
|
||||
{
|
||||
_sessions.StopPing();
|
||||
_pingTest = null;
|
||||
_testBtn.Text = "Test";
|
||||
return;
|
||||
}
|
||||
|
||||
_pingTest = _sessions.StartPing();
|
||||
if (_pingTest == null)
|
||||
{
|
||||
MessageBox.Show("Discover a server first.");
|
||||
return;
|
||||
}
|
||||
_pingTest.StatsUpdated += stats => this.Invoke(() =>
|
||||
{
|
||||
_pingStatsLabel.Text =
|
||||
$"sent: {stats.Sent} recv: {stats.Received} " +
|
||||
$"loss: {stats.LossPct:F1}% " +
|
||||
$"avg: {stats.AvgLatencyMs:F1}ms " +
|
||||
$"jitter: {stats.JitterMs:F1}ms";
|
||||
});
|
||||
_testBtn.Text = "Stop";
|
||||
_pingStatsLabel.Text = "pinging...";
|
||||
}
|
||||
|
||||
void PopulateList(string hostname, byte[] mac, UpstreamEntry[] entries)
|
||||
{
|
||||
var macStr = string.Join(":", mac.Select(b => b.ToString("X2")));
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace gatuna_client;
|
||||
|
||||
sealed class PingTest
|
||||
{
|
||||
readonly TunnelLink _link;
|
||||
readonly byte[] _serverMac;
|
||||
readonly CancellationTokenSource _cts = new();
|
||||
readonly ConcurrentDictionary<ulong, long> _outstanding = new();
|
||||
readonly ConcurrentQueue<double> _rtts = new();
|
||||
long _sent;
|
||||
long _received;
|
||||
double _lastRttMs;
|
||||
double _jitterSum;
|
||||
long _jitterCount;
|
||||
|
||||
public event Action<PingStats>? StatsUpdated;
|
||||
public event Action<string>? 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);
|
||||
@@ -14,6 +14,7 @@ sealed class SessionManager : IDisposable
|
||||
byte[]? _serverMac;
|
||||
string _serverHostname = "";
|
||||
UpstreamEntry[] _upstreams = [];
|
||||
PingTest? _pingTest;
|
||||
|
||||
// Serialized OPEN: only one outstanding at a time.
|
||||
readonly object _openLock = new();
|
||||
@@ -47,6 +48,22 @@ sealed class SessionManager : IDisposable
|
||||
_link.SendBroadcast(new Frame.Discover());
|
||||
}
|
||||
|
||||
public PingTest? StartPing()
|
||||
{
|
||||
if (_link == null || _serverMac == null)
|
||||
return null;
|
||||
_pingTest?.Stop();
|
||||
_pingTest = new PingTest(_link, _serverMac);
|
||||
_pingTest.Start();
|
||||
return _pingTest;
|
||||
}
|
||||
|
||||
public void StopPing()
|
||||
{
|
||||
_pingTest?.Stop();
|
||||
_pingTest = null;
|
||||
}
|
||||
|
||||
public void HandleFrame(Frame frame, byte[] srcMac)
|
||||
{
|
||||
switch (frame)
|
||||
@@ -85,6 +102,13 @@ sealed class SessionManager : IDisposable
|
||||
if (_sessions.TryRemove(close.SessionId, out var s))
|
||||
s.Dispose();
|
||||
break;
|
||||
|
||||
case Frame.Pong pong:
|
||||
_pingTest?.HandlePong(pong.Nonce);
|
||||
break;
|
||||
|
||||
case Frame.Ping _:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +251,7 @@ sealed class SessionManager : IDisposable
|
||||
|
||||
public void StopAll()
|
||||
{
|
||||
StopPing();
|
||||
foreach (var kv in _listeners)
|
||||
kv.Value.Listener.Stop();
|
||||
_listeners.Clear();
|
||||
|
||||
Reference in New Issue
Block a user