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.
- Baking -
BuildResponsereads the body first (so a file truncated under us still yields a consistent Content-Length), formats the header withWriteResponseHeader(the one place asset headers are formatted - baking and ring-read serving both use it), and concatenates into one native block. - Hot-path lookup -
TryGet(ReadOnlySpan<byte> urlPath)resolves straight from the recv buffer: ASCII→UTF16 via a stack buffer, then a dictionaryGetAlternateLookup<ReadOnlySpan<char>>- zero per-request string allocation. A string overload exists for convenience. - Symlink safety - enumeration skips reparse points
(
AttributesToSkip = ReparsePoint), so a symlink can't point outside the root and a symlinked directory isn't descended into - traversal is blocked. - Refcounting - the snapshot is reference-counted (
TryAddRef/Release, starting at 1 for the live reference). It is actually freed - descriptors closed, native blocks freed - only when the last reference is dropped. This is what makes reloads safe (below).
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.
- Acquire / Lease -
Acquire()returns aLease(a refcounted hold) on the live snapshot; the handler holds it for the whole request - lookup → ring read → send - and disposes it when the response is fully sent.TryAddReffails if the snapshot is already releasing, soAcquiresimply re-reads the live one. The bareTryGetoverloads don't lease and are only safe whenReloadisn't called concurrently.
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).