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();