Files over the ring

ioxide.file serves static assets the way the I/O model wants: small, hot files straight from immutable memory snapshots; large files as positional io_uring reads - no thread pool either way.

Asset snapshots

AssetCache opens every file under a root once, keyed by URL path. Files up to a threshold (default 256KB) get their entire HTTP response - status line, Content-Type, Content-Length, body - baked into native memory at snapshot build. Serving one is a dictionary lookup (span-based, straight from the request bytes, no allocation) and a send. Larger files keep a descriptor for ring reads.

// One snapshot, shared across all reactors.
var assets = new StaticAssets("/srv/www");

reactor.OnStart = r =>
{
    r.AddService(assets);

    // Per-reactor read concurrency for the large-file path.
    AssetReader.CreatePool(r, readers: 4, bufferBytes: 1 << 20);
};

Serving

var assets  = reactor.GetService<StaticAssets>();
var readers = reactor.GetService<RingPool<AssetReader>>();

if (assets.TryGet(pathBytes, out var asset))
{
    if (asset.Response != 0)
    {
        // Hot path: the baked response - no file I/O, no header formatting.
        await SendChunked(conn, asset.Response, asset.ResponseLength);
    }
    else
    {
        // Large file: positional read off the ring through a rented reader.
        var reader = await readers.RentAsync();

        try
        {
            int read = await reader.ReadAsync(asset.Fd, offset: 0);

            WriteHeader(conn, asset, read);
            await SendChunked(conn, reader.Buffer, read);
        }
        finally
        {
            readers.Return(reader);
        }
    }
}

The reader pool is what keeps concurrent requests from serializing: each rented AssetReader owns its buffer and in-flight slot, and the cache's descriptors are shared and positional, so any number of readers - within a reactor and across reactors - can read the same file at once.

Atomic reloads

Snapshots are immutable; deploys swap them whole. StaticAssets.Reload() builds a fresh AssetCache, swaps it in atomically, and disposes the old one after a grace period - a reactor always sees a consistent snapshot, and Content-Length always matches the body it ships. Wire it to SIGHUP and a deploy is kill -HUP $(pidof server):

PosixSignalRegistration.Create(PosixSignal.SIGHUP, ctx =>
{
    ctx.Cancel = true;   // handled - don't let the default action terminate us
    assets.Reload();
});
The baked path removes per-request file I/O, header formatting, and allocation entirely; the ring-read path remains for files above the bake threshold. Benchmark on your own hardware with the Playground's file mode.