diff --git a/src/clients/ioxide.file/ioxide.file.csproj b/src/clients/ioxide.file/ioxide.file.csproj
index 329e4091..5c245347 100644
--- a/src/clients/ioxide.file/ioxide.file.csproj
+++ b/src/clients/ioxide.file/ioxide.file.csproj
@@ -8,7 +8,7 @@
ioxide.file
ioxide.file
- 0.5.194
+ 0.6.208
MDA2AV
File serving for the ioxide io_uring runtime: immutable asset snapshots with baked responses, pooled positional ring reads, atomic reloads.
MIT
diff --git a/src/clients/ioxide.httpclient/ioxide.httpclient.csproj b/src/clients/ioxide.httpclient/ioxide.httpclient.csproj
index ab7c5bc0..d75920d9 100644
--- a/src/clients/ioxide.httpclient/ioxide.httpclient.csproj
+++ b/src/clients/ioxide.httpclient/ioxide.httpclient.csproj
@@ -8,7 +8,7 @@
ioxide.httpclient
ioxide.httpclient
- 0.5.194
+ 0.6.208
MDA2AV
The ring-native HTTP/1.1 client for the ioxide io_uring runtime - the upstream leg between a proxy and an origin. Connections are opened on the reactor thread that will use them, so a request never crosses a thread on its way out or back, and every response resumes the awaiting handler inline on its own reactor. Includes client-side TLS (SNI, ALPN, certificate verification and client certificates for mutual TLS) for https:// origins. Depends on ioxide core alone: no protocol package, no native asset.
MIT
diff --git a/src/clients/ioxide.pg/ioxide.pg.csproj b/src/clients/ioxide.pg/ioxide.pg.csproj
index 1fcc9ee7..0a71af6b 100644
--- a/src/clients/ioxide.pg/ioxide.pg.csproj
+++ b/src/clients/ioxide.pg/ioxide.pg.csproj
@@ -8,7 +8,7 @@
ioxide.pg
ioxide.pg
- 0.5.194
+ 0.6.208
MDA2AV
Postgres driver for the ioxide io_uring runtime: pooled ring-native connections per reactor, ring-native connect and handshake, inline completion resume.
MIT
diff --git a/src/clients/ioxide.redis/ioxide.redis.csproj b/src/clients/ioxide.redis/ioxide.redis.csproj
index dc2a1d5d..cff0bc51 100644
--- a/src/clients/ioxide.redis/ioxide.redis.csproj
+++ b/src/clients/ioxide.redis/ioxide.redis.csproj
@@ -8,7 +8,7 @@
ioxide.redis
ioxide.redis
- 0.5.194
+ 0.6.208
MDA2AV
Redis client for the ioxide io_uring runtime: pooled ring-native connections per reactor, full RESP2 protocol, a generic command API plus typed helpers (strings, keys, hashes, lists, sets, sorted sets, pub/sub, transactions, scripting), and pipelining. Inline completion resume.
MIT
diff --git a/src/ioxide/ioxide.csproj b/src/ioxide/ioxide.csproj
index 8a6cf070..bc9a22ac 100644
--- a/src/ioxide/ioxide.csproj
+++ b/src/ioxide/ioxide.csproj
@@ -8,7 +8,7 @@
ioxide
ioxide
- 0.5.194
+ 0.6.208
MDA2AV
A shared-nothing io_uring runtime for .NET: one ring per reactor thread, inline completions, zero native dependencies. The engine - reactor, connection, and the IRingHost client seam. Includes TLS termination: the OpenSSL handshake driven over the ring, then kernel TLS (kTLS) transmit offload, so handlers keep writing plaintext. TLS needs OpenSSL 3 and the Linux tls module; nothing else does, and neither is loaded unless you use it.
MIT
diff --git a/src/protocols/ioxide.http2/ioxide.http2.csproj b/src/protocols/ioxide.http2/ioxide.http2.csproj
index 59938776..7ec0f1fd 100644
--- a/src/protocols/ioxide.http2/ioxide.http2.csproj
+++ b/src/protocols/ioxide.http2/ioxide.http2.csproj
@@ -8,7 +8,7 @@
ioxide.http2
ioxide.http2
- 0.5.194
+ 0.6.208
MDA2AV
Pure-C# HTTP/2 for the ioxide io_uring runtime: framing, HPACK (static and dynamic tables, Huffman) and flow control, with zero native code. Serves h2c with prior knowledge and h2 over TLS by ALPN, buffered or streamed in either direction.
MIT
diff --git a/src/protocols/ioxide.http3/Http3Connection.cs b/src/protocols/ioxide.http3/Http3Connection.cs
index 846ac453..995d9047 100644
--- a/src/protocols/ioxide.http3/Http3Connection.cs
+++ b/src/protocols/ioxide.http3/Http3Connection.cs
@@ -23,6 +23,10 @@ public sealed partial class Http3Connection
// RFC 9114 section 8.1. The code a protocol error closes the connection with; the peer is
// entitled to know WHY it was dropped, and until now it was told nothing at all.
private const ulong H3GeneralProtocolError = 0x0101;
+ // RFC 9114 8.1 draws the line by WHY the frame is wrong, and the two are not interchangeable:
+ // UNEXPECTED is a frame that is not permitted in this state or on this stream, ERROR is one
+ // whose layout or size is invalid. A peer debugging its own framing is told different things.
+ private const ulong H3FrameUnexpected = 0x0105;
private const ulong H3FrameError = 0x0106;
private const ulong H3ExcessiveLoad = 0x0107;
private const ulong QpackDecompressionFailed = 0x0200;
@@ -301,7 +305,7 @@ private void FeedRequest(long sid, ReqStream rs, ReadOnlySpan data, bool f
{
if (!rs.HeadersDone)
{
- Fatal("DATA before HEADERS", H3FrameError);
+ Fatal("DATA before HEADERS", H3FrameUnexpected);
return;
}
rs.State = len == 0 ? ParseState.FrameHeader : ParseState.DataPayload;
@@ -326,7 +330,7 @@ private void FeedRequest(long sid, ReqStream rs, ReadOnlySpan data, bool f
}
else if (type is 0x3 or 0x4 or 0x5 or 0x7 or 0xD)
{
- Fatal($"frame 0x{type:x} unexpected on a request stream", H3FrameError);
+ Fatal($"frame 0x{type:x} unexpected on a request stream", H3FrameUnexpected);
return;
}
else
diff --git a/src/protocols/ioxide.http3/ioxide.http3.csproj b/src/protocols/ioxide.http3/ioxide.http3.csproj
index 95978366..20e4848d 100644
--- a/src/protocols/ioxide.http3/ioxide.http3.csproj
+++ b/src/protocols/ioxide.http3/ioxide.http3.csproj
@@ -8,7 +8,7 @@
ioxide.http3
ioxide.http3
- 0.5.194
+ 0.6.208
MDA2AV
Pure C# HTTP/3 for the ioxide io_uring runtime: frame parsing, QPACK (static table + Huffman) and request dispatch with zero native dependencies. Rides any QuicConnection via its stream read surface - engine-agnostic, drop-in alternative to ioxide.nghttp3.
MIT
diff --git a/src/protocols/ioxide.nghttp2/ioxide.nghttp2.csproj b/src/protocols/ioxide.nghttp2/ioxide.nghttp2.csproj
index 04ac0677..c9b34348 100644
--- a/src/protocols/ioxide.nghttp2/ioxide.nghttp2.csproj
+++ b/src/protocols/ioxide.nghttp2/ioxide.nghttp2.csproj
@@ -8,7 +8,7 @@
ioxide.nghttp2
ioxide.nghttp2
- 0.5.194
+ 0.6.208
MDA2AV
HTTP/2 for the ioxide io_uring runtime: framing, HPACK and flow control from nghttp2, statically linked behind a small shim with no external dependencies beyond libc. Serves HTTP/2 over any TcpConnection - h2c with prior knowledge, or h2 over TLS via ALPN. nghttp2 is sans-I/O, so ioxide keeps the ring and the loop.
MIT
diff --git a/src/protocols/ioxide.nghttp3/Connection/Nghttp3Connection.Callbacks.cs b/src/protocols/ioxide.nghttp3/Connection/Nghttp3Connection.Callbacks.cs
index adb1320a..a9777e38 100644
--- a/src/protocols/ioxide.nghttp3/Connection/Nghttp3Connection.Callbacks.cs
+++ b/src/protocols/ioxide.nghttp3/Connection/Nghttp3Connection.Callbacks.cs
@@ -32,7 +32,7 @@ private static unsafe void Fault(void* user, Exception e)
{
Nghttp3Connection connection = From(user);
connection._callbackFault ??= e;
- connection._protocolFailed = true;
+ connection.FailInternal();
Console.Error.WriteLine($"[ioxide.nghttp3] callback faulted, failing the connection: {e}");
}
catch
diff --git a/src/protocols/ioxide.nghttp3/Connection/Nghttp3Connection.Egress.cs b/src/protocols/ioxide.nghttp3/Connection/Nghttp3Connection.Egress.cs
index bca8d82a..c5cb2129 100644
--- a/src/protocols/ioxide.nghttp3/Connection/Nghttp3Connection.Egress.cs
+++ b/src/protocols/ioxide.nghttp3/Connection/Nghttp3Connection.Egress.cs
@@ -26,7 +26,7 @@ private unsafe void QueueResponse(long streamId, Nghttp3Response response)
if (submitResult != 0)
{
Console.Error.WriteLine($"[ioxide.nghttp3] submit_response failed: {Nghttp3.StrError(submitResult)}");
- _protocolFailed = true;
+ FailProtocol(submitResult);
}
}
@@ -56,7 +56,7 @@ private unsafe bool PumpEgress()
if (producedBytes < 0)
{
Console.Error.WriteLine($"[ioxide.nghttp3] writev failed: {Nghttp3.StrError((int)producedBytes)}");
- _protocolFailed = true;
+ FailProtocol((int)producedBytes);
return producedAnything;
}
if (producedBytes > 0)
diff --git a/src/protocols/ioxide.nghttp3/Connection/Nghttp3Connection.PushToEngine.cs b/src/protocols/ioxide.nghttp3/Connection/Nghttp3Connection.PushToEngine.cs
index ac794abd..bf497268 100644
--- a/src/protocols/ioxide.nghttp3/Connection/Nghttp3Connection.PushToEngine.cs
+++ b/src/protocols/ioxide.nghttp3/Connection/Nghttp3Connection.PushToEngine.cs
@@ -46,7 +46,7 @@ private unsafe void PushToEngine(in QuicRecvRing.Delivery item)
if (eventResult < 0)
{
Console.Error.WriteLine($"[ioxide.nghttp3] stream {item.Kind} handling failed: {Nghttp3.StrError(eventResult)}");
- _protocolFailed = true;
+ FailProtocol(eventResult);
}
return;
@@ -62,7 +62,7 @@ private unsafe void PushToEngine(in QuicRecvRing.Delivery item)
if (readResult < 0)
{
Console.Error.WriteLine($"[ioxide.nghttp3] read_stream failed: {Nghttp3.StrError((int)readResult)}");
- _protocolFailed = true;
+ FailProtocol((int)readResult);
return;
}
diff --git a/src/protocols/ioxide.nghttp3/Connection/Nghttp3Connection.RunBuffered.cs b/src/protocols/ioxide.nghttp3/Connection/Nghttp3Connection.RunBuffered.cs
index aea61c91..d2d206d6 100644
--- a/src/protocols/ioxide.nghttp3/Connection/Nghttp3Connection.RunBuffered.cs
+++ b/src/protocols/ioxide.nghttp3/Connection/Nghttp3Connection.RunBuffered.cs
@@ -18,7 +18,7 @@ public async Task RunBufferedAsync(Func handler
if (_nghttp3Handle == 0 && !_protocolFailed && !TrySetup())
{
- _protocolFailed = true;
+ FailInternal();
}
while (_quicConnection.TryGetDelivery(in snapshot, out QuicRecvRing.Delivery item))
@@ -46,6 +46,11 @@ public async Task RunBufferedAsync(Func handler
}
finally
{
+ // Before letting go, because letting go is all the transport sees: DecRef neither
+ // closes nor unregisters, so a connection dropped without a code stays routable until
+ // the idle sweep while the client waits on a request that will never be answered.
+ CloseWithPeerCode();
+
_quicConnection.DecRef();
Dispose();
}
@@ -61,7 +66,7 @@ public async Task RunBufferedAsync(Func
+ /// A failure nghttp3 reported, and the h3 code it means. The mapping is nghttp3's own
+ /// (nghttp3_err_infer_quic_app_error_code) rather than a table kept here: it owns which
+ /// of its errors are H3_FRAME_ERROR, H3_MESSAGE_ERROR or QPACK_DECOMPRESSION_FAILED, and
+ /// anything it does not recognise becomes H3_INTERNAL_ERROR.
+ ///
+ private void FailProtocol(int libError)
+ {
+ // First failure wins - later ones are usually consequences of it, and the peer can only be
+ // told once.
+ _peerCode ??= Nghttp3.ih3_app_error_code(libError);
+ _protocolFailed = true;
+ }
+
+ ///
+ /// A failure of ours rather than of the protocol - a control stream that would not open, a
+ /// callback that threw. H3_INTERNAL_ERROR is the honest code for it.
+ ///
+ private void FailInternal()
+ {
+ _peerCode ??= H3InternalError;
+ _protocolFailed = true;
+ }
+
+ ///
+ /// Tell the peer why, if there is a why. Called from every run loop's finally, where the
+ /// connection is being let go: without it the transport keeps the connection registered and
+ /// routable, and the client's request never completes.
+ ///
+ private void CloseWithPeerCode()
+ {
+ if (_peerCode is ulong code)
+ {
+ _quicConnection.Close(code);
+ }
+ }
+
private readonly Dictionary _requests = new();
private readonly List _readyStreamIds = [];
private readonly byte[] _egress = new byte[16 * 1024];
@@ -101,7 +151,7 @@ public void Shutdown()
if (shutdownResult != 0)
{
Console.Error.WriteLine($"[ioxide.nghttp3] shutdown failed: {Nghttp3.StrError(shutdownResult)}");
- _protocolFailed = true;
+ FailProtocol(shutdownResult);
return;
}
PumpEgress(); // the GOAWAY leaves now, ahead of any in-flight responses
diff --git a/src/protocols/ioxide.nghttp3/Interop/Nghttp3.cs b/src/protocols/ioxide.nghttp3/Interop/Nghttp3.cs
index c49bcdce..9d32279c 100644
--- a/src/protocols/ioxide.nghttp3/Interop/Nghttp3.cs
+++ b/src/protocols/ioxide.nghttp3/Interop/Nghttp3.cs
@@ -76,6 +76,11 @@ internal struct Callbacks
/// block). Unknown/already-closed ids (e.g. uni streams) are tolerated and return 0.
[DllImport(Lib)] internal static extern int ih3_close_stream(nint connection, long streamId, ulong appError);
+ /// The h3 application error code a library error means. nghttp3 owns this mapping -
+ /// which of its errors are H3_FRAME_ERROR, H3_MESSAGE_ERROR, QPACK_DECOMPRESSION_FAILED - so a
+ /// connection that must tell its peer why it died asks rather than keeping a table.
+ [DllImport(Lib)] internal static extern ulong ih3_app_error_code(int libError);
+
// --- client side (ioxide.httpclient) ------------------------------------------------------
/// Create the client-side nghttp3 connection; same event surface as the server one,
diff --git a/src/protocols/ioxide.nghttp3/ioxide.nghttp3.csproj b/src/protocols/ioxide.nghttp3/ioxide.nghttp3.csproj
index 799c38a8..ebca7231 100644
--- a/src/protocols/ioxide.nghttp3/ioxide.nghttp3.csproj
+++ b/src/protocols/ioxide.nghttp3/ioxide.nghttp3.csproj
@@ -8,7 +8,7 @@
ioxide.nghttp3
ioxide.nghttp3
- 0.5.194
+ 0.6.208
MDA2AV
HTTP/3 layer for the ioxide io_uring runtime: nghttp3 (H3 + QPACK) bundled as a single self-contained native library with no external dependencies. Rides any QuicConnection via its stream read surface - engine-agnostic, no ioxide.ngtcp2 dependency.
MIT
diff --git a/src/protocols/ioxide.nghttp3/native/ioxide_nghttp3_shim.c b/src/protocols/ioxide.nghttp3/native/ioxide_nghttp3_shim.c
index 89c3a66a..929643f4 100644
--- a/src/protocols/ioxide.nghttp3/native/ioxide_nghttp3_shim.c
+++ b/src/protocols/ioxide.nghttp3/native/ioxide_nghttp3_shim.c
@@ -517,6 +517,16 @@ int ih3_shutdown_stream_write(ih3_conn *c, int64_t stream_id)
return 0;
}
+/* The h3 application error code a library error means, straight from nghttp3 rather than from a
+ * table of our own: it owns the NGHTTP3_ERR_* -> RFC 9114 mapping and knows which of its errors are
+ * H3_FRAME_ERROR, H3_MESSAGE_ERROR, QPACK_DECOMPRESSION_FAILED and so on. Anything it does not
+ * recognise becomes H3_INTERNAL_ERROR, which is the right answer for "we failed and cannot say
+ * why". */
+uint64_t ih3_app_error_code(int liberr)
+{
+ return nghttp3_err_infer_quic_app_error_code(liberr);
+}
+
int ih3_close_stream(ih3_conn *c, int64_t stream_id, uint64_t app_error)
{
int rv = nghttp3_conn_close_stream(c->conn, stream_id, app_error);
diff --git a/src/protocols/ioxide.nghttp3/runtimes/linux-x64/native/libioxide_nghttp3.so b/src/protocols/ioxide.nghttp3/runtimes/linux-x64/native/libioxide_nghttp3.so
index eb52061d..421dbbdd 100755
Binary files a/src/protocols/ioxide.nghttp3/runtimes/linux-x64/native/libioxide_nghttp3.so and b/src/protocols/ioxide.nghttp3/runtimes/linux-x64/native/libioxide_nghttp3.so differ
diff --git a/src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj b/src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj
index 79831378..2cf01efb 100644
--- a/src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj
+++ b/src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj
@@ -8,7 +8,7 @@
ioxide.ngtcp2
ioxide.ngtcp2
- 0.5.194
+ 0.6.208
MDA2AV
QUIC engine for the ioxide io_uring runtime: ngtcp2 + picotls bundled as a single self-contained native library (only system dependency: libcrypto.so.3 / OpenSSL 3.x). Plugs into the reactor's QUIC transport via QuicConnection. Server side; engine bindings in progress.
MIT
diff --git a/src/serving/ioxide.Kestrel/ioxide.Kestrel.csproj b/src/serving/ioxide.Kestrel/ioxide.Kestrel.csproj
index 8d513d98..30a39d6e 100644
--- a/src/serving/ioxide.Kestrel/ioxide.Kestrel.csproj
+++ b/src/serving/ioxide.Kestrel/ioxide.Kestrel.csproj
@@ -8,7 +8,7 @@
ioxide.Kestrel
ioxide.Kestrel
- 0.5.194
+ 0.6.208
MDA2AV
ASP.NET Core Kestrel transport backed by the ioxide io_uring runtime: one reactor (ring) per core, SO_REUSEPORT load-balanced, with Kestrel's HTTP request loop pinned to the reactor thread. Drop-in via UseIoxide().
MIT
diff --git a/tests/Ioxide.Tests.E2E/Protocols/H3ErrorCodeTests.cs b/tests/Ioxide.Tests.E2E/Protocols/H3ErrorCodeTests.cs
index bcfdc076..e69e4cca 100644
--- a/tests/Ioxide.Tests.E2E/Protocols/H3ErrorCodeTests.cs
+++ b/tests/Ioxide.Tests.E2E/Protocols/H3ErrorCodeTests.cs
@@ -35,6 +35,7 @@ internal static class H3ErrorCodeTests
private const ulong H3NoError = 0x0100;
private const ulong H3GeneralProtocolError = 0x0101;
private const ulong H3ClosedCriticalStream = 0x0104;
+ private const ulong H3FrameUnexpected = 0x0105;
private const ulong H3FrameError = 0x0106;
private const ulong H3ExcessiveLoad = 0x0107;
private const ulong QpackDecompressionFailed = 0x0200;
@@ -73,17 +74,25 @@ private static void RegisterCodeSeam(Runner runner)
"the response (a HEADERS frame, fin) should have gone out on the request stream");
});
- runner.Test("http3/codes: DATA before HEADERS closes with H3_FRAME_ERROR", () =>
+ runner.Test("http3/codes: DATA before HEADERS closes with H3_FRAME_UNEXPECTED", () =>
{
+ // Named by the RFC rather than inferred: 9114 section 4.1 says receipt of an invalid
+ // SEQUENCE of frames is H3_FRAME_UNEXPECTED and calls out "a DATA frame before any
+ // HEADERS frame" as the example. The distinction in section 8.1 is why the frame is
+ // wrong, not how badly - UNEXPECTED is a frame not permitted in this state or on this
+ // stream, ERROR is one whose layout or size is invalid. This frame is well formed; it
+ // is merely too early, and a peer debugging its own framing is told two different
+ // things by the two codes. This test pinned 0x0106 until the difference was noticed.
var quic = new RecordingQuic();
Task run = StartPure(quic, out Served served, closeTransport: false,
(0, new byte[] { 0x00, 0x03, 0x61, 0x62, 0x63 }, false)); // DATA(len 3) as the first frame
Assert.True(run.IsCompleted, "a fatal protocol error must end the run loop");
Assert.Equal(0, served.Count);
- AssertClosedWith(quic, H3FrameError, "H3_FRAME_ERROR");
+ AssertClosedWith(quic, H3FrameUnexpected, "H3_FRAME_UNEXPECTED");
});
+
runner.Test("http3/codes: an empty HEADERS frame closes with H3_FRAME_ERROR", () =>
{
var quic = new RecordingQuic();
@@ -95,15 +104,18 @@ private static void RegisterCodeSeam(Runner runner)
AssertClosedWith(quic, H3FrameError, "H3_FRAME_ERROR");
});
- runner.Test("http3/codes: SETTINGS on a request stream closes with H3_FRAME_ERROR", () =>
+ runner.Test("http3/codes: SETTINGS on a request stream closes with H3_FRAME_UNEXPECTED", () =>
{
+ // The other half of the rule above. SETTINGS is a perfectly legal frame - on the
+ // CONTROL stream. Arriving on a request stream it is not malformed, it is not permitted
+ // here, which RFC 9114 section 8.1 gives a different code for. This pinned 0x0106 too.
var quic = new RecordingQuic();
Task run = StartPure(quic, out Served served, closeTransport: false,
(0, SettingsOnRequestStream, false));
Assert.True(run.IsCompleted, "a fatal protocol error must end the run loop");
Assert.Equal(0, served.Count);
- AssertClosedWith(quic, H3FrameError, "H3_FRAME_ERROR");
+ AssertClosedWith(quic, H3FrameUnexpected, "H3_FRAME_UNEXPECTED");
});
runner.Test("http3/codes: a stream ending mid-frame closes with H3_FRAME_ERROR", () =>
@@ -266,7 +278,7 @@ private static void RegisterNghttp3(Runner runner)
Assert.Equal(0, served);
});
- runner.Pending("h3/codes: nghttp3 - a protocol error tells the peer why", () =>
+ runner.Test("h3/codes: nghttp3 - a protocol error tells the peer why", () =>
{
var quic = new RecordingQuic();
Assert.True(quic.EnqueueStreamData(0, SettingsOnRequestStream, false), "the recv ring rejected the item");
@@ -279,9 +291,7 @@ private static void RegisterNghttp3(Runner runner)
+ (quic.ClosedWith is null
? "Close was never called - the connection sits registered until the idle sweep"
: $"it closed with 0x{quic.ClosedWith:x}"));
- }, "the defect the pure-C# stack just fixed, still live here: PushToEngine sets _protocolFailed "
- + "and the run loop exits without ever calling QuicConnection.Close, so no code reaches the "
- + "peer and the connection stays routable until the transport's 60 s idle sweep");
+ });
}
// --- helpers ---------------------------------------------------------------------------------