File internals

ioxide.file serves static files off the ring: small files are served from a fully precomputed HTTP response in native memory (no I/O at all), large files are read with positional IORING_OP_READs. Reloads swap whole snapshots, and a lease keeps a snapshot alive across an in-flight read. Every class below.

AssetCache · AssetCache.cs

An immutable snapshot of a directory, keyed by URL path. At build time it walks the root and, for each file, opens a descriptor (held for the snapshot's lifetime so the raw fd stays valid - one fd per file, mind RLIMIT_NOFILE). Files at or below maxCachedFileBytes (default 256 KB) get their entire HTTP response - header + body - baked into one contiguous native block at build time, so the hot path serves them with no file I/O, no header formatting, and no allocation. Larger files keep just the fd and are read off the ring.

AssetCache.cs · baking - read the body, format the header, concatenate into one native block

private static unsafe (nint Response, int Length) BuildResponse(SafeFileHandle handle, int bodyLength, string path)
{
    nint scratch = (nint)NativeMemory.Alloc((nuint)Math.Max(bodyLength, 1));
    int read = 0;
    while (read < bodyLength)
    {
        int n = RandomAccess.Read(handle, new Span<byte>((void*)(scratch + read), bodyLength - read), read);
        if (n <= 0) break;
        read += n;                                       // body first - Content-Length matches what ships
    }

    Span<byte> header = stackalloc byte[256];
    int h = WriteResponseHeader(header, path, read);     // the one place asset headers are formatted

    nint response = (nint)NativeMemory.Alloc((nuint)(h + read));
    header[..h].CopyTo(new Span<byte>((void*)response, h));
    Buffer.MemoryCopy((void*)scratch, (void*)(response + h), read, read);
    NativeMemory.Free((void*)scratch);
    return (response, h + read);                          // header + body, one contiguous block
}

AssetCache.cs · the hot path - resolve straight from the recv bytes, zero string allocation

public bool TryGet(ReadOnlySpan<byte> urlPath, out Asset asset)
{
    if (urlPath.Length is 0 or > 1024)
    {
        asset = default;
        return false;
    }

    Span<char> chars = stackalloc char[urlPath.Length];
    if (Ascii.ToUtf16(urlPath, chars, out int written) != OperationStatus.Done)
    {
        asset = default;
        return false;   // keys are ASCII URL paths; anything else can't match
    }

    return _assets.GetAlternateLookup<ReadOnlySpan<char>>().TryGetValue(chars[..written], out asset);
}

AssetCache.cs · enumeration skips reparse points, so a symlink can't escape the root

var walk = new EnumerationOptions
{
    RecurseSubdirectories = true,
    AttributesToSkip = FileAttributes.ReparsePoint | FileAttributes.Hidden | FileAttributes.System,
};

AssetCache.cs · a lock-free refcount - the snapshot frees only when the last hold drops

internal bool TryAddRef()
{
    int r;
    do
    {
        r = Volatile.Read(ref _refs);
        if (r == 0) return false;        // already releasing - don't resurrect
    }
    while (Interlocked.CompareExchange(ref _refs, r + 1, r) != r);
    return true;
}

internal void Release()
{
    if (Interlocked.Decrement(ref _refs) == 0)
    {
        DisposeCore();                   // close every fd, free every native block
    }
}

StaticAssets · StaticAssets.cs

A reloadable holder around an AssetCache. Reload builds a fresh snapshot, swaps it in atomically (Interlocked.Exchange), and drops the live reference on the old one - which is then freed only once every outstanding lease on it is released. No timer, no grace window: an in-flight read on the old snapshot can never hit a closed fd or freed memory.

StaticAssets.cs · Acquire leases the live snapshot; Reload swaps a fresh one in atomically

public Lease Acquire()
{
    while (true)
    {
        AssetCache cache = Volatile.Read(ref _cache);
        if (cache.TryAddRef())
        {
            return new Lease(cache);
        }
        // the snapshot we read was swapped out and is releasing; re-read the live one
    }
}

public void Reload()
{
    lock (_reloadLock)
    {
        AssetCache fresh;
        try { fresh = new AssetCache(_root, _maxCachedFileBytes); }
        catch (Exception e)
        {
            Console.Error.WriteLine($"[ioxide] asset reload failed, keeping current snapshot: {e.Message}");
            return;
        }
        AssetCache old = Interlocked.Exchange(ref _cache, fresh);
        old.Dispose();   // drops the live ref; old frees once every outstanding lease releases
    }
}

AssetReader · AssetReader.cs

One unit of file-serving capacity: a RingOpSource plus a native read buffer. It reads any descriptor (typically an AssetCache fd) at an offset, resumed inline. CreatePool builds a per-reactor RingPool<AssetReader> so concurrent requests don't serialize on one in-flight read. One ReadAsync returns up to Capacity bytes at the given offset; for a file larger than the buffer the caller loops at advancing offsets until the whole file is sent (so big files aren't truncated).

AssetReader.cs · one positional read on the ring, resumed inline

public ValueTask<int> ReadAsync(int fd, long offset)
{
    ValueTask<int> pending = _read.Prepare();
    _host.SubmitRead(fd, Buffer, Capacity, offset, _read);   // positional IORING_OP_READ
    return pending;                                          // caller loops offset += n for big files
}

RingFile · RingFile.cs

A single file whose reads run on the host's ring: opened once (a blocking, one-time open), then each read is an IORING_OP_READ at an offset, resumed inline. One read in flight at a time (it isn't safe to share concurrently - pool AssetReaders over an AssetCache for per-reactor concurrency). The minimal building block when you have a single file rather than a directory.

Reads use positional IORING_OP_READ (pread semantics) into native memory; opens are one-time and blocking (off the hot path), reads are the hot path and async. The small-file baked path is allocation- and copy-free per request; large files take one copy (kernel → native buffer → write slab).