diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj b/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj index 8c817a19434..7593b724bd8 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj @@ -179,6 +179,7 @@ + diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerCancellationTests.Rewind.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerCancellationTests.Rewind.cs new file mode 100644 index 00000000000..2c7c9d945ad --- /dev/null +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerCancellationTests.Rewind.cs @@ -0,0 +1,431 @@ +#nullable enable + +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Xamarin.Android.Net; + +using NUnit.Framework; + +namespace Xamarin.Android.NetTests +{ + public partial class AndroidMessageHandlerCancellationTests + { + [Test] + public async Task HttpContentStreamIsRewoundAfterCancellation () + { + const int requestContentLength = 1_000_000; + const int requestTimeoutMilliseconds = 10_000; + + int testPort = GetAvailablePort (); + using var listener = new HttpListener (); + listener.Prefixes.Add ($"http://+:{testPort}/"); + listener.Start (); + + var requestBody = new byte [requestContentLength]; + for (int i = 0; i < requestBody.Length; i++) + requestBody [i] = (byte) (i % 251); + var cancellationObserved = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var firstServerBodyRead = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var firstServerTask = HandleCancelledRequest (); + + using var cancellationTokenSource = new CancellationTokenSource (); + using var retryCancellationTokenSource = new CancellationTokenSource (); + using var client = new HttpClient (new AndroidMessageHandler ()); + var contentStream = new ControlledSeekableStream (requestBody); + using var content = new StreamContent (contentStream); + using var request = new HttpRequestMessage (HttpMethod.Post, $"http://localhost:{testPort}/") { Content = content }; + Task firstRequestTask = Task.CompletedTask; + Task retryServerTask = Task.CompletedTask; + Task retryRequestTask = Task.CompletedTask; + + try { + var stream = await content.ReadAsStreamAsync (); + Assert.AreEqual (0, stream.Position, "Stream position should be 0 before first request"); + + var firstResponseTask = client.SendAsync (request, cancellationTokenSource.Token); + firstRequestTask = firstResponseTask; + await WaitForTask (contentStream.FirstWriteCompletedTask, requestTimeoutMilliseconds, "The first request body did not start uploading.").ConfigureAwait (false); + await WaitForTask (firstServerBodyRead.Task, requestTimeoutMilliseconds, "The first server handler did not receive the request body prefix.").ConfigureAwait (false); + Assert.IsTrue (contentStream.IsFirstCopyBlocked, "The first content copy was not blocked after its initial destination write."); + Assert.AreEqual (1, contentStream.CopyCount, "The first request should start exactly one content copy."); + + cancellationTokenSource.Cancel (); + try { + await WaitForTask (firstResponseTask, requestTimeoutMilliseconds, "The first request did not observe cancellation.").ConfigureAwait (false); + using var firstResponse = await firstResponseTask.ConfigureAwait (false); + Assert.Fail ("The first request completed successfully instead of observing cancellation."); + } catch (OperationCanceledException) { + } + + cancellationObserved.TrySetResult (true); + await WaitForTask (firstServerTask, requestTimeoutMilliseconds, "The first server handler did not finish after cancellation.").ConfigureAwait (false); + + var streamAfterCancellation = await content.ReadAsStreamAsync (); + Assert.AreEqual (0, streamAfterCancellation.Position, "Stream position should be 0 after cancellation (stream should be rewound)"); + Assert.AreEqual (1, contentStream.CopyCount, "Cancellation should finish the first content copy before retry."); + + retryServerTask = HandleRetryRequest (); + using var retryRequest = new HttpRequestMessage (HttpMethod.Post, $"http://localhost:{testPort}/") { Content = content }; + var retryResponseTask = client.SendAsync (retryRequest, retryCancellationTokenSource.Token); + retryRequestTask = retryResponseTask; + var retryTasks = Task.WhenAll (retryResponseTask, retryServerTask); + await WaitForTask (retryTasks, requestTimeoutMilliseconds, "The retry request and server handler did not finish.").ConfigureAwait (false); + + using var retryResponse = await retryResponseTask.ConfigureAwait (false); + Assert.True (retryResponse.IsSuccessStatusCode, "Second request should succeed with reused content"); + Assert.AreEqual (2, contentStream.CopyCount, "The retry should perform a second, ungated content copy."); + + var streamAfterRetry = await content.ReadAsStreamAsync (); + Assert.AreEqual (0, streamAfterRetry.Position, "Stream position should be 0 after successful request"); + } finally { + bool firstWriteCompletedBeforeCleanup = contentStream.FirstWriteCompletedTask.IsCompleted; + bool firstServerBodyReadBeforeCleanup = firstServerBodyRead.Task.IsCompleted; + bool firstRequestCompletedBeforeCleanup = firstRequestTask.IsCompleted; + bool firstServerCompletedBeforeCleanup = firstServerTask.IsCompleted; + bool retryRequestCompletedBeforeCleanup = retryRequestTask.IsCompleted; + bool retryServerCompletedBeforeCleanup = retryServerTask.IsCompleted; + bool firstRequestCancellationExpected = cancellationTokenSource.IsCancellationRequested || !firstRequestCompletedBeforeCleanup; + + cancellationObserved.TrySetResult (true); + cancellationTokenSource.Cancel (); + retryCancellationTokenSource.Cancel (); + contentStream.ReleaseFirstCopy (); + listener.Abort (); + + await Task.WhenAll ( + ObserveTaskAfterCleanup (contentStream.FirstWriteCompletedTask, "first destination write signal", firstWriteCompletedBeforeCleanup, cancellationExpected: !firstWriteCompletedBeforeCleanup, listenerAbortExpected: false), + ObserveTaskAfterCleanup (firstServerBodyRead.Task, "first server body read signal", firstServerBodyReadBeforeCleanup, cancellationExpected: false, listenerAbortExpected: true), + ObserveTaskAfterCleanup (firstRequestTask, "first request", firstRequestCompletedBeforeCleanup, cancellationExpected: firstRequestCancellationExpected, listenerAbortExpected: false), + ObserveTaskAfterCleanup (firstServerTask, "first server handler", firstServerCompletedBeforeCleanup, cancellationExpected: false, listenerAbortExpected: true), + ObserveTaskAfterCleanup (retryRequestTask, "retry request", retryRequestCompletedBeforeCleanup, cancellationExpected: !retryRequestCompletedBeforeCleanup, listenerAbortExpected: false), + ObserveTaskAfterCleanup (retryServerTask, "retry server handler", retryServerCompletedBeforeCleanup, cancellationExpected: false, listenerAbortExpected: true) + ).ConfigureAwait (false); + } + + async Task HandleCancelledRequest () + { + try { + var context = await listener.GetContextAsync ().ConfigureAwait (false); + using var response = context.Response; + Assert.AreEqual (requestBody.Length, context.Request.ContentLength64, "The first request declared an unexpected content length."); + + var buffer = new byte [4096]; + int bytesRead = await context.Request.InputStream.ReadAsync (buffer, 0, buffer.Length).ConfigureAwait (false); + Assert.Greater (bytesRead, 0, "The first request ended before the server received its body prefix."); + Assert.Less (bytesRead, context.Request.ContentLength64, "The first server read unexpectedly consumed the complete request body."); + for (int i = 0; i < bytesRead; i++) { + if (buffer [i] != requestBody [i]) + Assert.Fail ($"The first request body differed at offset {i}."); + } + + firstServerBodyRead.TrySetResult (bytesRead); + await cancellationObserved.Task.ConfigureAwait (false); + response.Abort (); + } catch (Exception ex) { + firstServerBodyRead.TrySetException (ex); + throw; + } + } + + async Task HandleRetryRequest () + { + var context = await listener.GetContextAsync ().ConfigureAwait (false); + using var response = context.Response; + Assert.AreEqual (requestBody.Length, context.Request.ContentLength64, "The retry request declared an unexpected content length."); + var buffer = new byte [4096]; + int totalBytesRead = 0; + while (totalBytesRead < requestBody.Length) { + int bytesToRead = Math.Min (buffer.Length, requestBody.Length - totalBytesRead); + int bytesRead = await context.Request.InputStream.ReadAsync (buffer, 0, bytesToRead).ConfigureAwait (false); + Assert.Greater (bytesRead, 0, "The retry request ended before the complete body was received."); + for (int i = 0; i < bytesRead; i++) { + if (buffer [i] != requestBody [totalBytesRead + i]) + Assert.Fail ($"The retry request body differed at offset {totalBytesRead + i}."); + } + totalBytesRead += bytesRead; + } + + Assert.AreEqual (requestBody.Length, totalBytesRead, "The retry request did not contain the complete rewound body."); + + response.StatusCode = 200; + response.ContentLength64 = 0; + response.Close (); + } + + async Task ObserveTaskAfterCleanup (Task task, string taskName, bool completedBeforeCleanup, bool cancellationExpected, bool listenerAbortExpected) + { + try { + await WaitForTask (task, requestTimeoutMilliseconds, $"The {taskName} did not finish during cleanup.").ConfigureAwait (false); + } catch (OperationCanceledException) when (cancellationExpected) { + } catch (HttpListenerException) when (listenerAbortExpected && !completedBeforeCleanup) { + } catch (ObjectDisposedException) when (listenerAbortExpected && !completedBeforeCleanup) { + } catch (IOException) when (listenerAbortExpected && !completedBeforeCleanup) { + } + } + } + + [Test] + public async Task ControlledSeekableStreamGatesOnlyFirstCopy () + { + const int copyTimeoutMilliseconds = 10_000; + var content = new byte [32]; + for (int i = 0; i < content.Length; i++) + content [i] = (byte) i; + + using var stream = new ControlledSeekableStream (content); + using var firstDestination = new BufferedDestinationStream (); + using var cancellationTokenSource = new CancellationTokenSource (); + Task firstCopyTask = Task.CompletedTask; + + try { + firstCopyTask = stream.CopyToAsync (firstDestination, 8, cancellationTokenSource.Token); + await WaitForTask (stream.FirstWriteCompletedTask, copyTimeoutMilliseconds, "The controlled stream did not complete its first destination write.").ConfigureAwait (false); + + Assert.IsTrue (stream.IsFirstCopyBlocked, "The first copy should remain blocked after its initial destination write."); + Assert.IsFalse (firstCopyTask.IsCompleted, "The first copy completed before cancellation."); + Assert.AreEqual (8, stream.FirstWriteLength, "The first destination write should use the requested copy buffer size."); + Assert.AreEqual (8, stream.Position, "The controlled stream should advance only by the bytes written before its gate."); + Assert.AreEqual (1, firstDestination.FlushCount, "The first destination write should be flushed before the progress gate is signaled."); + CollectionAssert.AreEqual (new byte [] { 0, 1, 2, 3, 4, 5, 6, 7 }, firstDestination.ToArray (), + "The flushed destination should expose the complete first write."); + + cancellationTokenSource.Cancel (); + try { + await firstCopyTask.ConfigureAwait (false); + Assert.Fail ("The first controlled copy completed instead of observing cancellation."); + } catch (OperationCanceledException) { + } + + stream.Seek (0, SeekOrigin.Begin); + using var retryDestination = new MemoryStream (); + await stream.CopyToAsync (retryDestination, 8, CancellationToken.None).ConfigureAwait (false); + + Assert.AreEqual (2, stream.CopyCount, "The retry should perform a second content copy."); + CollectionAssert.AreEqual (content, retryDestination.ToArray (), "The ungated retry should copy the complete stream."); + } finally { + cancellationTokenSource.Cancel (); + stream.ReleaseFirstCopy (); + + try { + await WaitForTask (firstCopyTask, copyTimeoutMilliseconds, "The first controlled copy did not finish during cleanup.").ConfigureAwait (false); + } catch (OperationCanceledException) { + } + } + } + + [Test] + public async Task HttpListenerAbortCompletesPendingRequestBodyRead () + { + const int contentLength = 1024; + const int requestTimeoutMilliseconds = 10_000; + byte [] bodyPrefix = { 0, 1, 2, 3, 4, 5, 6, 7 }; + + int testPort = GetAvailablePort (); + using var listener = new HttpListener (); + listener.Prefixes.Add ($"http://127.0.0.1:{testPort}/"); + listener.Start (); + var contextTask = listener.GetContextAsync (); + + using var client = new TcpClient (); + await client.ConnectAsync (IPAddress.Loopback, testPort).ConfigureAwait (false); + using NetworkStream clientStream = client.GetStream (); + byte [] requestHeaders = Encoding.ASCII.GetBytes ( + $"POST / HTTP/1.1\r\nHost: 127.0.0.1:{testPort}\r\nContent-Length: {contentLength}\r\nConnection: keep-alive\r\n\r\n" + ); + await clientStream.WriteAsync (requestHeaders, 0, requestHeaders.Length).ConfigureAwait (false); + await clientStream.WriteAsync (bodyPrefix, 0, bodyPrefix.Length).ConfigureAwait (false); + await clientStream.FlushAsync ().ConfigureAwait (false); + + await WaitForTask (contextTask, requestTimeoutMilliseconds, "The listener did not accept the partial fixed-length request.").ConfigureAwait (false); + var context = await contextTask.ConfigureAwait (false); + using var response = context.Response; + Task pendingReadTask = Task.FromResult (0); + try { + Assert.AreEqual (contentLength, context.Request.ContentLength64, "The listener observed an unexpected content length."); + + var receivedPrefix = new byte [bodyPrefix.Length]; + int totalBytesRead = 0; + while (totalBytesRead < receivedPrefix.Length) { + int bytesRead = await context.Request.InputStream.ReadAsync ( + receivedPrefix, + totalBytesRead, + receivedPrefix.Length - totalBytesRead + ).ConfigureAwait (false); + Assert.Greater (bytesRead, 0, "The partial request ended before the body prefix was received."); + totalBytesRead += bytesRead; + } + CollectionAssert.AreEqual (bodyPrefix, receivedPrefix, "The listener received an unexpected request body prefix."); + + var pendingReadBuffer = new byte [1]; + pendingReadTask = context.Request.InputStream.ReadAsync (pendingReadBuffer, 0, pendingReadBuffer.Length); + var prematureCompletion = await Task.WhenAny (pendingReadTask, Task.Delay (250)).ConfigureAwait (false); + Assert.AreNotSame (pendingReadTask, prematureCompletion, "The request body read should remain pending while the client keeps the incomplete request open."); + + response.Abort (); + try { + await WaitForTask (pendingReadTask, requestTimeoutMilliseconds, "Aborting the response did not terminate the pending request body read.").ConfigureAwait (false); + int bytesRead = await pendingReadTask.ConfigureAwait (false); + Assert.AreEqual (0, bytesRead, "The aborted request body read should not produce additional bytes."); + } catch (IOException) { + } catch (HttpListenerException) { + } catch (ObjectDisposedException) { + } + } finally { + response.Abort (); + listener.Abort (); + try { + await WaitForTask (pendingReadTask, requestTimeoutMilliseconds, "The pending request body read did not finish during cleanup.").ConfigureAwait (false); + } catch (IOException) { + } catch (HttpListenerException) { + } catch (ObjectDisposedException) { + } + } + } + + static async Task WaitForTask (Task task, int timeoutMilliseconds, string failureMessage) + { + try { + await task.WaitAsync (TimeSpan.FromMilliseconds (timeoutMilliseconds)).ConfigureAwait (false); + } catch (TimeoutException) { + if (task.IsFaulted) + await task.ConfigureAwait (false); + Assert.Fail ($"{failureMessage} Timeout: {timeoutMilliseconds}ms."); + } + } + + sealed class ControlledSeekableStream : MemoryStream + { + readonly TaskCompletionSource firstWriteCompleted = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + readonly TaskCompletionSource releaseFirstCopy = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + int copyCount; + int firstWriteLength; + + public ControlledSeekableStream (byte [] content) + : base (content, writable: false) + { + } + + public int CopyCount => Volatile.Read (ref copyCount); + + public int FirstWriteLength => Volatile.Read (ref firstWriteLength); + + public Task FirstWriteCompletedTask => firstWriteCompleted.Task; + + public bool IsFirstCopyBlocked => firstWriteCompleted.Task.Status == TaskStatus.RanToCompletion && !releaseFirstCopy.Task.IsCompleted; + + public void ReleaseFirstCopy () + { + releaseFirstCopy.TrySetResult (true); + firstWriteCompleted.TrySetCanceled (); + } + + public override Task CopyToAsync (Stream destination, int bufferSize, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull (destination); + if (bufferSize <= 0) + throw new ArgumentOutOfRangeException (nameof (bufferSize)); + + cancellationToken.ThrowIfCancellationRequested (); + bool gateFirstCopy = Interlocked.Increment (ref copyCount) == 1; + return CopyToAsyncCore (destination, bufferSize, cancellationToken, gateFirstCopy); + } + + async Task CopyToAsyncCore (Stream destination, int bufferSize, CancellationToken cancellationToken, bool gateFirstCopy) + { + try { + var buffer = new byte [bufferSize]; + int bytesRead; + bool firstWrite = true; + while ((bytesRead = await ReadAsync (buffer, 0, buffer.Length, cancellationToken).ConfigureAwait (false)) > 0) { + await destination.WriteAsync (buffer, 0, bytesRead, cancellationToken).ConfigureAwait (false); + if (gateFirstCopy && firstWrite) { + await destination.FlushAsync (cancellationToken).ConfigureAwait (false); + firstWrite = false; + Volatile.Write (ref firstWriteLength, bytesRead); + firstWriteCompleted.TrySetResult (true); + await releaseFirstCopy.Task.WaitAsync (cancellationToken).ConfigureAwait (false); + } + } + } catch (Exception ex) { + if (gateFirstCopy) + firstWriteCompleted.TrySetException (ex); + throw; + } + } + } + + sealed class BufferedDestinationStream : Stream + { + readonly MemoryStream buffered = new MemoryStream (); + readonly MemoryStream committed = new MemoryStream (); + int flushCount; + + public int FlushCount => Volatile.Read (ref flushCount); + + public override bool CanRead => false; + + public override bool CanSeek => false; + + public override bool CanWrite => true; + + public override long Length => throw new NotSupportedException (); + + public override long Position { + get => throw new NotSupportedException (); + set => throw new NotSupportedException (); + } + + public byte [] ToArray () => committed.ToArray (); + + public override void Flush () + { + buffered.Position = 0; + buffered.CopyTo (committed); + buffered.SetLength (0); + buffered.Position = 0; + Interlocked.Increment (ref flushCount); + } + + public override Task FlushAsync (CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested (); + Flush (); + return Task.CompletedTask; + } + + public override int Read (byte [] buffer, int offset, int count) => throw new NotSupportedException (); + + public override long Seek (long offset, SeekOrigin origin) => throw new NotSupportedException (); + + public override void SetLength (long value) => throw new NotSupportedException (); + + public override void Write (byte [] buffer, int offset, int count) + { + buffered.Write (buffer, offset, count); + } + + public override Task WriteAsync (byte [] buffer, int offset, int count, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested (); + Write (buffer, offset, count); + return Task.CompletedTask; + } + + protected override void Dispose (bool disposing) + { + if (disposing) { + buffered.Dispose (); + committed.Dispose (); + } + base.Dispose (disposing); + } + } + } +} diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerCancellationTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerCancellationTests.cs index ccb0c89e227..2a598bb3c9f 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerCancellationTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerCancellationTests.cs @@ -18,7 +18,7 @@ namespace Xamarin.Android.NetTests [TestFixture] [Category ("AndroidMessageHandlerCancellation")] [Category ("InetAccess")] - public class AndroidMessageHandlerCancellationTests + public partial class AndroidMessageHandlerCancellationTests { const int StalledResponseContentLength = 1024 * 1024; const int UploadContentLength = 16 * 1024 * 1024; diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerTests.cs index f223ba3200a..6f69075166f 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerTests.cs @@ -351,85 +351,6 @@ private static X509Certificate2 BuildClientCertificate () return clientCert; } - [Test] - public async Task HttpContentStreamIsRewoundAfterCancellation () - { - int testPort = GetAvailablePort (); - using var listener = new HttpListener (); - listener.Prefixes.Add ($"http://+:{testPort}/"); - listener.Start (); - - // Handle the first request - simulate a slow server to allow cancellation - listener.BeginGetContext (ar => { - var ctx = listener.EndGetContext (ar); - // Read the request body slowly to ensure cancellation happens during upload - var buffer = new byte[4096]; - try { - while (ctx.Request.InputStream.Read (buffer, 0, buffer.Length) > 0) { - System.Threading.Thread.Sleep (100); // Slow down to allow cancellation - } - } catch (Exception ex) { - // Expected when connection is cancelled - Console.WriteLine ($"Exception while reading request body: {ex}"); - } - try { - ctx.Response.StatusCode = 200; - ctx.Response.Close (); - } catch (Exception ex) { - // Connection may already be closed - Console.WriteLine ($"Exception while closing response: {ex}"); - } - }, null); - - var tcs = new System.Threading.CancellationTokenSource (); - tcs.CancelAfter (500); // Cancel after 500ms - var client = new HttpClient (new AndroidMessageHandler ()); - var byc = new ByteArrayContent (new byte[1_000_000]); // 1 MB of data - var request = new HttpRequestMessage (HttpMethod.Post, $"http://localhost:{testPort}/") { Content = byc }; - - var stream = await byc.ReadAsStreamAsync (); - var positionBefore = stream.Position; - Assert.AreEqual (0, positionBefore, "Stream position should be 0 before first request"); - - bool exceptionThrown = false; - try { - await client.SendAsync (request, tcs.Token).ConfigureAwait (false); - // If we get here without exception, that's also OK for this test - } catch (Exception ex) when (IsConnectionFailure (ex)) { - Assert.Ignore ($"Ignoring transient connection failure: {ex.GetType ()}: {ex.Message}"); - } catch (Exception ex) { - // Expected - cancellation or connection error - // We catch all exceptions to ensure the test doesn't fail due to unhandled exceptions - Console.WriteLine ($"Exception during first request (expected): {ex}"); - exceptionThrown = true; - } - - // The key assertion: stream should be rewound even after an exception - var stream2 = await byc.ReadAsStreamAsync (); - var positionAfter = stream2.Position; - Assert.AreEqual (0, positionAfter, "Stream position should be 0 after failed request (stream should be rewound)"); - - // Only proceed with second request if we actually got an exception (test scenario succeeded) - if (exceptionThrown) { - var request2 = new HttpRequestMessage (HttpMethod.Post, $"http://localhost:{testPort}/") { Content = byc }; - - // Set up listener for second request - listener.BeginGetContext (ar => { - var ctx = listener.EndGetContext (ar); - ctx.Response.StatusCode = 200; - ctx.Response.Close (); - }, null); - - var response2 = await client.SendAsync (request2).ConfigureAwait (false); - Assert.True (response2.IsSuccessStatusCode, "Second request should succeed with reused content"); - - var stream3 = await byc.ReadAsStreamAsync (); - var positionFinal = stream3.Position; - Assert.AreEqual (0, positionFinal, "Stream position should be 0 after successful request"); - } - - listener.Close (); - } [Test] public void ConnectionFailureThrowsHttpRequestException () @@ -476,5 +397,6 @@ public void ExceedingMaxAutomaticRedirectionsThrowsHttpRequestException () Assert.IsNotNull (inner, $"Expected inner WebException but got {ex?.InnerException?.GetType ()}"); Assert.AreEqual (WebExceptionStatus.UnknownError, inner.Status, "Inner WebException should preserve UnknownError status"); } + } }