Files and timers
Files
ioxide.file opens every file under a directory once, shares the descriptors with every reactor, and reads them with positional io_uring reads on the reactor that asks. It speaks no HTTP and keeps no copy of any file: serving one is a dictionary lookup and a read on the ring, resumed inline.
dotnet add package ioxide.file
It depends on ioxide core alone. Sending a file without a copy is core's TcpConnection.ReadFileAsync; ioxide.file supplies the descriptors it reads.
What is in it#
| Type | What it is |
|---|---|
StaticAssets | What a server shares with its reactors: a directory's snapshot, leased to each request by Acquire() and replaced by Reload(). |
AssetCache | The snapshot: an open descriptor and a length per file, keyed by URL path. Each entry is an AssetCache.Asset - Fd, Path (absolute) and Length. Usable alone for a tree you never reload. |
AssetReader | A native buffer and one ring read at a time. AssetReader.CreatePool gives a reactor a RingPool<AssetReader>. |
RingFile | One file opened by path, read at offsets on the ring. |
All four are in namespace ioxide.file. Every read is an IORING_OP_READ at an offset through the reactor's IRingHost (reference), resumed on the reactor's thread.
A static file server#
Every file under wwwroot - or the directory given as the first argument - on port 8080, from every core:
using System.Runtime.InteropServices;
using System.Text.Unicode;
using ioxide;
using ioxide.file;
using ioxide.utils;
// Every file under the root, opened once, before any reactor starts: one snapshot for the process.
var assets = new StaticAssets(args.Length > 0 ? args[0] : "wwwroot");
var config = new ServerConfig
{
ReactorCount = Environment.ProcessorCount,
Tcp = new TcpOptions { Port = 8080, WriteSlabSize = 64 * 1024 },
};
var threads = new Thread[config.ReactorCount];
for (int i = 0; i < threads.Length; i++)
{
var reactor = new Reactor(i, config);
reactor.OnStart = r => r.AddService(assets); // the same snapshot on every reactor
reactor.TcpHandle = async (r, conn) =>
{
StaticAssets files = r.GetService<StaticAssets>();
try
{
while (true)
{
RecvSnapshot snapshot = await conn.ReadAsync();
// Held until the response is out: a reload cannot close these descriptors under the read.
using (StaticAssets.Lease lease = files.Acquire())
{
bool asked = false, found = false;
AssetCache.Asset asset = default;
while (conn.TryGetItem(snapshot, out SpscRecvRing.Item item))
{
if (item.HasBuffer)
{
if (!asked) { asked = true; found = Find(lease, item.AsSpan(), out asset); }
conn.ReturnBuffer(in item);
}
}
if (found)
{
if (!await SendFileAsync(conn, asset)) return; // the read failed: close
}
else if (asked)
{
conn.Write("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"u8);
await conn.FlushAsync();
}
}
if (snapshot.IsClosed) return;
conn.ResetRead();
}
}
finally
{
conn.DecRef();
}
};
threads[i] = new Thread(reactor.Run) { Name = $"reactor-{i}" };
threads[i].Start();
}
// After a deploy, kill -HUP <pid> opens the files again and swaps the new snapshot in.
using var reload = PosixSignalRegistration.Create(PosixSignal.SIGHUP, context =>
{
context.Cancel = true;
assets.Reload();
});
foreach (Thread thread in threads) thread.Join();
// The head, then the file read straight into the write slab behind it: no buffer of our own and
// no copy. A slab's worth per send, so a large file never grows the slab.
static async Task<bool> SendFileAsync(TcpConnection conn, AssetCache.Asset asset)
{
const int Chunk = 60 * 1024; // leaves room for the head in the 64 KiB slab
WriteHead(conn, asset);
for (long offset = 0; offset < asset.Length; )
{
int n = await conn.ReadFileAsync(asset.Fd, (int)Math.Min(Chunk, asset.Length - offset), offset);
if (n <= 0) return false; // an errno, or the file is shorter than the snapshot says
conn.AdvanceWrite(n); // the read placed the bytes; this makes them part of the send
offset += n;
if (offset < asset.Length) await conn.FlushAsync();
}
await conn.FlushAsync(); // a small file leaves with its head in one send
return true;
}
// "GET /css/site.css?v=3 HTTP/1.1" -> "/css/site.css", looked up in the leased snapshot.
static bool Find(StaticAssets.Lease lease, ReadOnlySpan<byte> request, out AssetCache.Asset asset)
{
asset = default;
if (!request.StartsWith("GET "u8)) return false;
ReadOnlySpan<byte> target = request[4..];
int end = target.IndexOfAny((byte)' ', (byte)'?');
return end > 0 && lease.TryGet(target[..end], out asset);
}
static void WriteHead(TcpConnection conn, AssetCache.Asset asset)
{
Span<byte> head = conn.GetSpan(256);
Utf8.TryWrite(head, $"HTTP/1.1 200 OK\r\nContent-Type: {ContentType(asset.Path)}\r\nContent-Length: {asset.Length}\r\n\r\n", out int written);
conn.Advance(written);
}
// ioxide.file knows nothing of HTTP: the content type is yours to choose.
static string ContentType(string path) => Path.GetExtension(path.AsSpan()) switch
{
".html" => "text/html; charset=utf-8",
".css" => "text/css",
".js" => "text/javascript",
".json" => "application/json",
".svg" => "image/svg+xml",
".png" => "image/png",
".jpg" => "image/jpeg",
".woff2" => "font/woff2",
".txt" => "text/plain; charset=utf-8",
_ => "application/octet-stream",
};
- One snapshot for the process, opened before the reactors start, since opening blocks.
- A lease per request pins the snapshot until the flush completes, so a reload cannot close the descriptor being read.
- The lookup reads the request bytes where they landed, building no string.
ReadFileAsyncputs the file behind the head in the write slab andAdvanceWritecommits it: a small file leaves with its head in one send, a larger one 60 KiB a send, and the 64 KiB slab never grows.kill -HUPreloads after a deploy.
The content types are the program's own: ioxide.file has no table of them, nor any other part of HTTP. HEAD, ranges, validators, compression and index files are the server's to add. This one answers one request per read, as the Tcp samples do; Playground/Clients/File frames requests across reads, serves TLS by BoringSSL or the kernel, and has both ways of reading.
The snapshot#
new StaticAssets(root) walks the root and the directories under it and opens each file read-only, for the snapshot's life. A file's key is its path under the root with a leading slash, /css/site.css.
- Left out: symbolic links, which could point outside the root, and hidden and system entries. On Linux, .NET counts a name starting with a dot as hidden:
.envis never served, nor anything under.well-known. - Exact lookups, case-sensitive, with nothing decoded or normalised:
%20,..,//and a trailing slash are matched as written, and miss;/finds no index file. The byte overload also misses on an empty path, one over 1024 bytes, or a non-ASCII byte. A request can only name a file the snapshot holds, so no path leads out of the root. - Shared without a lock: each read carries its own offset, so every reactor reads the same descriptor at once.
- Trusted, not re-checked.
Lengthis the size when the snapshot was built - a file edited in place can disagree with it - and the tree's additions and deletions show only after a reload. Deploy by writing the files, then reloading. - One descriptor per file for the snapshot's life: mind
RLIMIT_NOFILEon a large tree, and that a reload holds two snapshots' worth for a moment.
A missing root throws DirectoryNotFoundException, and a file that cannot be opened makes the constructor throw too.
Reloading#
Reload() builds a fresh snapshot of the same root and swaps it in atomically: a request sees the whole old tree or the whole new one. The old snapshot's descriptors close when its last lease is disposed - no timer, no grace period. Reloads are serialised by a lock and safe from any thread. If the rebuild throws, the served snapshot stays, standard error gets [ioxide] asset reload failed, keeping current snapshot: ..., and the descriptors the failed rebuild had opened are left for the garbage collector to close.
StaticAssets.Leasehas the same twoTryGetoverloads. Dispose it exactly once: it is a struct, eachDisposereleases a reference, and disposing a copy too frees the snapshot early.StaticAssets.TryGettakes no lease: safe only whereReloadnever runs.- Dispose
StaticAssetsonly when nothing will callAcquireagain: on a disposed instance,Acquirenever returns.
Reading a file#
Into the write slab#
ReadFileAsync(fd, length, offset) reads straight into the connection's write slab at its tail, saving a buffer, the pool that would lend it, and a copy. Reading and writing has its full contract - short reads, 0 at the end of the file or on a closed connection, one read in flight per connection. The bytes go out as read, which decides where it is legal:
- Cleartext, and kernel TLS transmit (
TlsOptions.KernelTx), where the slab holds plaintext and the kernel makes the records. The method's documentation says "cleartext only", but theClients/Filesample uses it under kernel TLS as well; withKernelTxon, a connection whose kernel TLS cannot be set up fails its handshake rather than falling back to BoringSSL. - Not BoringSSL's TLS, where the slab must hold ciphertext: the file would leave in the clear, and the sample refuses the combination. For one connection, the slab path is safe when it has no
TlsSession, or its session'sKernelTxis true.
Keep TcpOptions.WriteOverflow at Grow, the default. Under Segmented, once a response has spilled past the slab, the file's bytes land in the slab itself and are sent ahead of the bytes that spilled. And the slab grows to fit each read and stays grown until the connection is recycled: read a large file in chunks.
Through a reader's buffer#
Bytes that must change on the way - encrypted by BoringSSL, compressed - are read into a buffer first. AssetReader.CreatePool, from OnStart, builds a reactor's readers and registers them as a service, RingPool<AssetReader>:
reactor.OnStart = r =>
{
r.AddService(assets);
AssetReader.CreatePool(r, readers: 4, bufferBytes: 256 * 1024); // RingPool<AssetReader>, a service
};
readers bounds the reads in flight: a request that finds every reader out waits its turn, in order, without a thread. bufferBytes is each reader's native buffer, allocated once. ReadAsync(fd, offset) always asks for a whole buffer, so a larger file takes a read per buffer at advancing offsets:
// Under BoringSSL the slab must hold ciphertext, so the file passes through a buffer and
// TlsSession.Write encrypts it into the slab. The head went out the same way, before this.
static async Task<bool> SendThroughBufferAsync(TcpConnection conn, TlsSession tls,
RingPool<AssetReader> readers, AssetCache.Asset asset)
{
AssetReader reader = await readers.RentAsync(); // waits in line, without a thread, if all are out
try
{
for (long offset = 0; offset < asset.Length; )
{
int n = await reader.ReadAsync(asset.Fd, offset); // up to reader.Capacity bytes
if (n <= 0) return false;
tls.Write(conn, Bytes(reader.Buffer, n));
await conn.FlushAsync();
offset += n;
}
return true;
}
finally
{
readers.Return(reader); // before the next request rents it
}
}
// The buffer is native memory; an async method cannot hold the pointer itself.
static unsafe ReadOnlySpan<byte> Bytes(nint buffer, int length) => new((void*)buffer, length);
One file: RingFile#
using RingFile file = RingFile.Open(reactor, "/srv/www/index.html"); // a blocking open, once
nint buffer = Marshal.AllocHGlobal((int)file.Length); // native memory you own
try
{
int n = await file.ReadAsync(buffer, (int)file.Length, offset: 0); // bytes, 0 at the end, or -errno
}
finally
{
Marshal.FreeHGlobal(buffer);
}
Open is a blocking open(), read-only and close-on-exec, so it belongs in OnStart; it throws InvalidOperationException when it fails. Length is measured at the open. The memory ReadAsync reads into is yours, and must stay where it is until the read completes. One read at a time - a second while one is in flight throws InvalidOperationException - and Dispose closes the descriptor.
Hits, misses and memory#
ioxide.file holds no file bytes: each read leaves with the reactor's next batch of submissions, and the kernel supplies the bytes. Nothing in ioxide treats a page-cache hit differently from a miss, warms the cache or reads ahead - but the kernel does, and the reactor feels it:
- A hit is copied during the reactor's submit, on its thread - the submit "completes it", in the words of the repository's research notes (Research/Magpie/Ring.cs). A large read is a long copy, and no other connection on that reactor is served until it ends: read in chunks, as the server above does.
- A miss does not hold the reactor. The read completes when the disk has delivered; the reactor's wait returns on any completion, so it serves everything else meanwhile, and only the awaiting handler waits.
ioxide.file used to keep every small file's whole HTTP/1.1 response in memory. That went - it tied the snapshot to HTTP/1.1 and pinned file bytes - and the ring-read path measured about 13% under it (loopback, 4 reactors): the price of not caching. To serve a hot file from memory, read it once into an array of your own and Write it from there.
What it costs#
bench/file-matrix.sh measured the Clients/File sample in October 2026 - one saturated reactor, loopback, best of 2 passes - in requests per second and CPU per request:
| Transport, path | 4 KiB file | 64 KiB file |
|---|---|---|
| Cleartext, into the slab | 347k, 2.91 us | 110k, 9.22 us |
| Cleartext, through a buffer | 283k, 3.57 us | 96.9k, 10.42 us |
| Kernel TLS, into the slab | 225k, 4.51 us | 38.3k, 26.18 us |
| Kernel TLS, through a buffer | 202k, 5.02 us | 35.8k, 28.32 us |
| BoringSSL, through a buffer | 206k, 4.92 us | 52.9k, 19.12 us |
At 4 KiB, skipping the copy is worth 1.23× in cleartext and 1.11× under kernel TLS, and kernel TLS into the slab leads BoringSSL through a buffer by 1.09×. At 64 KiB the kernel's per-byte encryption outweighs the copy saved, and BoringSSL through a buffer wins by 1.38×; the crossover was not measured. In the full suite (bench/results/20260809T181559Z.json, 2 reactors, 64 connections) the sample served a 4 KiB file through the slab at 670k requests per second and 3.01 us of CPU each, against 877k and 2.30 us for Tcp/Raw's 2-byte answer from memory.
What you choose#
| Setting | Default | What it decides |
|---|---|---|
new StaticAssets(rootDir) | required | The tree the snapshot opens. |
AssetReader.CreatePool(reactor, readers, bufferBytes) | required; the sample uses 4 and 1 MiB | Reads in flight per reactor through buffers, and each buffer's size. |
TcpOptions.WriteSlabSize | 16 KiB | The slab a file is read into; ReadFileAsync grows it to fit a read. |
TcpOptions.WriteOverflow | Grow | Keep it with ReadFileAsync, as above. |
TlsOptions.KernelTx | false | Whether a TLS connection can take the slab path. |
ServerConfig.OpSlots | 4096 | The reactor's table of ring operations in flight: each read holds a slot until it completes, and the table doubles when it runs out. |
The rest is fixed: what the walk leaves out, the 1024-byte limit on a byte lookup, and the absence of HTTP.
Where to go next#
Timers
RingTimer: a wait the kernel holds on the reactor's ring, and how the rest of ioxide keeps time.
Reading and writing
ReadFileAsync in full, the write slab, flushes and overflow.
Kernel TLS
Encryption in the kernel, which keeps the slab path open over TLS.
IRingHost
The ring operations ioxide.file is built from, for a client of your own.