diff --git a/src/protocols/ioxide.nghttp2/Interop/Nghttp2.cs b/src/protocols/ioxide.nghttp2/Interop/Nghttp2.cs index 4f1a3ad8..8869eea5 100644 --- a/src/protocols/ioxide.nghttp2/Interop/Nghttp2.cs +++ b/src/protocols/ioxide.nghttp2/Interop/Nghttp2.cs @@ -34,6 +34,8 @@ internal struct Callbacks /// validated out of . With non-zero nghttp2 /// sends no WINDOW_UPDATE for DATA by itself - every byte handed to OnData is credited once, /// through - and OnStreamError reports every close, NO_ERROR included. + /// Without it, OnStreamError reports every close but the normal end of a stream: NO_ERROR once + /// the response's END_STREAM has gone out. /// Returns the handle, or 0 on failure. [DllImport(Lib)] internal static extern nint ih2_server_new(Callbacks callbacks, void* user, int manualFlow); diff --git a/src/protocols/ioxide.nghttp2/native/ioxide_nghttp2_shim.c b/src/protocols/ioxide.nghttp2/native/ioxide_nghttp2_shim.c index 43a8e919..63b3a366 100644 --- a/src/protocols/ioxide.nghttp2/native/ioxide_nghttp2_shim.c +++ b/src/protocols/ioxide.nghttp2/native/ioxide_nghttp2_shim.c @@ -163,13 +163,12 @@ static int on_data_chunk_recv(nghttp2_session *session, uint8_t flags, int32_t s static int on_stream_close(nghttp2_session *session, int32_t stream_id, uint32_t error_code, void *user_data) { - (void)session; ih2_conn *c = user_data; - /* NO_ERROR here is the normal end of a stream whose response already completed, so only a - * real error is worth waking the managed side for - unless it reads request bodies itself: - * a peer's RST_STREAM(NO_ERROR) ends one of those too. */ - if ((error_code != NGHTTP2_NO_ERROR || c->manual_flow) && c->cbs.on_stream_error != NULL) { + /* NO_ERROR before our END_STREAM went out is a peer's reset, not the normal end; manual flow hears every close. */ + if ((error_code != NGHTTP2_NO_ERROR || c->manual_flow || + nghttp2_session_get_stream_local_close(session, stream_id) == 0) && + c->cbs.on_stream_error != NULL) { c->cbs.on_stream_error(c->user, stream_id, error_code); } diff --git a/src/protocols/ioxide.nghttp2/runtimes/linux-arm64/native/libioxide_nghttp2.so b/src/protocols/ioxide.nghttp2/runtimes/linux-arm64/native/libioxide_nghttp2.so index 3edead35..fdd32b6d 100755 Binary files a/src/protocols/ioxide.nghttp2/runtimes/linux-arm64/native/libioxide_nghttp2.so and b/src/protocols/ioxide.nghttp2/runtimes/linux-arm64/native/libioxide_nghttp2.so differ diff --git a/src/protocols/ioxide.nghttp2/runtimes/linux-x64/native/libioxide_nghttp2.so b/src/protocols/ioxide.nghttp2/runtimes/linux-x64/native/libioxide_nghttp2.so index 81473db1..10a0b63e 100755 Binary files a/src/protocols/ioxide.nghttp2/runtimes/linux-x64/native/libioxide_nghttp2.so and b/src/protocols/ioxide.nghttp2/runtimes/linux-x64/native/libioxide_nghttp2.so differ diff --git a/tests/Ioxide.Tests.Unit/Nghttp2StreamCloseTests.cs b/tests/Ioxide.Tests.Unit/Nghttp2StreamCloseTests.cs new file mode 100644 index 00000000..ac22f746 --- /dev/null +++ b/tests/Ioxide.Tests.Unit/Nghttp2StreamCloseTests.cs @@ -0,0 +1,98 @@ +using System.Collections; +using System.IO.Pipelines; +using System.Reflection; +using ioxide.nghttp2; + +namespace Ioxide.Tests; + +/// A request its peer resets before it ends leaves the connection, whatever code the reset carries. +internal static class Nghttp2StreamCloseTests +{ + public static void Register(Runner runner) + { + foreach ((uint code, string name) in new[] { (0u, "NO_ERROR"), (8u, "CANCEL") }) + { + uint error = code; + runner.Test($"nghttp2: a request its peer resets with {name} before it ends is released", () => + { + using var client = new StrictClient(); + var connection = new Nghttp2Connection(client); + + int served = 0; + Task run = connection.RunAsync(_ => + { + served++; + return Nghttp2Response.Text("ok"); + }); + + client.SendRequests(); // preface and SETTINGS + client.Drain(); + + // A POST's headers without END_STREAM, so its body is still owed, and then the reset. + client.Feed([.. Headers(1, post: true, endStream: false), .. Http2FlowControlTests.RstStream(1, error)]); + client.Drain(); + Assert.Equal(0, Pending(connection)); + Assert.Equal(0, served); + + client.Feed(Headers(3, post: false, endStream: true)); // and the connection serves on + client.Drain(); + Assert.Equal(1, served); + + client.Close(run); + connection.Dispose(); + }); + } + + // Its handler is dispatched and parked, with no headers out, so nothing it could send would ever call back. + foreach (int staged in new[] { 128 * 1024, 0 }) + { + int bytes = staged; + runner.Test($"nghttp2: a streamed response its peer resets with NO_ERROR hears the peer gone at a flush of {bytes} B", () => + { + using var client = new StrictClient(); + var connection = new Nghttp2Connection(client); + + var go = new TaskCompletionSource(); + var outcome = new TaskCompletionSource(); + Task run = connection.RunAsync(async (_, writer) => + { + await go.Task; + if (bytes > 0) // past the 64 KiB a flush may run ahead of nghttp2 + { + writer.GetSpan(bytes)[..bytes].Fill((byte)'x'); + writer.Advance(bytes); + } + FlushResult flushed = await writer.FlushAsync(); + outcome.SetResult(flushed.IsCompleted ? "peer gone" : "flushed"); + }); + + client.SendRequests(); // preface and SETTINGS + client.Drain(); + client.Feed(Headers(1, post: false, endStream: true)); + client.Drain(); + client.Feed(Http2FlowControlTests.RstStream(1, 0)); + client.Drain(); + go.SetResult(); + client.Drain(); + + Assert.True(outcome.Task.IsCompleted, "the flush of a stream its peer reset never returned"); + Assert.Equal("peer gone", outcome.Task.Result); + client.Close(run); + connection.Dispose(); + }); + } + } + + // :method, :scheme http, :path /, and an :authority, which nghttp2 requires. + private static byte[] Headers(int streamId, bool post, bool endStream) + { + byte[] block = [post ? (byte)0x83 : (byte)0x82, 0x86, 0x84, 0x01, 9, .. "a.example"u8.ToArray()]; + return Http2FlowControlTests.FrameBytes(0x1, endStream ? (byte)0x5 : (byte)0x4, streamId, block); + } + + // The requests still being assembled. Nothing outside the connection can see them, so a leak shows nowhere else. + private static int Pending(Nghttp2Connection connection) + => ((IDictionary)typeof(Nghttp2Connection) + .GetField("_pending", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(connection)!).Count; +} diff --git a/tests/Ioxide.Tests.Unit/Program.cs b/tests/Ioxide.Tests.Unit/Program.cs index 757af8f1..5efd5475 100644 --- a/tests/Ioxide.Tests.Unit/Program.cs +++ b/tests/Ioxide.Tests.Unit/Program.cs @@ -29,6 +29,7 @@ private static int Main() Http2StreamedRequestTests.Register(runner); Http2TrailerTests.Register(runner); Http2PseudoHeaderTests.Register(runner); + Nghttp2StreamCloseTests.Register(runner); Http2RequestHeaderTests.Register(runner); Http2RequestReuseTests.Register(runner); Http2StreamedBodyTests.Register(runner);