Skip to content

shared ring: a few connections parked in a flush can drain the ring and stop every recv on the reactor #526

Open

Description

@MDA2AV

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

  1. added
    bugSomething isn't working
    severity:highConnection loss / resource leak
    on Nov 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingseverity:highConnection loss / resource leak

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions