fix: prevent QUIC heartbeat stalls and isolate TCP retransmission counters [CI] [SKIP TESTS]
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful

This commit is contained in:
justme committed 2026-10-20 18:23:31 +08:00
1 parent 0066a947da
commit 0fd38b2367
14 files changed
+299 -50

No files matched your search

@@ -33,6 +33,7 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
private static readonly MsQuicApi.ConnectionCallback s_callback = OnEvent;
private readonly object _sendGate = new();
private readonly object _nativeGate = new();
private readonly TaskCompletionSource _connected = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _shutdown = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly GCHandle _self;
@@ -48,6 +49,8 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
private int _sendEnabled;
private int _disposeStarted;
private double? _handshakeDurationMs;
private QuicTransportSnapshot? _transportSnapshot;
private long _statisticsAt;
private MsQuicConnection(
nint handle,
@@ -105,24 +108,33 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
public double? HandshakeDurationMs => _handshakeDurationMs;
public bool TryGetStatistics(out MsQuicApi.QuicStatisticsV2 statistics)
public QuicTransportSnapshot? TransportSnapshot => Volatile.Read(ref _transportSnapshot);
// Called only from this connection's serialized native callbacks. GetParam is
// inline on the MsQuic worker; calling it from a heartbeat can wait forever
// for a stalled worker and take the entire Fleet heartbeat down with it.
private void UpdateTransportSnapshot()
{
nint handle = Volatile.Read(ref _handle);
if (handle == 0)
long now = Stopwatch.GetTimestamp();
if (handle == 0 || (_statisticsAt != 0 && Stopwatch.GetElapsedTime(_statisticsAt, now) < TimeSpan.FromSeconds(1)))
{
statistics = default;
return false;
return;
}
_statisticsAt = now;
try
{
statistics = MsQuicApi.GetStatisticsV2(handle);
return true;
MsQuicApi.QuicStatisticsV2 stats = MsQuicApi.GetStatisticsV2(handle);
ulong lost = stats.SendSuspectedLostPackets >= stats.SendSpuriousLostPackets
? stats.SendSuspectedLostPackets - stats.SendSpuriousLostPackets : 0;
double? loss = stats.SendTotalPackets > 0 ? lost * 100.0 / stats.SendTotalPackets : null;
Volatile.Write(ref _transportSnapshot, new QuicTransportSnapshot(loss, stats.SendTotalPackets,
stats.SendCongestionCount, stats.SendEcnCongestionCount, stats.SendPathMtu));
}
catch (InvalidOperationException)
{
statistics = default;
return false;
// Some event phases do not yet expose statistics. Retain the last snapshot.
}
}
@@ -214,12 +226,23 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
var connection = new MsQuicConnection(handle, 0, 0, null, datagramHandler, log, closed, peerAddressChanged);
MsQuicApi.SetContext(handle, GCHandle.ToIntPtr(connection._self));
MsQuicApi.SetCallbackHandler(handle, s_callback, GCHandle.ToIntPtr(connection._self));
MsQuicApi.ConnectionSetConfiguration(handle, configuration);
MsQuicApi.SetParam(handle, DatagramReceiveEnabled, 1);
// Transport parameters must be set before SetConfiguration starts TLS.
MsQuicApi.ConnectionSetConfiguration(handle, configuration);
return connection;
}
public bool Send(ReadOnlySpan<byte> datagram)
{
// ConnectionClose must not free the handle or a pending send while
// its native buffer is still being prepared/submitted.
lock (_nativeGate)
{
return SendCore(datagram);
}
}
private bool SendCore(ReadOnlySpan<byte> datagram)
{
nint handle = Volatile.Read(ref _handle);
if (handle == 0 || !IsReady || datagram.Length > MaxDatagramBytes)
@@ -264,9 +287,12 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposeStarted, 1) == 0 && Volatile.Read(ref _handle) is nint handle && handle != 0)
lock (_nativeGate)
{
MsQuicApi.ConnectionShutdown(handle);
if (Interlocked.Exchange(ref _disposeStarted, 1) == 0 && Volatile.Read(ref _handle) is nint handle && handle != 0)
{
MsQuicApi.ConnectionShutdown(handle);
}
}
if (Volatile.Read(ref _handle) != 0)
@@ -396,6 +422,7 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
return connection.ValidatePeerCertificate(eventPointer);
}
connection.UpdateTransportSnapshot();
return 0;
}
catch (Exception ex)
@@ -418,10 +445,23 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
private void OnDatagramStateChanged(nint eventPointer)
{
byte sendEnabled = Marshal.ReadByte(eventPointer, IntPtr.Size);
int maxSendLength = (ushort)Marshal.ReadInt16(eventPointer, IntPtr.Size + 1);
(bool sendEnabled, int maxSendLength) = ReadDatagramState(eventPointer);
Volatile.Write(ref _maxDatagramBytes, maxSendLength);
Volatile.Write(ref _sendEnabled, sendEnabled == 0 ? 0 : 1);
Volatile.Write(ref _sendEnabled, sendEnabled ? 1 : 0);
}
internal static (bool SendEnabled, int MaxSendLength) ReadDatagramState(nint eventPointer)
{
// BOOLEAN is followed by padding before the naturally aligned uint16_t.
var state = Marshal.PtrToStructure<DatagramState>(eventPointer + IntPtr.Size);
return (state.SendEnabled != 0, state.MaxSendLength);
}
[StructLayout(LayoutKind.Sequential)]
private struct DatagramState
{
public byte SendEnabled;
public ushort MaxSendLength;
}
private void OnDatagramReceived(nint eventPointer)
@@ -554,7 +594,17 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
return;
}
MsQuicApi.ConnectionClose(handle);
// RegistrationClose waits for native workers/children and is forbidden
// on a MsQuic callback. Return from the callback before waiting for cleanup.
ThreadPool.QueueUserWorkItem(_ => CloseHandles(handle));
}
private void CloseHandles(nint handle)
{
lock (_nativeGate)
{
MsQuicApi.ConnectionClose(handle);
}
lock (_sendGate)
{
foreach ((nint context, SendState state) in _sends)
@@ -60,19 +60,7 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
public double? HandshakeDurationMs => _client?.HandshakeDurationMs;
public QuicTransportSnapshot? GetTransportSnapshot()
{
if (_client is null || !_client.TryGetStatistics(out MsQuicApi.QuicStatisticsV2 stats))
{
return null;
}
ulong lost = stats.SendSuspectedLostPackets >= stats.SendSpuriousLostPackets
? stats.SendSuspectedLostPackets - stats.SendSpuriousLostPackets
: 0;
double? packetLossRate = stats.SendTotalPackets > 0 ? lost * 100.0 / stats.SendTotalPackets : null;
return new QuicTransportSnapshot(packetLossRate, stats.SendTotalPackets, stats.SendCongestionCount, stats.SendEcnCongestionCount, stats.SendPathMtu);
}
public QuicTransportSnapshot? GetTransportSnapshot() => _client?.TransportSnapshot;
public void Bind()
{
@@ -70,14 +70,15 @@ public static class TcpInfo
}
/// <summary>
/// 一条连接上周期性的内核采样(<c>tcp_info</c> 流):每个采样一行 (ts, side, srtt, rttvar, cwnd, retrans_total, segs_out, bytes_acked)。
/// 一条连接上周期性的内核采样(<c>tcp_info</c> 流):每个采样一行 (ts, side, srtt, rttvar, cwnd, retrans_total, segs_out, bytes_acked, conn)。
/// 两端各采样自己的套接字:Probe 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。
/// 累计计数器在 Backend 按 side 分别求差。
/// 累计计数器在 Backend 按 (side, conn) 分别求差;conn 是当前记录器内分配的连接编号,不包含网络地址。
/// </summary>
public sealed class TcpInfoRecorder
{
private readonly MonoClock _clock;
private readonly long _side;
private long _nextConnection;
public TcpInfoRecorder(StreamSpec spec, Side side, MonoClock clock)
{
@@ -96,6 +97,9 @@ public sealed class TcpInfoRecorder
return;
}
// A recorder survives reconnects, and listeners can briefly have more
// than one socket. Kernel counters belong to a socket, not just a side.
long connection = Interlocked.Increment(ref _nextConnection);
try
{
using var timer = new PeriodicTimer(interval, _clock.Time);
@@ -107,7 +111,7 @@ public sealed class TcpInfoRecorder
}
long key = Rows.Append();
Rows.SetRow(key, [_clock.NowUs(), _side, s.SrttUs, s.RttVarUs, s.Cwnd, s.TotalRetrans, s.SegsOut, s.BytesAcked]);
Rows.SetRow(key, [_clock.NowUs(), _side, s.SrttUs, s.RttVarUs, s.Cwnd, s.TotalRetrans, s.SegsOut, s.BytesAcked, connection]);
}
}
catch (OperationCanceledException)
@@ -22,6 +22,7 @@ public static class Kinds
[
Col.TsLocal("ts"), Col.U8("side"), Col.U32("srtt_us"), Col.U32("rttvar_us"), Col.U32("cwnd"),
Col.U32("retrans_total"), Col.U32("segs_out"), Col.U64("bytes_acked"),
new ColumnSpec("conn", ColumnType.U64, ColumnEnc.Plain, true),
],
Union: true);
@@ -1,3 +1,4 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Security.Cryptography;
using Fleet.Core;
@@ -124,9 +125,18 @@ public static class TcpWire
public static async ValueTask WriteMessageAsync(Stream stream, ReadOnlyMemory<byte> body, CancellationToken ct)
{
byte[] prefix = new byte[VarInt.MaxBytes];
int n = VarInt.Write(prefix, (ulong)body.Length);
await stream.WriteAsync(prefix.AsMemory(0, n), ct);
await stream.WriteAsync(body, ct);
byte[] frame = ArrayPool<byte>.Shared.Rent(checked(VarInt.MaxBytes + body.Length));
try
{
int n = VarInt.Write(frame, (ulong)body.Length);
body.CopyTo(frame.AsMemory(n));
// TCP_NODELAY is enabled: two writes otherwise create a tiny
// length-only segment before every echo body (and two TLS records).
await stream.WriteAsync(frame.AsMemory(0, n + body.Length), ct);
}
finally
{
ArrayPool<byte>.Shared.Return(frame);
}
}
}
@@ -203,7 +203,7 @@ public sealed class MeasurementTests
var builder = new StreamBuilder(spec);
foreach ((long ts, long side, long retrans) in rows)
{
builder.Add(ts, side, 40_000, 5_000, 10, retrans, 100, 1_000);
builder.Add(ts, side, 40_000, 5_000, 10, retrans, 100, 1_000, null);
}
return builder.Build();
@@ -216,8 +216,8 @@ public sealed class MeasurementTests
Assert.Equal(info.Columns.Count, info.ColumnsOf(Side.Agent).Count); // each side owns every column
var agent = new RowStore(info, Side.Agent, 64);
var recorder = new Source { Stores = { ["tcp_info"] = agent } };
agent.SetRow(agent.Append(), [500, 1, 40_000, 5_000, 10, 7, 100, 1_000]);
agent.SetRow(agent.Append(), [600, 1, 40_000, 5_000, 10, 8, 120, 1_100]);
agent.SetRow(agent.Append(), [500, 1, 40_000, 5_000, 10, 7, 100, 1_000, 1]);
agent.SetRow(agent.Append(), [600, 1, 40_000, 5_000, 10, 8, 120, 1_100, 1]);
var msg = EchoFragment((0, 1000, 3000));
var fragment = new RawChunk(msg.Header, [msg.Streams[0], InfoRows(info, (100, 0, 1))]);
@@ -227,6 +227,8 @@ public sealed class MeasurementTests
Assert.Equal(3, merged.Count);
Assert.Equal([0L, 1L, 1L], merged.Find("side")!.Values);
Assert.Equal([1L, 7L, 8L], merged.Find("retrans_total")!.Values);
Assert.False(merged.Find("conn")!.IsValid(0));
Assert.Equal(1, merged.Find("conn")!.Values[1]);
// The agent's rows were consumed: the next chunk carries only the probe's own rows
IReadOnlyList<RawStream> second = FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, recorder).Value;
@@ -238,7 +240,7 @@ public sealed class MeasurementTests
{
StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!;
var agent = new RowStore(info, Side.Agent, 64);
agent.SetRow(agent.Append(), [500, 1, 40_000, 5_000, 10, 7, 100, 1_000]);
agent.SetRow(agent.Append(), [500, 1, 40_000, 5_000, 10, 7, 100, 1_000, 1]);
// A probe on a platform without kernel sampling sends the echo rows only
RawChunk fragment = EchoFragment((0, 1000, 3000));
@@ -248,6 +250,18 @@ public sealed class MeasurementTests
Assert.Equal(7, rows.Find("retrans_total")!.Values[0]);
}
[Fact]
public void FragmentMerger_LegacyTcpSamples_KeepConnectionIdentityUnknown()
{
StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!;
RawStream sample = InfoRows(info, (100, 0, 1));
var legacy = new RawStream(sample.Name, sample.Count, sample.Columns.Where(c => c.Name != "conn").ToArray());
var fragment = new RawChunk(new RawChunkHeader { Kind = "tcp_echo" }, [legacy]);
RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, new Source()).Value);
Assert.False(merged.Find("conn")!.IsValid(0));
Assert.Equal(1, merged.Find("retrans_total")!.Values[0]);
}
[Fact]
public void TcpInfo_OnLinux_ReadsTheKernelCountersOfALiveConnection()
{
@@ -0,0 +1,77 @@
using System.Net;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Fleet.Core;
namespace MoShiro.Measurement.Tests;
public sealed class MsQuicConnectionTests
{
[Theory]
[InlineData(1200, true)]
[InlineData(1280, true)]
[InlineData(1472, false)]
public void DatagramState_UsesTheNativeUint16Alignment(int maximum, bool enabled)
{
nint nativeEvent = Marshal.AllocHGlobal(IntPtr.Size + 4);
try
{
Marshal.WriteByte(nativeEvent, IntPtr.Size, enabled ? (byte)1 : (byte)0);
Marshal.WriteByte(nativeEvent, IntPtr.Size + 1, 0xA5); // padding is not part of MaxSendLength
Marshal.WriteInt16(nativeEvent, IntPtr.Size + 2, (short)maximum);
Assert.Equal((enabled, maximum), MsQuicConnection.ReadDatagramState(nativeEvent));
}
finally
{
Marshal.FreeHGlobal(nativeEvent);
}
}
[Fact]
public async Task DatagramConnection_CanSendCloseAndReconnect_WithoutBlockingNativeWorkers()
{
using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256);
using X509Certificate2 certificate = request.CreateSelfSigned(DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddYears(100));
await using var listener = new MsQuicListener(IPAddress.Loopback, [0], certificate,
(connection, data) => connection.Send(data), NullNodeLog.Instance, null);
listener.Start();
for (int attempt = 0; attempt < 3; attempt++)
{
var reply = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
MsQuicConnection client = await MsQuicConnection.ConnectAsync(IPAddress.Loopback, listener.BoundPorts[0], 0,
key.ExportSubjectPublicKeyInfo(), (_, data) => reply.TrySetResult(data.ToArray()), NullNodeLog.Instance, timeout.Token);
try
{
while (!client.IsReady)
{
await Task.Delay(10, timeout.Token);
}
Assert.InRange(client.MaxDatagramBytes, 64, 1500);
byte[] payload = Enumerable.Range(0, 64).Select(i => (byte)i).ToArray();
// DATAGRAM is unreliable, and the peer can receive the first
// packet before its handshake-complete notification permits replies.
while (!reply.Task.IsCompleted)
{
Assert.True(client.Send(payload));
await Task.WhenAny(reply.Task, Task.Delay(20, timeout.Token));
timeout.Token.ThrowIfCancellationRequested();
}
Assert.Equal(payload, await reply.Task.WaitAsync(timeout.Token));
Assert.NotNull(client.TransportSnapshot);
}
finally
{
// The old callback called RegistrationClose, deadlocking its own
// worker. A second connection then hung in SetParam/GetParam.
await client.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(3));
await client.WaitForShutdownAsync().WaitAsync(TimeSpan.FromSeconds(3));
}
}
}
}
@@ -0,0 +1,30 @@
namespace MoShiro.Protocol.Tests;
public sealed class TcpWireTests
{
[Theory]
[InlineData(0)]
[InlineData(64)]
[InlineData(16384)]
public async Task Message_UsesOneWriteForPrefixAndBody_AndPreservesFraming(int size)
{
using var stream = new CountingStream();
byte[] body = Enumerable.Range(0, size).Select(i => (byte)i).ToArray();
await TcpWire.WriteMessageAsync(stream, body, default);
Assert.Equal(1, stream.Writes);
stream.Position = 0;
Assert.Equal(body, await TcpWire.ReadMessageAsync(stream, default));
Assert.Null(await TcpWire.ReadMessageAsync(stream, default));
}
private sealed class CountingStream : MemoryStream
{
public int Writes { get; private set; }
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
Writes++;
return base.WriteAsync(buffer, cancellationToken);
}
}
}