Skip to content

Commit 9f96808

Browse files
badrishcCopilot
andauthored
Improve network connection handling (#2268)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fac9eff commit 9f96808

21 files changed

Lines changed: 1091 additions & 32 deletions

‎libs/common/AsyncUtils.cs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ public static class AsyncUtils
2222
public static void BlockingWait(Task t)
2323
=> t.GetAwaiter().GetResult();
2424

25+
/// <summary>
26+
/// Block on a given <see cref="Task"/> for at most <paramref name="millisecondsTimeout"/> milliseconds.
27+
///
28+
/// If at all possible, you should instead await your tasks, but for cases where this is not possible use this helper.
29+
/// </summary>
30+
/// <param name="t">The task to wait for.</param>
31+
/// <param name="millisecondsTimeout">The maximum time to wait, or <see cref="System.Threading.Timeout.Infinite"/>.</param>
32+
/// <returns>True if the task completed within the timeout; otherwise false, and the task keeps running.</returns>
33+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
34+
public static bool BlockingWait(Task t, int millisecondsTimeout)
35+
=> t.Wait(millisecondsTimeout);
36+
2537
/// <summary>
2638
/// Block on a given <see cref="Task{TResult}"/>.
2739
///

‎libs/common/Networking/GarnetTcpNetworkSender.cs‎

Lines changed: 140 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
using System.Net.Sockets;
88
using System.Runtime.CompilerServices;
99
using System.Threading;
10+
using System.Threading.Tasks;
1011
using Garnet.networking;
12+
using Microsoft.Extensions.Logging;
1113

1214
namespace Garnet.common
1315
{
@@ -46,6 +48,23 @@ public class GarnetTcpNetworkSender : NetworkSenderBase
4648
/// </summary>
4749
protected readonly int ThrottleMax = 8;
4850

51+
/// <summary>
52+
/// Longest a send waits for an earlier send to complete, in milliseconds, before the connection is
53+
/// closed as stalled; <see cref="Timeout.Infinite"/> waits indefinitely.
54+
/// </summary>
55+
readonly int sendTimeoutMs;
56+
57+
/// <summary>
58+
/// Progressive spins a draining close performs before it blocks.
59+
/// </summary>
60+
const int DrainSpinIterations = 32;
61+
62+
/// <summary>
63+
/// Progressive spins a send performs while waiting for a send slot before it blocks; this matches the spin
64+
/// <see cref="SemaphoreSlim.Wait()"/> performs before it blocks.
65+
/// </summary>
66+
const int CapacitySpinIterations = 140;
67+
4968
readonly string remoteEndpoint;
5069
readonly string localEndpoint;
5170

@@ -64,12 +83,18 @@ public class GarnetTcpNetworkSender : NetworkSenderBase
6483
/// </summary>
6584
/// <param name="socket"></param>
6685
/// <param name="networkBufferSettings"></param>
86+
/// <param name="networkPool"></param>
6787
/// <param name="throttleMax"></param>
88+
/// <param name="sendTimeout">
89+
/// Longest a send waits for an earlier send to complete before the connection is closed as stalled.
90+
/// Zero or negative waits indefinitely.
91+
/// </param>
6892
public GarnetTcpNetworkSender(
6993
Socket socket,
7094
NetworkBufferSettings networkBufferSettings,
7195
LimitedFixedBufferPool networkPool,
72-
int throttleMax = 8)
96+
int throttleMax = 8,
97+
TimeSpan sendTimeout = default)
7398
: base(networkBufferSettings.sendBufferSize)
7499
{
75100
this.networkBufferSettings = networkBufferSettings;
@@ -78,6 +103,7 @@ public GarnetTcpNetworkSender(
78103
this.saeaStack = new(2 * ThrottleMax);
79104
this.responseObject = null;
80105
this.ThrottleMax = throttleMax;
106+
this.sendTimeoutMs = sendTimeout > TimeSpan.Zero ? (int)Math.Min(sendTimeout.TotalMilliseconds, int.MaxValue) : Timeout.Infinite;
81107
this.spinLock = new();
82108
this.closeRequested = 0;
83109

@@ -258,11 +284,19 @@ public override void SendCallback(object context)
258284
/// <inheritdoc />
259285
public override void DisposeNetworkSender(bool waitForSendCompletion)
260286
{
261-
if (!waitForSendCompletion)
287+
// Invariant: every in-flight send completes before the buffer stack and throttle are disposed, since a
288+
// send's completion returns its buffer to the stack and may release the throttle. Draining also closes
289+
// the sender, so any later send completes immediately without reaching the socket.
290+
//
291+
// A graceful close first lets in-flight sends reach the peer, giving up once no send has completed for the
292+
// send timeout (never, when the timeout is infinite). An abortive close, or a graceful one that gives up,
293+
// disposes the socket instead. That aborts the in-flight sends, so the unbounded drain after it is prompt
294+
// and still upholds the invariant.
295+
if (!waitForSendCompletion || !TryDrainSends(sendTimeoutMs))
296+
{
262297
socket.Dispose();
263-
264-
// Wait for ongoing sends to complete
265-
while (throttleCount >= 0 && Interlocked.CompareExchange(ref throttleCount, int.MinValue, 0) != 0) Thread.Yield();
298+
_ = TryDrainSends(Timeout.Infinite);
299+
}
266300

267301
// Empty and dispose the stack
268302
saeaStack.Dispose();
@@ -272,6 +306,56 @@ public override void DisposeNetworkSender(bool waitForSendCompletion)
272306
socket.Dispose();
273307
}
274308

309+
/// <summary>
310+
/// Waits for in-flight sends to complete, then closes the sender to further sends.
311+
/// </summary>
312+
/// <param name="timeoutMs">Longest to wait without any send completing, or <see cref="Timeout.Infinite"/>.</param>
313+
/// <returns>False if no send completed within <paramref name="timeoutMs"/>; the sender is then still open.</returns>
314+
bool TryDrainSends(int timeoutMs)
315+
{
316+
// Sends complete within microseconds while the peer reads, so spin progressively first
317+
var spinner = ConfiguredSpinWait.BoundedProgressive(DrainSpinIterations);
318+
do
319+
{
320+
if (TryCloseIfDrained())
321+
return true;
322+
}
323+
while (spinner.TryWait());
324+
325+
// A peer that reads slowly, or not at all, can take far longer. Blocking on a task, rather than sleeping,
326+
// lets the thread pool inject a replacement for this thread while it waits.
327+
return AsyncUtils.BlockingWait(DrainSendsAsync(timeoutMs));
328+
}
329+
330+
async Task<bool> DrainSendsAsync(int timeoutMs)
331+
{
332+
using var poll = new PeriodicTimer(TimeSpan.FromMilliseconds(1));
333+
var pending = Volatile.Read(ref throttleCount);
334+
var progressAt = Environment.TickCount64;
335+
while (!TryCloseIfDrained())
336+
{
337+
var count = Volatile.Read(ref throttleCount);
338+
if (count != pending)
339+
{
340+
pending = count;
341+
progressAt = Environment.TickCount64;
342+
}
343+
else if (timeoutMs != Timeout.Infinite && Environment.TickCount64 - progressAt >= timeoutMs)
344+
{
345+
return false;
346+
}
347+
348+
_ = await poll.WaitForNextTickAsync().ConfigureAwait(false);
349+
}
350+
return true;
351+
}
352+
353+
/// <summary>
354+
/// Closes the sender to further sends if no send is in flight, or reports that it is already closed.
355+
/// </summary>
356+
bool TryCloseIfDrained()
357+
=> Volatile.Read(ref throttleCount) < 0 || Interlocked.CompareExchange(ref throttleCount, int.MinValue, 0) == 0;
358+
275359
/// <inheritdoc />
276360
public override void Throttle()
277361
{
@@ -329,7 +413,7 @@ private unsafe void Send(Socket socket, GarnetSaeaBuffer sendObject, int offset,
329413
return;
330414
}
331415
if (cnt > ThrottleMax)
332-
throttle.Wait();
416+
WaitForSendCapacity();
333417

334418
// Debug.WriteLine($"SEND: [{size}][{System.Text.Encoding.UTF8.GetString(new Span<byte>(sendObject.socketEventAsyncArgs.Buffer, offset, size)).Replace("\n", "|").Replace("\r", "")}]");
335419

@@ -341,12 +425,62 @@ private unsafe void Send(Socket socket, GarnetSaeaBuffer sendObject, int offset,
341425
SeaaBuffer_Completed(null, sendObject.socketEventAsyncArgs);
342426
}
343427

428+
/// <summary>
429+
/// Waits for an in-flight send to complete, which frees a send slot for the caller.
430+
/// </summary>
431+
/// <remarks>
432+
/// The caller is usually a thread-pool thread that also serves other connections. Blocking on a task lets
433+
/// the thread pool inject a replacement thread, whereas blocking on the semaphore does not, so other
434+
/// connections continue to be served while this one waits. A peer that accepts no data for
435+
/// the send timeout has its connection closed, which also completes its in-flight sends.
436+
/// <para>
437+
/// A completion that frees a slot for a waiter decrements the send count before it releases the semaphore,
438+
/// so the count can reach zero, letting the sender be disposed, while that release is still to come. A waiter
439+
/// therefore leaves only once it has taken its release, even after a timeout; otherwise the release could
440+
/// land on a disposed semaphore.
441+
/// </para>
442+
/// <para>
443+
/// The timeout is applied to the wait on the untimed semaphore task rather than passed to the semaphore,
444+
/// because a timed semaphore wait allocates a timer and async state on every call. The untimed task also
445+
/// stays queued after a timeout, so it is the one that takes the owed release.
446+
/// </para>
447+
/// </remarks>
448+
/// <exception cref="GarnetException">No send completed within the send timeout.</exception>
449+
[MethodImpl(MethodImplOptions.NoInlining)]
450+
private void WaitForSendCapacity()
451+
{
452+
// A send usually completes shortly while the peer reads, so spin progressively, as the semaphore's own
453+
// blocking wait does, before paying for a task and a blocking wait.
454+
var spinner = ConfiguredSpinWait.BoundedProgressive(CapacitySpinIterations, sleep1Threshold: -1);
455+
do
456+
{
457+
if (throttle.Wait(0))
458+
return;
459+
}
460+
while (spinner.TryWait());
461+
462+
var slot = throttle.WaitAsync();
463+
if (AsyncUtils.BlockingWait(slot, sendTimeoutMs))
464+
return;
465+
466+
// Closing the socket completes every in-flight send, so the release this waiter is owed arrives promptly
467+
_ = TryClose();
468+
AsyncUtils.BlockingWait(slot);
469+
throw new GarnetException($"Closed connection to {remoteEndpoint}: no send completed within {sendTimeoutMs} ms",
470+
LogLevel.Warning, clientResponse: false, disposeSession: true);
471+
}
472+
344473
[MethodImpl(MethodImplOptions.AggressiveInlining)]
345474
private void SeaaBuffer_Completed(object sender, SocketAsyncEventArgs e)
346475
{
347476
ReturnBuffer((GarnetSaeaBuffer)e.UserToken);
348477
if (Interlocked.Decrement(ref throttleCount) >= ThrottleMax)
478+
{
479+
#if DEBUG
480+
ExceptionInjectionHelper.ResetAndWait(ExceptionInjectionType.Sender_Pause_Before_Slot_Release);
481+
#endif
349482
throttle.Release();
483+
}
350484
}
351485
}
352486
}

‎libs/common/Networking/NetworkHandler.cs‎

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33

44
using System;
55
using System.Diagnostics;
6+
using System.IO;
67
using System.Net.Security;
78
using System.Runtime.CompilerServices;
9+
using System.Runtime.ExceptionServices;
810
using System.Security.Cryptography.X509Certificates;
911
using System.Threading;
1012
using System.Threading.Tasks;
@@ -161,13 +163,26 @@ protected int BaseReceiveBufferSize
161163
// Number of times Dispose has been called
162164
int disposeCount;
163165

166+
// Set once a TLS write fails; a failed write can leave a partial record on the connection, so nothing
167+
// further can be written to it
168+
bool writeFailed;
169+
170+
// Longest a server-side TLS handshake may take; zero waits indefinitely
171+
readonly TimeSpan tlsHandshakeTimeout;
172+
173+
// Longest delay a cancellation timer supports, about 49.7 days
174+
static readonly TimeSpan MaxTlsHandshakeTimeout = TimeSpan.FromMilliseconds(uint.MaxValue - 1);
175+
164176
/// <summary>
165177
/// Constructor
166178
/// </summary>
167-
public unsafe NetworkHandler(TServerHook serverHook, TNetworkSender networkSender, NetworkBufferSettings networkBufferSettings, LimitedFixedBufferPool networkPool, bool useTLS, IMessageConsumer messageConsumer = null, ILogger logger = null)
179+
/// <param name="tlsHandshakeTimeout">Longest a server-side TLS handshake may take before the connection is closed. Zero or negative waits indefinitely; a timeout beyond the longest a timer supports, about 49.7 days, is limited to it.</param>
180+
public unsafe NetworkHandler(TServerHook serverHook, TNetworkSender networkSender, NetworkBufferSettings networkBufferSettings, LimitedFixedBufferPool networkPool, bool useTLS, IMessageConsumer messageConsumer = null, ILogger logger = null, TimeSpan tlsHandshakeTimeout = default)
168181
: base(networkPool.MinAllocationSize)
169182
{
170183
this.logger = logger;
184+
this.tlsHandshakeTimeout = tlsHandshakeTimeout <= TimeSpan.Zero ? TimeSpan.Zero
185+
: tlsHandshakeTimeout < MaxTlsHandshakeTimeout ? tlsHandshakeTimeout : MaxTlsHandshakeTimeout;
171186
this.serverHook = serverHook;
172187
this.networkSender = networkSender;
173188
this.session = messageConsumer;
@@ -246,9 +261,13 @@ public virtual async Task StartAsync(SslServerAuthenticationOptions tlsOptions =
246261
async Task AuthenticateAsServerAsync(SslServerAuthenticationOptions tlsOptions, string remoteEndpointName, CancellationToken token = default)
247262
{
248263
Debug.Assert(readerStatus == TlsReaderStatus.Active);
264+
265+
// The deadline is absolute; handshake bytes that arrive before it do not extend it
266+
using var handshakeDeadline = tlsHandshakeTimeout > TimeSpan.Zero ? CancellationTokenSource.CreateLinkedTokenSource(token) : null;
267+
handshakeDeadline?.CancelAfter(tlsHandshakeTimeout);
249268
try
250269
{
251-
await sslStream.AuthenticateAsServerAsync(tlsOptions, token).ConfigureAwait(false);
270+
await sslStream.AuthenticateAsServerAsync(tlsOptions, handshakeDeadline?.Token ?? token).ConfigureAwait(false);
252271

253272
if (token.IsCancellationRequested) throw new TaskCanceledException("AuthenticateAsServerAsync was cancelled");
254273

@@ -263,10 +282,16 @@ async Task AuthenticateAsServerAsync(SslServerAuthenticationOptions tlsOptions,
263282
}
264283
catch (Exception ex)
265284
{
266-
logger?.LogWarning(ex, "An error has occurred");
285+
var timedOut = handshakeDeadline?.IsCancellationRequested == true && !token.IsCancellationRequested;
286+
if (timedOut)
287+
logger?.LogInformation("TLS handshake with {remoteEndpoint} did not complete within {timeout}", remoteEndpointName, tlsHandshakeTimeout);
288+
else
289+
logger?.LogWarning(ex, "An error has occurred");
267290
readerStatus = TlsReaderStatus.Rest;
268291
if (expectingData.CurrentCount == 0) expectingData.Release();
269292
Dispose();
293+
if (timedOut)
294+
throw new TimeoutException($"TLS handshake did not complete within {tlsHandshakeTimeout}", ex);
270295
throw;
271296
}
272297
}
@@ -926,8 +951,24 @@ public override unsafe bool SendResponse(int offset, int size)
926951
logger?.LogTrace("SEND: [{send}]", System.Text.Encoding.UTF8.GetString(
927952
new Span<byte>(transportSendBuffer).Slice(offset, size)).Replace("\n", "|").Replace("\r", ""));
928953
#endif
929-
sslStream.Write(transportSendBuffer, offset, size);
930-
sslStream.Flush();
954+
if (writeFailed)
955+
return false;
956+
957+
try
958+
{
959+
sslStream.Write(transportSendBuffer, offset, size);
960+
sslStream.Flush();
961+
}
962+
catch (Exception ex)
963+
{
964+
writeFailed = true;
965+
966+
// SslStream wraps what the underlying sender throws; surface the sender's own failure, such as a send
967+
// timeout, as the session would see it without TLS
968+
if (ex is IOException { InnerException: GarnetException inner })
969+
ExceptionDispatchInfo.Throw(inner);
970+
throw;
971+
}
931972
return true;
932973
}
933974

‎libs/common/Networking/TcpNetworkHandler.cs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT license.
33

4+
using System;
45
using System.Net.Sockets;
56
using Garnet.networking;
67
using Microsoft.Extensions.Logging;
@@ -17,8 +18,10 @@ public abstract class TcpNetworkHandler<TServerHook> : TcpNetworkHandlerBase<TSe
1718
/// <summary>
1819
/// Constructor
1920
/// </summary>
20-
public TcpNetworkHandler(TServerHook serverHook, Socket socket, NetworkBufferSettings networkBufferSettings, LimitedFixedBufferPool networkPool, bool useTLS, IMessageConsumer messageConsumer = null, int networkSendThrottleMax = 8, ILogger logger = null)
21-
: base(serverHook, new GarnetTcpNetworkSender(socket, networkBufferSettings, networkPool, networkSendThrottleMax), socket, networkBufferSettings, networkPool, useTLS, messageConsumer: messageConsumer, logger: logger)
21+
/// <param name="networkSendTimeout">Longest a send waits for an earlier send to complete before the connection is closed. Zero or negative waits indefinitely.</param>
22+
/// <param name="tlsHandshakeTimeout">Longest a server-side TLS handshake may take before the connection is closed. Zero or negative waits indefinitely.</param>
23+
public TcpNetworkHandler(TServerHook serverHook, Socket socket, NetworkBufferSettings networkBufferSettings, LimitedFixedBufferPool networkPool, bool useTLS, IMessageConsumer messageConsumer = null, int networkSendThrottleMax = 8, ILogger logger = null, TimeSpan networkSendTimeout = default, TimeSpan tlsHandshakeTimeout = default)
24+
: base(serverHook, new GarnetTcpNetworkSender(socket, networkBufferSettings, networkPool, networkSendThrottleMax, networkSendTimeout), socket, networkBufferSettings, networkPool, useTLS, messageConsumer: messageConsumer, logger: logger, tlsHandshakeTimeout: tlsHandshakeTimeout)
2225
{
2326
}
2427
}

‎libs/common/Networking/TcpNetworkHandlerBase.cs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ public abstract class TcpNetworkHandlerBase<TServerHook, TNetworkSender> : Netwo
3131
/// <summary>
3232
/// Constructor
3333
/// </summary>
34-
public TcpNetworkHandlerBase(TServerHook serverHook, TNetworkSender networkSender, Socket socket, NetworkBufferSettings networkBufferSettings, LimitedFixedBufferPool networkPool, bool useTLS, IMessageConsumer messageConsumer = null, ILogger logger = null)
35-
: base(serverHook, networkSender, networkBufferSettings, networkPool, useTLS, messageConsumer: messageConsumer, logger: logger)
34+
/// <param name="tlsHandshakeTimeout">Longest a server-side TLS handshake may take before the connection is closed. Zero or negative waits indefinitely.</param>
35+
public TcpNetworkHandlerBase(TServerHook serverHook, TNetworkSender networkSender, Socket socket, NetworkBufferSettings networkBufferSettings, LimitedFixedBufferPool networkPool, bool useTLS, IMessageConsumer messageConsumer = null, ILogger logger = null, TimeSpan tlsHandshakeTimeout = default)
36+
: base(serverHook, networkSender, networkBufferSettings, networkPool, useTLS, messageConsumer: messageConsumer, logger: logger, tlsHandshakeTimeout: tlsHandshakeTimeout)
3637
{
3738
this.logger = logger;
3839
this.socket = socket;

‎libs/common/Testing/ExceptionInjectionType.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,5 +160,10 @@ public enum ExceptionInjectionType
160160
/// Pause after receiving the full diskless snapshot, before recovery resets the AOF addresses.
161161
/// </summary>
162162
Replication_Pause_Before_Diskless_Snapshot_Recovery,
163+
/// <summary>
164+
/// Server network sender: pause a send completion that frees a slot for a waiting send, after it has
165+
/// decremented the send count and before it releases the waiter.
166+
/// </summary>
167+
Sender_Pause_Before_Slot_Release,
163168
}
164169
}

0 commit comments

Comments
 (0)