diff --git a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj
index 59680062..e46653b1 100644
--- a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj
+++ b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj
@@ -10,8 +10,7 @@
-
-
+
diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs
index 1ca861cd..a4dd6544 100644
--- a/Engine/Ioxide/Hosting/IoxideServer.cs
+++ b/Engine/Ioxide/Hosting/IoxideServer.cs
@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.IO.Pipelines;
+using System.Security.Cryptography.X509Certificates;
using GenHTTP.Api.Content;
using GenHTTP.Api.Infrastructure;
@@ -9,6 +10,8 @@
using GenHTTP.Engine.Shared.Types;
using ioxide;
+using ioxide.tls;
+
using Microsoft.Extensions.Logging;
namespace GenHTTP.Engine.Ioxide.Hosting;
@@ -17,13 +20,23 @@ public sealed class IoxideServer : IServer
{
private readonly ServerConfiguration _config;
- private readonly IoxideEndPoint _endPoint;
+ private readonly IoxideEndPoint _primary;
+
+ private readonly Dictionary _endPointByPort;
+
+ private readonly Dictionary _secure;
+
+ private readonly ushort[] _extraPorts;
private readonly Func? _configure;
private readonly Action? _onReactorStart;
- private readonly Func>? _connectionFactory;
+ private readonly Func>? _connectionFactory;
+
+ private readonly bool _kernelTx;
+
+ private readonly bool _kernelRx;
private readonly ILogger _logger;
@@ -45,35 +58,74 @@ public sealed class IoxideServer : IServer
public IHandler Handler { get; }
- internal IoxideServer(ServerConfiguration config, IHandler handler, Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null)
+ internal IoxideServer(ServerConfiguration config, IHandler handler, Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null, bool kernelTx = false, bool kernelRx = false)
{
_config = config;
Handler = handler;
_configure = configure;
_onReactorStart = onReactorStart;
_connectionFactory = connectionFactory;
+ _kernelTx = kernelTx;
+ _kernelRx = kernelRx;
_logger = config.Logging.CreateLogger();
- var ep = config.EndPoints.First(); // spike: still only SERVE the first endpoint
+ var mapped = config.EndPoints
+ .Select(e => new IoxideEndPoint(e.Address, e.Port, e.DualStack, e.Security != null))
+ .ToList();
+
+ _primary = mapped[0];
+ _endPointByPort = mapped.ToDictionary(e => e.Port);
+ _extraPorts = mapped.Skip(1).Select(e => e.Port).ToArray();
- _endPoint = new IoxideEndPoint(ep.Address, ep.Port, ep.DualStack, ep.Security != null);
+ if (mapped.Any(e => e.DualStack != _primary.DualStack))
+ {
+ throw new NotSupportedException("The ioxide engine binds all endpoints with one dual-stack mode.");
+ }
- // Advertise every configured endpoint (including secure ones) in IServer.EndPoints so concerns
- // that inspect it — e.g. the secure-upgrade redirect, which derives the HTTPS port from a secure
- // endpoint — behave correctly, even though the reactor currently only binds the first endpoint.
- EndPoints = new IoxideEndPoints(
- config.EndPoints.Select(e => (IEndPoint)new IoxideEndPoint(e.Address, e.Port, e.DualStack, e.Security != null)).ToList()
- );
+ // Certificates are resolved per reactor in OnStart, not here: the provider is queried for
+ // its default (no-SNI) certificate then, and a port whose provider yields none is still
+ // advertised as secure (so secure-upgrade redirects work) but serves no handshake.
+ _secure = config.EndPoints
+ .Where(e => e.Security is not null)
+ .ToDictionary(e => e.Port, e => e.Security!);
- var endPointCount = config.EndPoints.Count();
+ EndPoints = new IoxideEndPoints(mapped.Cast().ToList());
+ }
- if (endPointCount > 1)
+ // The certificate for every secure port whose provider yields a default (no-SNI) certificate.
+ // Providers that select by SNI (unsupported here) return none and are skipped - the port stays
+ // advertised as secure but its handshakes are refused.
+ private IEnumerable> ResolveTls()
+ {
+ foreach (var (port, security) in _secure)
{
- _logger.LogWarning("Configured with {Count} endpoints, but the ioxide engine only serves the first one ({Address}:{Port})", endPointCount, _endPoint.Address, _endPoint.Port);
+ if (security.CertificateValidator is not null)
+ {
+ throw new NotSupportedException("Client certificate validation is not supported by the ioxide engine.");
+ }
+
+ if (security.CertificateProvider.Provide(null) is not { } certificate)
+ {
+ _logger.LogWarning("No default certificate for secure port {Port}; handshakes there will be refused (SNI selection is unsupported).", port);
+ continue;
+ }
+
+ yield return new(port, new TlsOptions
+ {
+ CertificatePem = certificate.ExportCertificatePem(),
+ KeyPem = ExportKeyPem(certificate),
+ KernelTx = _kernelTx,
+ KernelRx = _kernelRx
+ });
}
}
+ private static string ExportKeyPem(X509Certificate2 certificate)
+ => certificate.GetRSAPrivateKey()?.ExportPkcs8PrivateKeyPem()
+ ?? certificate.GetECDsaPrivateKey()?.ExportPkcs8PrivateKeyPem()
+ ?? throw new InvalidOperationException("The certificate carries no exportable RSA or ECDSA private key.");
+
public async ValueTask StartAsync()
{
await PrepareHandlerAsync();
@@ -87,12 +139,16 @@ public async ValueTask StartAsync()
cfg = _configure(cfg);
}
- // The endpoint binding (.Port()/.Bind()) determines the listen port and dual-stack mode, so
+ // The endpoint bindings (.Port()/.Bind()) determine the listen ports and dual-stack mode, so
// they always win over whatever the configuration hook may have set.
cfg = cfg with
{
- Port = _endPoint.Port,
- DualStack = _endPoint.DualStack
+ DualStack = _primary.DualStack,
+ Tcp = (cfg.Tcp ?? new TcpOptions()) with
+ {
+ Port = _primary.Port,
+ ExtraPorts = _extraPorts
+ }
};
_threads = new Thread[cfg.ReactorCount];
@@ -108,16 +164,26 @@ public async ValueTask StartAsync()
{
var reactor = new Reactor(i, cfg)
{
- // Runs once on the reactor's own thread before it serves: bind the reactor into the
- // [ThreadStatic] seam so handler code can resolve per-reactor services, then let the
- // host register those services (e.g. PgPool.Start(r, ...)) on this reactor's ring.
OnStart = r =>
{
IoxideReactor.Bind(r);
+
+ if (_secure.Count > 0)
+ {
+ var registry = new TlsRegistry();
+
+ foreach (var (port, options) in ResolveTls())
+ {
+ registry.Add(port, TlsService.Start(r, options, register: false));
+ }
+
+ r.AddService(registry);
+ }
+
_onReactorStart?.Invoke(r);
listening.Signal();
},
- Handle = (_, c) => ConnectionDriver.HandleAsync(this, _endPoint, c, _connectionFactory),
+ TcpHandle = (_, c) => ConnectionDriver.HandleAsync(this, _endPointByPort[c.ListenerPort], c, _connectionFactory)
};
_reactors[i] = reactor;
@@ -143,7 +209,10 @@ public async ValueTask StartAsync()
_logger.LogWarning("Not all reactors reported listening within 10s; the server may not be fully accepting yet.");
}
- _logger.LogInformation("Listening on {Address}:{Port} ({Settings})", _endPoint.Address, _endPoint.Port, DescribeSettings());
+ if (_logger.IsEnabled(LogLevel.Information))
+ {
+ _logger.LogInformation("Listening on {Address}:{Port} ({Settings})", _primary.Address, _primary.Port, DescribeSettings());
+ }
}
private async ValueTask PrepareHandlerAsync()
@@ -164,7 +233,7 @@ private async ValueTask PrepareHandlerAsync()
}
}
- private string DescribeSettings() => $"ioxide, {(_endPoint.Secure ? "HTTPS" : "HTTP")}, DualStack: {_endPoint.DualStack}, Reactors: {_reactors?.Length ?? 0}";
+ private string DescribeSettings() => $"ioxide, {_endPointByPort.Count} endpoint(s), TLS on {_secure.Count}, DualStack: {_primary.DualStack}, Reactors: {_reactors?.Length ?? 0}";
public async ValueTask DisposeAsync()
{
diff --git a/Engine/Ioxide/Hosting/IoxideServerHost.cs b/Engine/Ioxide/Hosting/IoxideServerHost.cs
index 62335033..76ce2dc9 100644
--- a/Engine/Ioxide/Hosting/IoxideServerHost.cs
+++ b/Engine/Ioxide/Hosting/IoxideServerHost.cs
@@ -9,10 +9,10 @@
namespace GenHTTP.Engine.Ioxide.Hosting;
-public sealed class IoxideServerHost(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null) : ServerHost
+public sealed class IoxideServerHost(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null, bool kernelTx = false, bool kernelRx = false) : ServerHost
{
-
+
protected override IServer Build(ServerConfiguration config, IHandler handler)
- => new IoxideServer(config, handler, configure, onReactorStart, connectionFactory);
+ => new IoxideServer(config, handler, configure, onReactorStart, connectionFactory, kernelTx, kernelRx);
}
diff --git a/Engine/Ioxide/Protocol/ConnectionDriver.cs b/Engine/Ioxide/Protocol/ConnectionDriver.cs
index cc6aa8fd..105dd75b 100644
--- a/Engine/Ioxide/Protocol/ConnectionDriver.cs
+++ b/Engine/Ioxide/Protocol/ConnectionDriver.cs
@@ -15,7 +15,7 @@
using Microsoft.Extensions.Logging;
using Connection = GenHTTP.Api.Protocol.Connection;
-using IoConnection = ioxide.Connection;
+using IoConnection = ioxide.TcpConnection;
namespace GenHTTP.Engine.Ioxide.Protocol;
@@ -89,10 +89,39 @@ internal static partial class ConnectionDriver
internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoConnection conn, Func>? connectionFactory)
{
- // Default transport is a plain duplex pipe over the connection. A connectionFactory (e.g. the
- // TLS-terminating one supplied by the host for the :8081 listener) can swap in a transport that
- // decrypts inbound bytes and writes plaintext for kTLS TX.
- var pipe = connectionFactory is null ? new ioxide.ConnectionDualPipe(conn) : await connectionFactory(conn);
+ IDuplexPipe pipe;
+
+ try
+ {
+ if (connectionFactory is not null)
+ {
+ pipe = await connectionFactory(conn);
+ }
+ else if (endPoint.Secure)
+ {
+ // A secure port with no certificate (an SNI-only provider yielded none) is advertised
+ // for redirects but cannot handshake - FIN the connection so the client's handshake
+ // fails fast rather than a plaintext response landing on an https port.
+ if (!IoxideReactor.Current.GetService().TryFor(conn.ListenerPort, out var service))
+ {
+ _ = Shutdown(conn.ClientFd, ShutWrite);
+ conn.DecRef();
+ return;
+ }
+
+ pipe = await IoxideTls.AcceptAsync(conn, service);
+ }
+ else
+ {
+ pipe = new ioxide.TcpConnectionDualPipe(conn);
+ }
+ }
+ catch
+ {
+ // failed handshake (or factory fault) - release the connection instead of leaking it
+ conn.DecRef();
+ return;
+ }
var reader = pipe.Input;
var writer = pipe.Output;
diff --git a/Engine/Ioxide/README.md b/Engine/Ioxide/README.md
index 22a029dd..64a17e62 100644
--- a/Engine/Ioxide/README.md
+++ b/Engine/Ioxide/README.md
@@ -13,13 +13,14 @@ no ASP.NET Core.
```
ioxide reactor (one per core, io_uring, SO_REUSEPORT)
- └─ accept → Connection
- └─ new ConnectionDualPipe(conn) // .Input = PipeReader, .Output = PipeWriter (zero-copy, inline IVTS)
+ └─ accept → TcpConnection
+ └─ TcpConnectionDualPipe(conn) // .Input = PipeReader, .Output = PipeWriter (zero-copy, inline IVTS)
+ (or TlsConnectionDualPipe for endpoints bound with a certificate)
└─ ConnectionDriver loop:
Glyph11 parser → GenHTTP Request (reused, public) → Handler.HandleAsync → ResponseWriter → PipeWriter
```
-The integration seam is ioxide's `ConnectionDualPipe`: GenHTTP's parse/handle/respond
+The integration seam is ioxide's `TcpConnectionDualPipe`: GenHTTP's parse/handle/respond
loop is already pure `PipeReader`/`PipeWriter`, so ioxide's native pipe bridge drops
straight in. Reused from GenHTTP unchanged: the public `Request` model, the Glyph11
parser, the `IResponseSink` content contract. Forked (thin): the per-connection loop
@@ -48,10 +49,10 @@ binding (`.Port()`/`.Bind()`), so any port set in the hook is overridden.
```csharp
await Host.Create(c => c with
{
- ReactorCount = 16, // one io_uring reactor per core
- RingEntries = 16384,
- RecvBufferSize = 64 * 1024,
- BufferRingEntries = 8192,
+ ReactorCount = 16, // one io_uring reactor per core
+ RingEntries = 16384,
+ RecvBufferSize = 64 * 1024,
+ RecvSlots = 8192,
})
.Handler(app)
.RunAsync();
@@ -60,9 +61,9 @@ await Host.Create(c => c with
## Dependency on ioxide
References the published [`ioxide`](https://www.nuget.org/packages/ioxide) NuGet
-package (`0.0.5`). The BCL pipe bridges the engine builds on
-(`ConnectionDualPipe`/`ConnectionPipeReader`/`ConnectionPipeWriter`/`ConnectionStream`)
-ship in that package.
+package (`0.4.161`). The BCL pipe bridges the engine builds on
+(`TcpConnectionDualPipe`/`TcpConnectionPipeReader`/`TcpConnectionPipeWriter`) and the
+ring-native TLS termination (`TlsService`/`TlsConnectionDualPipe`) ship in that package.
**Build note:** requires a .NET SDK with Roslyn 5.3+ (SDK 10.0.301+) because
GenHTTP's `MemoryView` source generator references `Microsoft.CodeAnalysis 5.3`.
@@ -77,10 +78,14 @@ Response handling mirrors the Internal engine: cached status lines, a once-a-sec
`Date` header (per-reactor, thread-static), and a `ChunkedWriter` for unknown-length
content. `Handler.PrepareAsync` runs at startup so handlers initialise before serving.
+Also validated: **HTTPS** - endpoints bound with a certificate (`.Bind(address, port, cert)`)
+are TLS-terminated ring-natively (OpenSSL both ways; kernel TLS stays opt-in through the
+`connectionFactory` seam) - and **multiple endpoints**, served by one reactor set via
+`ExtraPorts`, mixed plaintext and TLS.
+
Not yet implemented:
-- TLS / HTTPS (ioxide is plaintext `AF_INET` only here; `ioxide.tls`/kTLS would wire HTTPS endpoints).
-- IPv6 bind and multiple endpoints (first endpoint only).
+- Client certificates, SNI-selected certificates, and per-endpoint dual-stack modes.
- Graceful shutdown / connection drain (reactors are background threads; `DisposeAsync` only flips `Running`).
- `IServerCompanion` callbacks, the `Host`-header check, and the error-response path.
diff --git a/Engine/Ioxide/Server.cs b/Engine/Ioxide/Server.cs
index aafbe1cc..3bfc619c 100644
--- a/Engine/Ioxide/Server.cs
+++ b/Engine/Ioxide/Server.cs
@@ -26,13 +26,23 @@ public static class Host
/// 's reactor seam (IoxideReactor.Current).
///
///
- /// Optional hook to turn an accepted into the duplex pipe the engine
- /// serves it over. Defaults to a plain ConnectionDualPipe. Supply a custom factory to
- /// wrap the transport — e.g. terminate TLS on a second listener port by decrypting inbound bytes
- /// and writing plaintext for kTLS TX. A returned pipe implementing
- /// is disposed when the connection ends.
+ /// Optional hook to turn an accepted into the duplex pipe the engine
+ /// serves it over, overriding the built-in transport selection (plain pipe, or TLS termination
+ /// for endpoints bound with a certificate). A returned pipe implementing
+ /// is disposed when the connection ends.
///
- public static IServerHost Create(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null)
- => new IoxideServerHost(configure, onReactorStart, connectionFactory);
+ ///
+ /// Offload TLS record ENCRYPTION to the kernel (kTLS TX) on TLS-terminated endpoints instead of
+ /// encrypting in OpenSSL. Off by default (OpenSSL both ways). The kernel produces the records on
+ /// the send path while OpenSSL still drives the handshake; requires the Linux tls module
+ /// and TLS 1.3.
+ ///
+ ///
+ /// Offload TLS record DECRYPTION to the kernel (kTLS RX) on the receive path. Off by default and
+ /// experimental; it requires (RX shares the ULP handoff TX installs,
+ /// so ioxide refuses RX alone) and a peer that sends no post-handshake control records.
+ ///
+ public static IServerHost Create(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null, bool kernelTx = false, bool kernelRx = false)
+ => new IoxideServerHost(configure, onReactorStart, connectionFactory, kernelTx, kernelRx);
}
diff --git a/Engine/Ioxide/Tls/IoxideTls.cs b/Engine/Ioxide/Tls/IoxideTls.cs
index ef25bc0b..d0b95483 100644
--- a/Engine/Ioxide/Tls/IoxideTls.cs
+++ b/Engine/Ioxide/Tls/IoxideTls.cs
@@ -6,24 +6,13 @@
namespace GenHTTP.Engine.Ioxide;
///
-/// TLS-termination helpers for the ioxide engine. The engine owns the transport plumbing (the
-/// decrypt pump + kTLS-TX pipe adapter, ); the host supplies the
-/// certificate/key and decides which connections to terminate (typically by listener port).
+/// TLS helpers for the ioxide engine. Endpoints bound with a certificate are terminated
+/// automatically; these helpers remain for hosts that wire a custom connectionFactory.
///
-///
-///
-/// Host.Create(
-/// configure: c => c with { ExtraPorts = [8081] },
-/// onReactorStart: r => IoxideTls.StartService(r, new TlsOptions { CertificatePath = cert, KeyPath = key }),
-/// connectionFactory: conn => conn.ListenerPort == 8081
-/// ? IoxideTls.AcceptAsync(conn)
-/// : new ValueTask<IDuplexPipe>(new ConnectionDualPipe(conn)));
-///
-///
public static class IoxideTls
{
///
- /// onReactorStart hook: start the ring-native TLS service (OpenSSL context) on this reactor.
+ /// onReactorStart hook: start a ring-native TLS service (OpenSSL context) on this reactor.
///
public static void StartService(Reactor reactor, TlsOptions options) => TlsService.Start(reactor, options);
@@ -31,9 +20,22 @@ public static class IoxideTls
/// connectionFactory helper: TLS-terminate on the current reactor and
/// return the duplex pipe the engine serves over. Requires to have run.
///
- public static async ValueTask AcceptAsync(Connection conn)
+ public static async ValueTask AcceptAsync(TcpConnection conn)
+ => await AcceptAsync(conn, IoxideReactor.Current.GetService());
+
+ internal static async ValueTask AcceptAsync(TcpConnection conn, TlsService service)
{
- var session = await IoxideReactor.Current.GetService().AcceptAsync(conn);
- return new TlsDuplexPipe(conn, session);
+ var session = await service.AcceptAsync(conn);
+
+ return new TlsConnectionDualPipe(conn, session);
}
}
+
+internal sealed class TlsRegistry
+{
+ private readonly Dictionary _byPort = [];
+
+ public void Add(ushort port, TlsService service) => _byPort[port] = service;
+
+ public bool TryFor(ushort port, out TlsService service) => _byPort.TryGetValue(port, out service!);
+}
diff --git a/Engine/Ioxide/Tls/TlsDuplexPipe.cs b/Engine/Ioxide/Tls/TlsDuplexPipe.cs
deleted file mode 100644
index 0bf52cdd..00000000
--- a/Engine/Ioxide/Tls/TlsDuplexPipe.cs
+++ /dev/null
@@ -1,124 +0,0 @@
-using System.Buffers;
-using System.IO.Pipelines;
-
-using ioxide;
-using ioxide.tls;
-
-namespace GenHTTP.Engine.Ioxide;
-
-///
-/// Adapts a TLS connection to the duplex pipe GenHTTP serves over. Inbound: a pump reads raw recv
-/// slices, decrypts each via the , and writes the plaintext into a Pipe the
-/// engine reads. Outbound: the engine writes plaintext to the connection's writer and kTLS TX (enabled
-/// during the handshake) has the kernel produce the records — so no explicit encrypt step.
-///
-internal sealed class TlsDuplexPipe : IDuplexPipe, IAsyncDisposable
-{
- private readonly Connection _conn;
-
- private readonly TlsSession _tls;
-
- private readonly Pipe _inbound;
-
- private readonly ConnectionDualPipe _outer; // only its writer is used (plaintext + kTLS TX)
-
- private readonly CancellationTokenSource _cts;
-
- private readonly Task _pump;
-
- public TlsDuplexPipe(Connection conn, TlsSession session)
- {
- _conn = conn;
- _tls = session;
- _inbound = new Pipe();
- _outer = new ConnectionDualPipe(conn);
- _cts = new CancellationTokenSource();
- _pump = PumpAsync(_cts.Token);
- }
-
- public PipeReader Input => _inbound.Reader;
-
- public PipeWriter Output => _outer.Output;
-
- private async Task PumpAsync(CancellationToken ct)
- {
- var writer = _inbound.Writer;
-
- try
- {
- // The client's first request can ride in bundled with its Finished flight.
- var initial = _tls.DrainPlaintext();
- if (!initial.IsEmpty)
- {
- writer.Write(initial);
- await writer.FlushAsync(ct);
- }
-
- while (!ct.IsCancellationRequested)
- {
- var snapshot = await _conn.ReadAsync();
-
- var produced = false;
-
- unsafe
- {
- while (_conn.TryGetItem(snapshot, out var item))
- {
- if (item.HasBuffer)
- {
- var plain = _tls.Decrypt(item.Ptr, item.Len);
- if (!plain.IsEmpty)
- {
- writer.Write(plain);
- produced = true;
- }
- }
-
- _conn.ReturnBuffer(in item);
- }
- }
-
- _conn.ResetRead();
-
- if (produced)
- {
- var flush = await writer.FlushAsync(ct);
- if (flush.IsCompleted)
- {
- break;
- }
- }
-
- if (snapshot.IsClosed)
- {
- break;
- }
- }
- }
- catch
- {
- // connection fault / cancellation — the reader is completed in finally
- }
- finally
- {
- await writer.CompleteAsync();
- }
- }
-
- public async ValueTask DisposeAsync()
- {
- _cts.Cancel();
-
- try
- {
- await _pump;
- }
- catch
- {
- // ignore teardown faults
- }
-
- _tls.Dispose();
- _cts.Dispose();
- }
-}
diff --git a/Modules/IoxideFiles/AssetFreshness.cs b/Modules/IoxideFiles/AssetFreshness.cs
new file mode 100644
index 00000000..22ba712a
--- /dev/null
+++ b/Modules/IoxideFiles/AssetFreshness.cs
@@ -0,0 +1,46 @@
+using ioxide.file;
+
+namespace GenHTTP.Modules.IoxideFiles;
+
+///
+/// Whether a snapshot's asset still matches the file on disk, by size - the same check
+/// ioxide.file performed until 0.4.167, when it moved to trusting descriptors for the lifetime of
+/// a snapshot. Reproduced here so this module keeps serving edited files without a reload.
+///
+internal static class AssetFreshness
+{
+
+ ///
+ /// True when the descriptor can be trusted. and
+ /// describe the file as it is now, so a caller that gets
+ /// false can still serve the changed file rather than 404 it.
+ ///
+ internal static bool IsFresh(in AssetCache.Asset asset, out bool exists, out long currentLength)
+ {
+ try
+ {
+ var info = new FileInfo(asset.Path);
+
+ exists = info.Exists;
+ currentLength = exists ? info.Length : 0;
+
+ return exists && currentLength == asset.Length;
+ }
+ catch (IOException)
+ {
+ // Racing with a rename or delete: treat as gone rather than serve a stale descriptor.
+ exists = false;
+ currentLength = 0;
+
+ return false;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ exists = false;
+ currentLength = 0;
+
+ return false;
+ }
+ }
+
+}
diff --git a/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj b/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj
index 5173c738..5722f1a7 100644
--- a/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj
+++ b/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj
@@ -19,7 +19,7 @@
-
+
diff --git a/Modules/IoxideFiles/IoxideAssetContent.cs b/Modules/IoxideFiles/IoxideAssetContent.cs
index 3f57ff8a..448277b3 100644
--- a/Modules/IoxideFiles/IoxideAssetContent.cs
+++ b/Modules/IoxideFiles/IoxideAssetContent.cs
@@ -1,6 +1,7 @@
using System.Buffers;
using Microsoft.Win32.SafeHandles;
+
using GenHTTP.Api.Protocol;
using GenHTTP.Engine.Ioxide;
@@ -14,6 +15,10 @@ namespace GenHTTP.Modules.IoxideFiles;
/// Writes one asset's body to the response sink, flush-disciplined so it never stages more than
/// bytes into the ioxide write slab at once. Re-resolves the asset under its own
/// lease, so nothing is held across an await.
+///
+/// The body is read positionally off the ring through a per-reactor pool.
+/// It cannot use the connection's write slab directly - TcpConnection.ReadFileAsync reads
+/// into that slab, and this writes into GenHTTP's response sink instead - so the copy stays.
///
public sealed class IoxideAssetContent(StaticAssets assets, string path, long length, ContentType contentType, ReadOnlyMemory? contentEncoding) : IResponseContent
{
@@ -39,24 +44,19 @@ public async ValueTask WriteAsync(IResponseSink sink)
return; // vanished between header and body (rare)
}
- if (AssetCache.IsFresh(asset, out var exists, out _))
+ // ioxide.file bakes no HTTP any more, so there is no cached response to write - the body
+ // is always read off the ring. The only question is WHICH file: the snapshot's descriptor
+ // when it still matches disk, or a fresh open when the file changed underneath it. The
+ // handler resolved the same question to set Content-Length, and `length` carries its
+ // answer, so the two cannot disagree.
+ if (AssetFreshness.IsFresh(asset, out var exists, out _))
{
- if (asset.Response != 0)
- {
- // Fresh + baked: write just the body (GenHTTP framed the header). The baked block is
- // header+body in native memory; the body is the trailing asset.Length bytes.
- await WriteNative(sink, asset.Response + (nint)(asset.ResponseLength - asset.Length), asset.Length);
- }
- else
- {
- // Fresh but too large to bake: read off the ring from the cached fd.
- await WriteFromDisk(sink, asset.Fd, length);
- }
+ await WriteFromDisk(sink, asset.Fd, length);
}
else if (exists)
{
- // Changed on disk (edit or atomic rename): open the current path fresh so a rename resolves
- // to the new inode, not the cached fd.
+ // Changed on disk (edit or atomic rename): open the current path so a rename resolves
+ // to the new inode rather than the descriptor the snapshot still holds.
await WriteChanged(sink, asset.Path, length);
}
}
@@ -80,6 +80,29 @@ private static async ValueTask WriteNative(IResponseSink sink, nint data, long l
private static unsafe void WriteChunk(IBufferWriter writer, nint data, int len)
=> writer.Write(new ReadOnlySpan((byte*)data, len));
+ private static async ValueTask WriteChanged(IResponseSink sink, string filePath, long len)
+ {
+ SafeFileHandle handle;
+
+ try
+ {
+ handle = File.OpenHandle(filePath);
+ }
+ catch
+ {
+ return; // raced with a delete
+ }
+
+ try
+ {
+ await WriteFromDisk(sink, (int)handle.DangerousGetHandle(), len);
+ }
+ finally
+ {
+ handle.Dispose();
+ }
+ }
+
private static async ValueTask WriteFromDisk(IResponseSink sink, int fd, long len)
{
var readers = RentPool();
@@ -108,29 +131,6 @@ private static async ValueTask WriteFromDisk(IResponseSink sink, int fd, long le
}
}
- private static async ValueTask WriteChanged(IResponseSink sink, string filePath, long len)
- {
- SafeFileHandle handle;
-
- try
- {
- handle = File.OpenHandle(filePath);
- }
- catch
- {
- return; // raced with a delete
- }
-
- try
- {
- await WriteFromDisk(sink, (int)handle.DangerousGetHandle(), len);
- }
- finally
- {
- handle.Dispose();
- }
- }
-
// The AssetReader pool is per-reactor; ioxide's GetService throws if absent, so create-and-self-
// register on first use on this reactor.
private static RingPool RentPool()
diff --git a/Modules/IoxideFiles/IoxideFilesHandler.cs b/Modules/IoxideFiles/IoxideFilesHandler.cs
index 5563a7f6..8db2de9a 100644
--- a/Modules/IoxideFiles/IoxideFilesHandler.cs
+++ b/Modules/IoxideFiles/IoxideFilesHandler.cs
@@ -70,7 +70,9 @@ internal IoxideFilesHandler(StaticAssets assets)
return default; // raced away
}
- if (AssetCache.IsFresh(asset, out var exists, out var currentSize))
+ // This length becomes Content-Length, so it has to be the size the body writer will
+ // actually produce - hence resolving freshness here and not only at write time.
+ if (AssetFreshness.IsFresh(asset, out var exists, out var currentSize))
{
length = asset.Length;
}
diff --git a/Playground/Program.cs b/Playground/Program.cs
index 8333f987..925868c3 100644
--- a/Playground/Program.cs
+++ b/Playground/Program.cs
@@ -1,9 +1,36 @@
-using GenHTTP.Engine.Internal;
-
-using GenHTTP.Modules.IO;
-
-var app = Content.From(Resource.FromString("Hello World!"));
-
-await Host.Create()
- .Handler(app)
- .RunAsync();
+using GenHTTP.Engine.Ioxide;
+
+using GenHTTP.Modules.Files;
+using GenHTTP.Modules.IO;
+using GenHTTP.Modules.Layouting;
+
+// The namespace and the class share a name, so the class needs an alias to be reachable.
+using IoxideFilesModule = GenHTTP.Modules.IoxideFiles.IoxideFiles;
+
+// Two static handlers over the SAME directory, so the difference can be priced rather than argued:
+//
+// /ring/* IoxideFiles - ioxide.file opens every file once, shares the descriptors across
+// reactors and reads them positionally off the io_uring ring. Nothing is cached in
+// memory, so resident size stays flat whatever the asset set weighs.
+// /disk/* GenHTTP's built-in Files module, for comparison.
+//
+// GENHTTP_STATIC picks the directory; without it neither route is mounted.
+//
+// GENHTTP_STATIC=/srv/www dotnet run -c Release --project Playground
+// wrk -t8 -c64 -d8s http://127.0.0.1:8080/ring/asset.bin
+// wrk -t8 -c64 -d8s http://127.0.0.1:8080/disk/asset.bin
+
+var staticDir = Environment.GetEnvironmentVariable("GENHTTP_STATIC");
+
+var app = Layout.Create()
+ .Add("ok", Content.From(Resource.FromString("ok")));
+
+if (staticDir != null && Directory.Exists(staticDir))
+{
+ app = app.Add("ring", IoxideFilesModule.From(staticDir))
+ .Add("disk", Assets.From(staticDir));
+}
+
+await Host.Create()
+ .Handler(app)
+ .RunAsync();