diff --git a/src/protocols/ioxide.nghttp2/Connection/Nghttp2Connection.Streamed.cs b/src/protocols/ioxide.nghttp2/Connection/Nghttp2Connection.Streamed.cs index 48a76f23..a4ffd753 100644 --- a/src/protocols/ioxide.nghttp2/Connection/Nghttp2Connection.Streamed.cs +++ b/src/protocols/ioxide.nghttp2/Connection/Nghttp2Connection.Streamed.cs @@ -110,11 +110,12 @@ private Nghttp2ResponseWriter RentWriter(int streamId) } /// Headers of a streamed response - no END_STREAM, the body follows. - internal unsafe void SendStreamedHeaders(int streamId, Nghttp2Response response) + /// False when nghttp2 no longer knows the stream: the peer reset it. + internal unsafe bool SendStreamedHeaders(int streamId, Nghttp2Response response) { if (_handle == 0) { - return; + return true; } byte[] headers = PackHeaders(response, out int headersLength); @@ -126,10 +127,15 @@ internal unsafe void SendStreamedHeaders(int streamId, Nghttp2Response response) result = Nghttp2.ih2_submit_response_stream(_handle, streamId, headerBytes, (nuint)headersLength); } + if (result == ErrStreamClosed) + { + return false; + } if (result != 0) { _failed = true; } + return true; } finally { diff --git a/src/protocols/ioxide.nghttp2/Connection/Nghttp2Connection.cs b/src/protocols/ioxide.nghttp2/Connection/Nghttp2Connection.cs index 86d3fa6e..dd402c06 100644 --- a/src/protocols/ioxide.nghttp2/Connection/Nghttp2Connection.cs +++ b/src/protocols/ioxide.nghttp2/Connection/Nghttp2Connection.cs @@ -45,6 +45,7 @@ public sealed partial class Nghttp2Connection : IDisposable // RFC 9113 ENHANCE_YOUR_CALM: a request body past MaxRequestBytes, as the managed stack resets it. private const uint EnhanceYourCalm = 0xb; private const int ErrInvalidArgument = -501; // NGHTTP2_ERR_INVALID_ARGUMENT + private const int ErrStreamClosed = -510; // NGHTTP2_ERR_STREAM_CLOSED private readonly IDuplexPipe _pipe; private readonly Nghttp2Options _options; diff --git a/src/protocols/ioxide.nghttp2/Http/Nghttp2ResponseWriter.cs b/src/protocols/ioxide.nghttp2/Http/Nghttp2ResponseWriter.cs index b6cd9898..939f843e 100644 --- a/src/protocols/ioxide.nghttp2/Http/Nghttp2ResponseWriter.cs +++ b/src/protocols/ioxide.nghttp2/Http/Nghttp2ResponseWriter.cs @@ -80,9 +80,9 @@ public void WriteHeaders(Nghttp2Response response) private void SendHeaders(Nghttp2Response response) { - if (!_gone) + if (!_gone && !_connection.SendStreamedHeaders(StreamId, response)) { - _connection.SendStreamedHeaders(StreamId, response); + _gone = true; } } diff --git a/src/protocols/ioxide.nghttp2/Interop/Nghttp2.cs b/src/protocols/ioxide.nghttp2/Interop/Nghttp2.cs index b4862e54..4f1a3ad8 100644 --- a/src/protocols/ioxide.nghttp2/Interop/Nghttp2.cs +++ b/src/protocols/ioxide.nghttp2/Interop/Nghttp2.cs @@ -43,17 +43,19 @@ internal struct Callbacks /// Answer the request on . Headers are packed /// [u16 namelen][name][u16 valuelen][value]..., :status first. The body is copied - /// natively and freed on stream close. Returns 0, or a negative nghttp2 error. + /// natively and freed on stream close; for a stream the peer already reset, nothing is kept and + /// the result is still 0. Returns 0, or a negative nghttp2 error. [DllImport(Lib)] internal static extern int ih2_submit_response(nint connection, int streamId, byte* headers, nuint headersLen, byte* body, nuint bodyLen); /// - /// Open a response whose body arrives over time. Headers go out at once; the body follows - /// through until ends it. + /// Open a response whose body arrives over time. Headers are queued for the next drain; the body + /// follows through until ends it. /// /// nghttp2 PULLS: it asks for body bytes when it is ready to frame them, and its read callback /// defers whenever nothing is buffered - which is what holds the stream open through a slow - /// producer rather than ending it early. Returns 0, or a negative nghttp2 error. + /// producer rather than ending it early. Returns 0, NGHTTP2_ERR_STREAM_CLOSED for a stream the peer + /// already reset, or another negative nghttp2 error. /// [DllImport(Lib)] internal static extern int ih2_submit_response_stream(nint connection, int streamId, byte* headers, nuint headersLen); diff --git a/src/protocols/ioxide.nghttp2/native/ioxide_nghttp2_shim.c b/src/protocols/ioxide.nghttp2/native/ioxide_nghttp2_shim.c index 3c7ba4f0..43a8e919 100644 --- a/src/protocols/ioxide.nghttp2/native/ioxide_nghttp2_shim.c +++ b/src/protocols/ioxide.nghttp2/native/ioxide_nghttp2_shim.c @@ -395,6 +395,9 @@ int ih2_submit_response_stream(ih2_conn *c, int32_t stream_id, if (c == NULL || c->session == NULL) { return NGHTTP2_ERR_INVALID_STATE; } + if (nghttp2_session_find_stream(c->session, stream_id) == NULL) { + return NGHTTP2_ERR_STREAM_CLOSED; /* the peer reset it: no callback would ever free a stream attached now */ + } nghttp2_nv nv[IH2_MAX_HEADERS]; int count = ih2_unpack_headers(headers, headers_len, nv); @@ -516,6 +519,9 @@ int ih2_submit_response(ih2_conn *c, int32_t stream_id, if (c == NULL || c->session == NULL) { return NGHTTP2_ERR_INVALID_STATE; } + if (nghttp2_session_find_stream(c->session, stream_id) == NULL) { + return 0; /* the peer reset it: no callback would ever free a body attached now, and an error fails the connection */ + } nghttp2_nv nv[IH2_MAX_HEADERS]; int count = ih2_unpack_headers(headers, headers_len, nv); 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 c89102ff..3edead35 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 bb65d6b6..81473db1 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/Nghttp2ResetResponseTests.cs b/tests/Ioxide.Tests.Unit/Nghttp2ResetResponseTests.cs new file mode 100644 index 00000000..b3199191 --- /dev/null +++ b/tests/Ioxide.Tests.Unit/Nghttp2ResetResponseTests.cs @@ -0,0 +1,123 @@ +using System.IO.Pipelines; +using System.Runtime.InteropServices; +using ioxide.nghttp2; + +namespace Ioxide.Tests; + +/// A response given after the peer reset its stream leaves nothing behind in the shim. +internal static class Nghttp2ResetResponseTests +{ + public static void Register(Runner runner) + { + runner.Test("nghttp2: a buffered response to a stream its peer reset while the handler ran is not kept", () => + { + using var client = new StrictClient(); + var connection = new Nghttp2Connection(client); + + TaskCompletionSource? parked = null; + Task run = connection.RunAsync(_ => + { + parked = new TaskCompletionSource(); + return new ValueTask(parked.Task); + }); + var response = new Nghttp2Response { Body = new byte[64 * 1024] }; + + client.SendRequests(); // preface and SETTINGS + client.Drain(); + + const int warm = 20, total = 220; + long before = 0; + for (int i = 0; i < total; i++) + { + int id = 1 + (2 * i); + client.Feed(Get(id)); + client.Drain(); + client.Feed(Http2FlowControlTests.RstStream(id, 8)); // CANCEL, while the handler is parked + client.Drain(); + parked!.SetResult(response); + client.Drain(); + if (i == warm - 1) + { + before = NativeHeapInUse(); + } + } + long grown = NativeHeapInUse() - before; + + client.Close(run); + connection.Dispose(); + Assert.True(grown < 6 * 1024 * 1024, // malloc is the whole process's; the leak is 12.8 MiB + $"{total - warm} responses of 64 KiB to reset streams left {grown / 1024} KiB on the native heap"); + }); + + foreach (int staged in new[] { 128 * 1024, 0 }) + { + int bytes = staged; + runner.Test($"nghttp2: a streamed response to a stream reset in the same read as its end 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(); + bool servedNext = false; + Task run = connection.RunAsync(async (request, writer) => + { + if (request.StreamId != 1) + { + servedNext = true; + return; + } + + 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(); + client.Drain(); + client.Feed([.. Get(1), .. Http2FlowControlTests.RstStream(1, 8)]); // its writer is made after the reset + client.Drain(); + go.SetResult(); + client.Drain(); + + Assert.True(outcome.Task.IsCompleted, "the flush of a stream nghttp2 had already closed never returned"); + Assert.Equal("peer gone", outcome.Task.Result); + + Assert.True(!connection.IsBroken, "one stream's reset failed the connection"); // the loop only sees that after its next read + client.Feed(Get(3)); + client.Drain(); + Assert.True(servedNext, "the connection served nothing after the reset"); + client.Close(run); + connection.Dispose(); + }); + } + } + + // A GET with END_STREAM: :scheme http, :path /, and an :authority, which nghttp2 requires. + private static byte[] Get(int streamId) + { + byte[] block = [0x82, 0x86, 0x84, 0x01, 9, .. "a.example"u8.ToArray()]; + return Http2FlowControlTests.FrameBytes(0x1, 0x5, streamId, block); + } + + [StructLayout(LayoutKind.Sequential)] + private struct MallInfo2 + { + public nuint Arena, Ordblks, Smblks, Hblks, Hblkhd, Usmblks, Fsmblks, Uordblks, Fordblks, Keepcost; + } + + [DllImport("libc", EntryPoint = "mallinfo2")] + private static extern MallInfo2 GetMallInfo2(); + + // What malloc has handed out and not had back, which is where the shim keeps a response's body. + private static long NativeHeapInUse() + { + MallInfo2 info = GetMallInfo2(); + return (long)info.Uordblks + (long)info.Hblkhd; + } +} diff --git a/tests/Ioxide.Tests.Unit/Program.cs b/tests/Ioxide.Tests.Unit/Program.cs index fe2963c8..f9d8229c 100644 --- a/tests/Ioxide.Tests.Unit/Program.cs +++ b/tests/Ioxide.Tests.Unit/Program.cs @@ -32,6 +32,7 @@ private static int Main() Http2StreamedBodyTests.Register(runner); Http2FlowControlTests.Register(runner); Http2BufferedCreditTests.Register(runner); + Nghttp2ResetResponseTests.Register(runner); HpackEncodeTests.Register(runner); QpackStaticEncodeTests.Register(runner); AlpnSelectTests.Register(runner);