Repository navigation
Description
In the default shared mode, connections that aren't being read can take all of a reactor's receive buffers. Every other connection on that reactor then stops receiving until the buffers come back, which can take as long as SendTimeoutMs (60 s by default).
It takes very little traffic, because a one-byte send holds a whole 32 KiB buffer.
Parts of this are known. The answer on #227 describes the same exhaustion from a handler that holds a whole body, with the rule "stream or copy a request body as it arrives". #371 put RecvQueueEntries back to 64 so that one connection can't hold the ring. What this adds is that it takes no such handler: one that follows that rule still stops reading while it's parked in a flush, and about 64 of them are enough.
Cause. Each recv completion takes one buffer from the ring however few bytes it carries, and keeps it until the handler reads. A handler parked in a flush isn't reading, and a peer that stops reading keeps it parked. RecvQueueEntries limits the buffers per connection, and its doc says so ("in shared mode one connection can hold this many of RecvSlots", TcpOptions.cs:56-62), but with 4096 slots and 64 entries that leaves it to about 64 connections. Once the ring is dry, every connection's next recv ends in -ENOBUFS and parks (Reactor.Tcp.cs:99-110). #93 made that a stall rather than a teardown, which is what's measured here.
Reproduced at 0.25.482 (918ccc8), on x64 Linux 6.14. One reactor, RecvBufferSize 32 KiB, RecvSlots 4096, RecvQueueEntries 64, SendTimeoutMs 3000. 70 connections each asked for an 8 MiB answer and stopped reading, then each sent 60 one-byte messages: 4,200 bytes in 72 ms.
| Mode | Another client's round trip, before | During |
|---|---|---|
| Shared (default) | 4 ms | 2,879 ms |
| Incremental | 5 ms | 0 ms |
The stall ended only when the send timeout freed the buffers, so at the default 60 s I'd expect it to last far longer (not measured). With more reactors the connections needed should scale with the count, since SO_REUSEPORT spreads them (not run past one reactor). The answer has to be big enough to fill the socket's send buffer, so that the flush parks.
Test. It uses a smaller ring and no send timeout, so the stall lasts until the parked connections go, and asserts no timing. It fails today, and the control passes. PR #307 doesn't change that: applied to this commit, the test still fails, because these handlers haven't let go.
SharedRingStarvationTests
Add to tests/Ioxide.Tests.E2E/Core/, and SharedRingStarvationTests.Register(runner); to that project's Program.cs. It uses runner.Test, so it stays red until the fix; runner.Pending commits it first.
using System.Net;
using System.Net.Sockets;
using System.Text;
using ioxide;
using ioxide.utils;
namespace Ioxide.Tests;
/// <summary>
/// Connections whose handlers are parked in a flush keep taking buffers for what their peers send, a whole
/// buffer per recv however few bytes it carries. With the shared ring, that is every connection's supply.
/// </summary>
internal static class SharedRingStarvationTests
{
// Enough parked connections to take the ring without any one of them overflowing its 64-entry queue.
private const int RingSlots = 256;
private const int ParkedConnections = 5;
private const int SendsEach = 60;
public static void Register(Runner runner)
{
runner.Test("shared ring: connections parked in a flush leave another connection able to read", () =>
{
Assert.True(AnsweredWhileOthersAreParked(incremental: null),
"the other connection's request was never answered: its recv is parked for a buffer nobody will return");
});
runner.Test("shared ring: control: with a ring per connection the same peers stop nobody", () =>
{
Assert.True(AnsweredWhileOthersAreParked(new IncrementalOptions { MaxConnections = 16 }),
"the other connection's request was never answered");
}, skip: !TestServer.KernelAtLeast(6, 12));
}
private static bool AnsweredWhileOthersAreParked(IncrementalOptions? incremental)
{
byte[] big = new byte[8 * 1024 * 1024]; // far past what a 4 KiB window and the socket buffers hold
byte[] pong = "pong"u8.ToArray();
int port = TestServer.StartConfigured(async (_, conn) =>
{
try
{
while (true)
{
RecvSnapshot snapshot = await conn.ReadAsync();
while (conn.TryGetItem(snapshot, out SpscRecvRing.Item item))
{
if (item.HasBuffer)
{
conn.Write(item.AsSpan()[0] == (byte)'B' ? big : pong);
conn.ReturnBuffer(in item);
}
}
await conn.FlushAsync();
if (snapshot.IsClosed)
{
return;
}
}
}
finally
{
conn.DecRef();
}
}, new ServerConfig
{
RecvBufferSize = 4096,
RecvSlots = RingSlots,
Incremental = incremental,
Tcp = new TcpOptions { RecvQueueEntries = 64, ReadTimeoutMs = 0, SendTimeoutMs = 0 },
}).Port;
using Socket other = Connect(port);
Assert.True(Asked(other), "the other connection was not answered even before the rest arrived");
var parked = new List<Socket>();
try
{
for (int i = 0; i < ParkedConnections; i++)
{
Socket socket = Connect(port, receiveBuffer: 4096);
socket.Send("B"u8); // and never reads the answer, so its handler parks in the flush
parked.Add(socket);
}
Thread.Sleep(300);
for (int round = 0; round < SendsEach; round++)
{
foreach (Socket socket in parked)
{
socket.Send("x"u8);
}
Thread.Sleep(1); // apart, so each is a recv of its own
}
return Asked(other);
}
finally
{
parked.ForEach(socket => socket.Dispose());
}
}
private static bool Asked(Socket socket)
{
socket.Send("p"u8);
var answer = new byte[4];
try
{
for (int got = 0; got < answer.Length;)
{
int n = socket.Receive(answer, got, answer.Length - got, SocketFlags.None);
if (n == 0)
{
return false;
}
got += n;
}
}
catch (SocketException e) when (e.SocketErrorCode == SocketError.TimedOut)
{
return false;
}
return Encoding.ASCII.GetString(answer) == "pong";
}
private static Socket Connect(int port, int? receiveBuffer = null)
{
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
{
NoDelay = true,
ReceiveTimeout = 5_000,
};
if (receiveBuffer is int bytes)
{
socket.ReceiveBufferSize = bytes; // before the connect, so it is the window advertised
}
socket.Connect(IPAddress.Loopback, port);
return socket;
}
}Fix options.
- Stop taking buffers for a connection that holds too many. Cancel its recv when its unread completions pass a budget, and re-arm when its handler drains. Its bytes wait in the kernel and TCP slows the peer, as in incremental mode. This also gives shared mode the read backpressure it lacks. The budget has to be small enough that the connections you expect can't add up to the whole ring.
- Copy small recvs out of the ring when the handler isn't reading, so a one-byte send can't pin 32 KiB.
- Recommend incremental mode for anything public, once incremental: a connection whose ring is dry after its handler has gone is never closed, so clients can use up MaxConnections for good #525 is fixed. It costs a ring per connection.
The same bug is filed on kron as MDA2AV/kron#22.
Activity