ioxidedocs

Core runtime

API reference

The core's public types that applications use, in namespace ioxide: the configuration, the reactor, and the seam ring-native clients are built on. The connection a TCP handler gets is in TCP connections, and the QUIC types in QUIC below HTTP.

ServerConfig#

Everything a reactor is configured with. sealed record ServerConfig, set with init. Give every reactor of a server the same instance.

PropertyDefaultMeaning
int ReactorCount12How many reactors the server runs. You create them; a reactor reads this back as ShardCount, which QUIC routes datagrams between reactors by.
uint RingEntries8192Submission queue depth of each ring, at most 32768. The kernel makes the completion queue twice as deep.
int OpSlots4096Initial size of each reactor's table of in-flight IRingHost operations - every timer, and every socket or file operation of the clients, holds a slot until it completes. Doubles when full. Must be positive.
bool DualStackfalseBind TCP listeners and UDP sockets as IPv6 on :: with IPV6_V6ONLY off, so one socket takes IPv4 and IPv6 clients. False: IPv4 on 0.0.0.0.
int RecvBufferSize32 KiBShared mode: bytes per receive buffer.
int RecvSlots4096Shared mode: buffers in the reactor's ring - a power of two below 65536, or Run fails. Together with RecvBufferSize it sets the memory the reactor reserves for receiving: 4096 buffers of 32 KiB, 128 MiB.
IncrementalOptions? IncrementalnullSet: a buffer ring per connection (kernel 6.12+), and the two options above go unused. Null: the shared ring. Receive buffers compares them.
TcpOptions? Tcpport 8080The TCP transport: ports, backlog, connection pool, write path and timeouts - TCP connections. Null opens no TCP listener: a QUIC-only server, or a reactor that only calls out.
UdpOptions? Udpno portsRaw UDP sockets, below. Opens nothing until Ports lists one; null opens none either.
QuicOptions? QuicnullSet: the QUIC transport, on a UDP port of its own (443 by default) - QUIC on the ring.

IncrementalOptions#

sealed record IncrementalOptions - the per-connection buffer rings of IOU_PBUF_RING_INC.

PropertyDefaultMeaning
int MaxConnections4096Buffer-group ids per reactor, one per live connection. An accept past it is closed at once - as is one whose ring cannot be registered for want of locked memory.
int RecvSlots16Buffers per connection ring, a power of two. Nothing checks it until the first connection registers its ring, and a refusal then ends the reactor.
int RecvBufferSize4096Bytes per buffer; the kernel appends successive receives into one until it is full.

In this mode every connection sends with a plain send and grows its write buffer: TcpOptions.ZeroCopySend and WriteOverflow are not applied.

UdpOptions#

sealed record UdpOptions - raw datagram sockets. QUIC binds its own port and shares only RecvSlots, SocketBufferBytes and Gro with them.

PropertyDefaultMeaning
ushort[] PortsemptyPorts every reactor binds, with SO_REUSEPORT; their datagrams go to OnDatagram.
int RecvSlots16Depth of the reactor's UDP buffer ring, rounded up to a power of two. A buffer holds one datagram or GRO train of up to 64 KiB, so this bounds the datagrams in flight across the reactor's UDP sockets.
int SocketBufferBytes8 MiBReceive and send buffer asked for on every UDP socket. The kernel clamps it to net.core.rmem_max and wmem_max - 208 KiB on a stock box - and ioxide says so once at start. A higher cap is no sure win: on the repository's HTTP/3 benchmark the full 8 MiB cost about 45% of throughput at saturation. Must be positive.
bool GrotrueUDP_GRO: a burst of equal-size datagrams from one peer arrives as one, with GroSegmentSize set.

Reactor#

sealed class Reactor : IRingHost - one ring and the thread that runs it.

Lifecycle and identity#

MemberWhat it does
Reactor(int id, ServerConfig config)Records the configuration and opens nothing. id runs from 0 to ReactorCount - 1. Throws ArgumentOutOfRangeException when OpSlots is not positive.
void Run()The reactor's life, on the calling thread: install the synchronization context, create the ring, open the listeners and UDP sockets, register the receive buffers, run OnStart, arm the accepts, loop until Stop, tear down. A fault goes to OnFault, or leaves Run when there is none.
void Stop()From any thread: the loop finishes its turn and exits, and Run closes the listeners, the UDP sockets and the accepted connections, then the ring, on the reactor thread. Join the thread to wait for it.
int ShardIndexThe id. QUIC stamps it into the connection IDs it mints.
int ShardCountServerConfig.ReactorCount.
bool OnReactorThreadWhether the caller is this reactor's thread.
const int TickMs250: the milliseconds between ticker runs.

Hooks#

Public fields, set before Run. Each is called on the reactor thread.

FieldCalled
Action<Reactor>? OnStartOnce, after the ring, sockets and buffers exist and before the loop starts: open clients and add services and tickers here. The listeners are already bound, so a connection that arrives meanwhile waits in the backlog.
Func<Reactor, TcpConnection, Task> TcpHandleOnce per accepted connection, started inline from the accept's completion. A throw is logged and the connection released. Needed whenever Tcp is set, which it is by default.
Func<Reactor, QuicConnection, Task>? QuicHandleOnce per QUIC connection the transport adopts. Null starts none (QUIC below HTTP).
UdpDatagramHandler? OnDatagramOnce per datagram on a UdpOptions.Ports socket, synchronously (UDP).
Action<Reactor, Exception>? OnFaultWhen the reactor ends on an exception rather than Stop - the ring refused, OnStart threw, an io_uring_enter error - before the teardown. Without it the exception leaves Run.

Scheduling, services and tickers#

MemberWhat it does
void ScheduleOnReactor(Action<object?> callback, object? state)Queues callback(state) to run on the reactor thread at the top of the next turn; from another thread it also wakes the reactor. A throw from the callback is logged. Safe from any thread.
void AddService<T>(T service) where T : classKeeps service under typeof(T), replacing any earlier one. Reactor thread.
T GetService<T>() where T : classThe service kept under typeof(T); throws InvalidOperationException when there is none. Reactor thread.
void AddTicker(Action ticker)Runs ticker on the reactor thread every TickMs for the reactor's life; a throw is logged and the ticker kept. Reactor thread.

Sending datagrams#

MemberWhat it does
void UdpSendTo(int socketFd, nint peerAddr, int peerAddrLen, ReadOnlySpan<byte> payload, int gsoSegmentSize = 0)Sends one datagram from one of the reactor's UDP sockets to a native sockaddr. Address and payload are copied into a pooled send slot, so both can be reused at once. With gsoSegmentSize above 0 the kernel splits the payload into datagrams of that size (UDP_SEGMENT). Throws ArgumentException for a payload over 64 KiB or an address over 128 bytes. A failed send is logged, not reported. Reactor thread only: elsewhere it throws InvalidOperationException.

Plumbing#

EnqueueReturnQ(ushort bid) and EnqueueReturnQIncremental(int fd, ushort gen, ushort bid) are how a TCP connection hands a receive buffer back from any thread; hand buffers back through the connection instead (Reading and writing). The members named Quic... belong to the QUIC transport and its engine (QUIC on the ring).

IRingHost and IRingCompletion#

interface IRingHost - what a ring-native client submits through; Reactor implements it. Each call takes the completion to call back. Buffers and addresses cross as nint, because a pointer cannot live across an await, and the memory behind them must stay valid and in place until the operation completes. A result is a byte count or a negative errno. The calls are valid once Run has started - from OnStart on - and from any thread: another thread's submission is queued and submitted on the reactor's next turn. There is no cancel.

MemberOperation
void SubmitConnect(int fd, nint sockaddr, int sockaddrLen, IRingCompletion completion)IORING_OP_CONNECT: 0 once connected.
void SubmitSend(int fd, nint buffer, int length, IRingCompletion completion)IORING_OP_SEND: the bytes sent, which may be fewer than length.
void SubmitRecv(int fd, nint buffer, int length, IRingCompletion completion)IORING_OP_RECV: the bytes received, 0 at end of stream.
void SubmitRead(int fd, nint buffer, int length, long offset, IRingCompletion completion)IORING_OP_READ at offset: a positional read, for files.
void SubmitWrite(int fd, nint buffer, int length, long offset, IRingCompletion completion)IORING_OP_WRITE at offset.
void SubmitTimeout(long nanoseconds, IRingCompletion completion)IORING_OP_TIMEOUT, held by the kernel: completes with -ETIME (-62) when the time is up, which for a timeout is success. Less than 1 ns counts as 1.

interface IRingCompletion has one member, void Complete(int result). The reactor calls it on its thread as it dispatches the completion, after freeing the operation's slot, so it may submit the next operation. An exception that escapes it ends the reactor.

RingOpSource#

sealed class RingOpSource : IRingCompletion, IValueTaskSource<int> - one operation at a time as an awaitable, reusable and allocation-free, whose await resumes inline on the reactor (how).

MemberWhat it does
ValueTask<int> Prepare()Arms the source for one operation and returns the task to await. Call it just before the submit. Throws InvalidOperationException while an operation is in flight.
void Complete(int result)Called by the reactor: completes the task with result, running the awaiting code inline.

RingSocket#

sealed class RingSocket : IDisposable - a TCP client socket whose connect, sends and receives run on a ring: the building block of ioxide.httpclient, ioxide.pg and ioxide.redis.stackexchange. A complete program uses one.

MemberWhat it does
static RingSocket CreateTcp(IRingHost host, AddressFamily family = AddressFamily.InterNetwork)A TCP socket of that family - InterNetworkV6 for an IPv6 address - with TCP_NODELAY, closed on exec, whose operations go to host, a Reactor. Throws InvalidOperationException if the socket cannot be created.
ValueTask<int> ConnectAsync(IPAddress address, ushort port)Connects on the ring: 0, or a negative errno. IPv4 or IPv6, matching the socket's family; an IPv6 address keeps its scope id. No names: resolve first, off the reactor - a DNS lookup would block it.
ValueTask<int> ConnectAsync(string ipv4, ushort port)The same for an IPv4 literal; throws ArgumentException for anything else.
ValueTask<int> SendAsync(nint buffer, int length)One send: the bytes sent, possibly fewer than length, or a negative errno.
ValueTask<int> RecvAsync(nint buffer, int length)One receive: the bytes received, 0 at end of stream, or a negative errno.
int FdThe descriptor.
void Dispose()Closes the descriptor. Only with nothing in flight: an operation cannot be cancelled.

One send and one receive may be in flight together - the socket keeps a RingOpSource for each, and the connect uses the receive one - and a second of either throws. IPv6 arrived in 0.17.339; the HTTP client resolves names off the reactor and connects with these.

RingPool<T>#

sealed class RingPool<T> where T : class - resources rented and returned on one reactor, with a first-come queue for renters that find none idle. Its continuations run synchronously, so a return on the reactor resumes the next renter inline, with no thread involved. It takes a lock, which makes it safe from other threads too. ioxide.file's pool of readers is one.

MemberWhat it does
ValueTask<T> RentAsync()An idle resource at once, the last returned first; otherwise a wait in arrival order that blocks no thread.
void Return(T item)Hands item to the oldest waiter, running its continuation now, or keeps it idle. Also how the pool is filled.
int IdleCount, int WaiterCountResources idle and renters waiting, right now.

ReactorSynchronizationContext#

sealed class ReactorSynchronizationContext : SynchronizationContext - installed by Run on each reactor's thread; applications do not create one (how it is used).

MemberWhat it does
Reactor ReactorThe reactor it posts to.
void Post(SendOrPostCallback d, object? state)Queues d(state) on the reactor, never inline - the same queue as ScheduleOnReactor.
void Send(SendOrPostCallback d, object? state)On the reactor thread, runs d(state) at once. Elsewhere, posts it and blocks until it has run, rethrowing what it threw.
SynchronizationContext CreateCopy()The same instance.

UDP#

Each reactor binds every UdpOptions.Ports port with SO_REUSEPORT and keeps one multishot receive armed per socket. Every datagram goes to OnDatagram on the reactor thread, and its buffer goes back to the ring as soon as the handler returns. An echo server:

using ioxide;

var config = new ServerConfig
{
    ReactorCount = Environment.ProcessorCount,
    Tcp = null,                               // no TCP listener
    Udp = new UdpOptions { Ports = [9000] },  // every reactor binds 9000 with SO_REUSEPORT
};

var threads = new Thread[config.ReactorCount];
for (int i = 0; i < threads.Length; i++)
{
    var reactor = new Reactor(i, config)
    {
        // Inline on the reactor thread. The datagram's memory is valid only during this call;
        // UdpSendTo copies what it sends.
        OnDatagram = static (Reactor r, in UdpDatagram d) =>
            r.UdpSendTo(d.SocketFd, d.PeerAddr, d.PeerAddrLen, d.Payload),
    };
    threads[i] = new Thread(reactor.Run) { Name = $"reactor-{i}" };
    threads[i].Start();
}

foreach (Thread thread in threads)
{
    thread.Join();
}

delegate void UdpDatagramHandler(Reactor reactor, in UdpDatagram datagram) - a throw is logged and the datagram dropped. Datagrams on the QUIC port go to the QUIC transport instead. readonly ref struct UdpDatagram points into the receive buffer and is valid only during the call:

FieldMeaning
int SocketFdThe socket it arrived on; reply through it with UdpSendTo.
ushort LocalPortThat socket's port, so one handler can serve several.
nint PeerAddr, int PeerAddrLenThe sender's native sockaddr_in or sockaddr_in6.
ReadOnlySpan<byte> PayloadThe datagram, or with GRO a train of them. Anything past 64 KiB is cut off, and logged.
int GroSegmentSizeNon-zero when GRO coalesced a train: Payload holds datagrams of this size, the last possibly shorter.
byte TosThe TOS or traffic-class byte; ECN is its low two bits.

IoxideRuntime and the clock#

IoxideRuntime is not an entry point: it holds one constant, Version, the NuGet package's version, generated from the project file at build time - "0.18.374" in the release these pages describe. There is no host and no builder; a server is the reactors you start.

Native.MonotonicMs is the millisecond clock every ioxide timeout and deadline reads: CLOCK_MONOTONIC_COARSE, read from the vDSO in about 2 ns, the same value Environment.TickCount64 returns. The rest of Native is the raw interop - system calls, constants and kernel structures - public, but not an application API.