From 4428beb6988d72657f3d8f871b63cb7968e0de34 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 8 Sep 2026 11:22:42 +0200 Subject: [PATCH 1/8] [flaky-ci] Stabilize cancellation rewind test Track both server handlers, synchronize cancellation after upload begins, and fully validate the retried request body before responding. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AndroidMessageHandlerTests.cs | 222 +++++++++++++----- 1 file changed, 161 insertions(+), 61 deletions(-) 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..4eac6e1f232 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 @@ -354,81 +354,181 @@ private static X509Certificate2 BuildClientCertificate () [Test] public async Task HttpContentStreamIsRewoundAfterCancellation () { + const int requestContentLength = 8_000_000; + const int requestTimeoutMilliseconds = 10_000; + 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]; + + var requestBodyStarted = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var cancellationObserved = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var firstServerTask = HandleCancelledRequest (); + + using var cancellationTokenSource = new System.Threading.CancellationTokenSource (); + using var retryCancellationTokenSource = new System.Threading.CancellationTokenSource (); + using var client = new HttpClient (new AndroidMessageHandler ()); + var requestBody = new byte [requestContentLength]; + for (int i = 0; i < requestBody.Length; i++) + requestBody [i] = (byte) (i % 251); + using var content = new ByteArrayContent (requestBody); + 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 (requestBodyStarted.Task, "The first request body did not start uploading.").ConfigureAwait (false); + Assert.Greater (stream.Position, 0, "The content stream did not advance after the server received the request body."); + Assert.Less (stream.Position, stream.Length, "The content upload completed before the test could cancel it."); + + cancellationTokenSource.Cancel (); + var completedTask = await Task.WhenAny (firstResponseTask, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); + if (completedTask != firstResponseTask) { + cancellationObserved.TrySetResult (true); + await WaitForTask (firstServerTask, "The first server handler did not finish after releasing the request body.").ConfigureAwait (false); + Assert.Fail ($"The first request did not observe cancellation within {requestTimeoutMilliseconds}ms."); + } + 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}"); + using var firstResponse = await firstResponseTask.ConfigureAwait (false); + Assert.Fail ("The first request completed successfully instead of observing cancellation."); + } catch (OperationCanceledException) { + cancellationObserved.TrySetResult (true); } + + cancellationObserved.TrySetResult (true); + await WaitForTask (firstServerTask, "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)"); + + 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, "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"); + + var streamAfterRetry = await content.ReadAsStreamAsync (); + Assert.AreEqual (0, streamAfterRetry.Position, "Stream position should be 0 after successful request"); + } finally { + bool requestBodyStartedBeforeCleanup = requestBodyStarted.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 (); + listener.Abort (); + + await Task.WhenAll ( + ObserveTaskAfterCleanup (requestBodyStarted.Task, "request body start signal", requestBodyStartedBeforeCleanup, 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 { - ctx.Response.StatusCode = 200; - ctx.Response.Close (); + var context = await listener.GetContextAsync ().ConfigureAwait (false); + using var response = context.Response; + var buffer = new byte [4096]; + int bytesRead = await context.Request.InputStream.ReadAsync (buffer, 0, buffer.Length).ConfigureAwait (false); + if (bytesRead == 0) + throw new InvalidOperationException ("The first request ended before its body started uploading."); + + requestBodyStarted.TrySetResult (true); + await cancellationObserved.Task.ConfigureAwait (false); + + try { + while (await context.Request.InputStream.ReadAsync (buffer, 0, buffer.Length).ConfigureAwait (false) > 0) { + } + } catch (IOException) { + // The canceled client can close the connection while the server drains the request. + } catch (HttpListenerException) { + // The canceled client can close the connection while the server drains the request. + } + + try { + response.StatusCode = 204; + response.ContentLength64 = 0; + response.Close (); + } catch (IOException) { + // The canceled client can close the connection before the server closes the response. + } catch (HttpListenerException) { + // The canceled client can close the connection before the server closes the response. + } } catch (Exception ex) { - // Connection may already be closed - Console.WriteLine ($"Exception while closing response: {ex}"); + requestBodyStarted.TrySetException (ex); + throw; } - }, 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; + 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; + int bytesRead; + while ((bytesRead = await context.Request.InputStream.ReadAsync (buffer, 0, buffer.Length).ConfigureAwait (false)) > 0) { + if (totalBytesRead + bytesRead > requestBody.Length) + Assert.Fail ($"The retry request body exceeded the expected {requestBody.Length} bytes."); + + 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 (); } - // 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"); + async Task WaitForTask (Task task, string failureMessage) + { + var completed = await Task.WhenAny (task, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); + if (completed != task) + Assert.Fail ($"{failureMessage} Timeout: {requestTimeoutMilliseconds}ms."); + + await task.ConfigureAwait (false); } - listener.Close (); + async Task ObserveTaskAfterCleanup (Task task, string taskName, bool completedBeforeCleanup, bool cancellationExpected, bool listenerAbortExpected) + { + var completed = await Task.WhenAny (task, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); + if (completed != task) + Assert.Fail ($"The {taskName} did not finish during cleanup within {requestTimeoutMilliseconds}ms."); + + try { + await task.ConfigureAwait (false); + } catch (OperationCanceledException) when (cancellationExpected) { + } catch (HttpListenerException) when (listenerAbortExpected && !completedBeforeCleanup) { + } catch (ObjectDisposedException) when (listenerAbortExpected && !completedBeforeCleanup) { + } catch (IOException) when (listenerAbortExpected && !completedBeforeCleanup) { + } + } } [Test] From b6746d6613caec9743d2fb479e4d838766a05327 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 8 Sep 2026 12:56:59 +0200 Subject: [PATCH 2/8] [flaky-ci] Gate cancellation on controlled stream copy Replace MemoryStream position inference with an explicit destination-write gate and add focused coverage for cancellation and retry behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AndroidMessageHandlerTests.cs | 182 ++++++++++++++---- 1 file changed, 143 insertions(+), 39 deletions(-) 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 4eac6e1f232..75522646de3 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 @@ -6,6 +6,7 @@ using System.Net.Sockets; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Threading; using System.Threading.Tasks; using Android.Runtime; @@ -354,7 +355,7 @@ private static X509Certificate2 BuildClientCertificate () [Test] public async Task HttpContentStreamIsRewoundAfterCancellation () { - const int requestContentLength = 8_000_000; + const int requestContentLength = 1_000_000; const int requestTimeoutMilliseconds = 10_000; int testPort = GetAvailablePort (); @@ -362,17 +363,17 @@ public async Task HttpContentStreamIsRewoundAfterCancellation () listener.Prefixes.Add ($"http://+:{testPort}/"); listener.Start (); - var requestBodyStarted = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); var cancellationObserved = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); var firstServerTask = HandleCancelledRequest (); - using var cancellationTokenSource = new System.Threading.CancellationTokenSource (); - using var retryCancellationTokenSource = new System.Threading.CancellationTokenSource (); + using var cancellationTokenSource = new CancellationTokenSource (); + using var retryCancellationTokenSource = new CancellationTokenSource (); using var client = new HttpClient (new AndroidMessageHandler ()); var requestBody = new byte [requestContentLength]; for (int i = 0; i < requestBody.Length; i++) requestBody [i] = (byte) (i % 251); - using var content = new ByteArrayContent (requestBody); + 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; @@ -384,9 +385,9 @@ public async Task HttpContentStreamIsRewoundAfterCancellation () var firstResponseTask = client.SendAsync (request, cancellationTokenSource.Token); firstRequestTask = firstResponseTask; - await WaitForTask (requestBodyStarted.Task, "The first request body did not start uploading.").ConfigureAwait (false); - Assert.Greater (stream.Position, 0, "The content stream did not advance after the server received the request body."); - Assert.Less (stream.Position, stream.Length, "The content upload completed before the test could cancel it."); + await WaitForTask (contentStream.FirstWriteCompletedTask, "The first request body did not start uploading.").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 (); var completedTask = await Task.WhenAny (firstResponseTask, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); @@ -408,6 +409,7 @@ public async Task HttpContentStreamIsRewoundAfterCancellation () 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 }; @@ -418,11 +420,12 @@ public async Task HttpContentStreamIsRewoundAfterCancellation () 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 requestBodyStartedBeforeCleanup = requestBodyStarted.Task.IsCompleted; + bool firstWriteCompletedBeforeCleanup = contentStream.FirstWriteCompletedTask.IsCompleted; bool firstRequestCompletedBeforeCleanup = firstRequestTask.IsCompleted; bool firstServerCompletedBeforeCleanup = firstServerTask.IsCompleted; bool retryRequestCompletedBeforeCleanup = retryRequestTask.IsCompleted; @@ -432,10 +435,11 @@ public async Task HttpContentStreamIsRewoundAfterCancellation () cancellationObserved.TrySetResult (true); cancellationTokenSource.Cancel (); retryCancellationTokenSource.Cancel (); + contentStream.ReleaseFirstCopy (); listener.Abort (); await Task.WhenAll ( - ObserveTaskAfterCleanup (requestBodyStarted.Task, "request body start signal", requestBodyStartedBeforeCleanup, cancellationExpected: false, listenerAbortExpected: true), + ObserveTaskAfterCleanup (contentStream.FirstWriteCompletedTask, "first destination write signal", firstWriteCompletedBeforeCleanup, cancellationExpected: !firstWriteCompletedBeforeCleanup, listenerAbortExpected: false), 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), @@ -445,38 +449,28 @@ await Task.WhenAll ( async Task HandleCancelledRequest () { + var context = await listener.GetContextAsync ().ConfigureAwait (false); + using var response = context.Response; + var buffer = new byte [4096]; + await cancellationObserved.Task.ConfigureAwait (false); + try { - var context = await listener.GetContextAsync ().ConfigureAwait (false); - using var response = context.Response; - var buffer = new byte [4096]; - int bytesRead = await context.Request.InputStream.ReadAsync (buffer, 0, buffer.Length).ConfigureAwait (false); - if (bytesRead == 0) - throw new InvalidOperationException ("The first request ended before its body started uploading."); - - requestBodyStarted.TrySetResult (true); - await cancellationObserved.Task.ConfigureAwait (false); - - try { - while (await context.Request.InputStream.ReadAsync (buffer, 0, buffer.Length).ConfigureAwait (false) > 0) { - } - } catch (IOException) { - // The canceled client can close the connection while the server drains the request. - } catch (HttpListenerException) { - // The canceled client can close the connection while the server drains the request. + while (await context.Request.InputStream.ReadAsync (buffer, 0, buffer.Length).ConfigureAwait (false) > 0) { } + } catch (IOException) { + // The canceled client can close the connection while the server drains the request. + } catch (HttpListenerException) { + // The canceled client can close the connection while the server drains the request. + } - try { - response.StatusCode = 204; - response.ContentLength64 = 0; - response.Close (); - } catch (IOException) { - // The canceled client can close the connection before the server closes the response. - } catch (HttpListenerException) { - // The canceled client can close the connection before the server closes the response. - } - } catch (Exception ex) { - requestBodyStarted.TrySetException (ex); - throw; + try { + response.StatusCode = 204; + response.ContentLength64 = 0; + response.Close (); + } catch (IOException) { + // The canceled client can close the connection before the server closes the response. + } catch (HttpListenerException) { + // The canceled client can close the connection before the server closes the response. } } @@ -531,6 +525,56 @@ async Task ObserveTaskAfterCleanup (Task task, string taskName, bool completedBe } } + [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 MemoryStream (); + using var cancellationTokenSource = new CancellationTokenSource (); + Task firstCopyTask = Task.CompletedTask; + + try { + firstCopyTask = stream.CopyToAsync (firstDestination, 8, cancellationTokenSource.Token); + var firstWriteCompleted = await Task.WhenAny (stream.FirstWriteCompletedTask, Task.Delay (copyTimeoutMilliseconds)).ConfigureAwait (false); + Assert.AreSame (stream.FirstWriteCompletedTask, firstWriteCompleted, "The controlled stream did not complete its first destination write."); + await stream.FirstWriteCompletedTask.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."); + + 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 (); + + var firstCopyCompleted = await Task.WhenAny (firstCopyTask, Task.Delay (copyTimeoutMilliseconds)).ConfigureAwait (false); + Assert.AreSame (firstCopyTask, firstCopyCompleted, "The first controlled copy did not finish during cleanup."); + try { + await firstCopyTask.ConfigureAwait (false); + } catch (OperationCanceledException) { + } + } + } + [Test] public void ConnectionFailureThrowsHttpRequestException () { @@ -576,5 +620,65 @@ 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"); } + + 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) { + 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; + } + } + } } } From b250e8eb89e536ac68ac094960e5af0b3fcbc460 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 8 Sep 2026 13:04:00 +0200 Subject: [PATCH 3/8] [flaky-ci] Flush upload progress before cancellation Flush the controlled stream's initial destination write before signaling progress and cover buffered destinations that hide unflushed bytes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AndroidMessageHandlerTests.cs | 73 ++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) 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 75522646de3..5f06cb4a810 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 @@ -534,7 +534,7 @@ public async Task ControlledSeekableStreamGatesOnlyFirstCopy () content [i] = (byte) i; using var stream = new ControlledSeekableStream (content); - using var firstDestination = new MemoryStream (); + using var firstDestination = new BufferedDestinationStream (); using var cancellationTokenSource = new CancellationTokenSource (); Task firstCopyTask = Task.CompletedTask; @@ -548,6 +548,9 @@ public async Task ControlledSeekableStreamGatesOnlyFirstCopy () 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 { @@ -667,6 +670,7 @@ async Task CopyToAsyncCore (Stream destination, int bufferSize, CancellationToke 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); @@ -680,5 +684,72 @@ async Task CopyToAsyncCore (Stream destination, int bufferSize, CancellationToke } } } + + 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); + } + } } } From b265019c539d59e92a38469a783c0aceee4e2e95 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 8 Sep 2026 16:49:08 +0200 Subject: [PATCH 4/8] [flaky-ci] Abort canceled request without draining Use a server-observed body prefix before cancellation, abort the first response instead of waiting for EOF, and read exactly Content-Length on retry. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AndroidMessageHandlerTests.cs | 123 ++++++++++++++---- 1 file changed, 99 insertions(+), 24 deletions(-) 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 5f06cb4a810..73f054fb82c 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 @@ -6,6 +6,7 @@ using System.Net.Sockets; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Text; using System.Threading; using System.Threading.Tasks; @@ -364,6 +365,7 @@ public async Task HttpContentStreamIsRewoundAfterCancellation () listener.Start (); var cancellationObserved = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + var firstServerBodyRead = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); var firstServerTask = HandleCancelledRequest (); using var cancellationTokenSource = new CancellationTokenSource (); @@ -386,6 +388,7 @@ public async Task HttpContentStreamIsRewoundAfterCancellation () var firstResponseTask = client.SendAsync (request, cancellationTokenSource.Token); firstRequestTask = firstResponseTask; await WaitForTask (contentStream.FirstWriteCompletedTask, "The first request body did not start uploading.").ConfigureAwait (false); + await WaitForTask (firstServerBodyRead.Task, "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."); @@ -426,6 +429,7 @@ public async Task HttpContentStreamIsRewoundAfterCancellation () 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; @@ -440,6 +444,7 @@ public async Task HttpContentStreamIsRewoundAfterCancellation () 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), @@ -449,28 +454,26 @@ await Task.WhenAll ( async Task HandleCancelledRequest () { - var context = await listener.GetContextAsync ().ConfigureAwait (false); - using var response = context.Response; - var buffer = new byte [4096]; - await cancellationObserved.Task.ConfigureAwait (false); - try { - while (await context.Request.InputStream.ReadAsync (buffer, 0, buffer.Length).ConfigureAwait (false) > 0) { + 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}."); } - } catch (IOException) { - // The canceled client can close the connection while the server drains the request. - } catch (HttpListenerException) { - // The canceled client can close the connection while the server drains the request. - } - try { - response.StatusCode = 204; - response.ContentLength64 = 0; - response.Close (); - } catch (IOException) { - // The canceled client can close the connection before the server closes the response. - } catch (HttpListenerException) { - // The canceled client can close the connection before the server closes the response. + firstServerBodyRead.TrySetResult (bytesRead); + await cancellationObserved.Task.ConfigureAwait (false); + response.Abort (); + } catch (Exception ex) { + firstServerBodyRead.TrySetException (ex); + throw; } } @@ -481,11 +484,10 @@ async Task HandleRetryRequest () Assert.AreEqual (requestBody.Length, context.Request.ContentLength64, "The retry request declared an unexpected content length."); var buffer = new byte [4096]; int totalBytesRead = 0; - int bytesRead; - while ((bytesRead = await context.Request.InputStream.ReadAsync (buffer, 0, buffer.Length).ConfigureAwait (false)) > 0) { - if (totalBytesRead + bytesRead > requestBody.Length) - Assert.Fail ($"The retry request body exceeded the expected {requestBody.Length} bytes."); - + 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}."); @@ -578,6 +580,79 @@ public async Task ControlledSeekableStreamGatesOnlyFirstCopy () } } + [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); + + var contextCompleted = await Task.WhenAny (contextTask, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); + Assert.AreSame (contextTask, contextCompleted, "The listener did not accept the partial fixed-length request."); + 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 (); + var readCompleted = await Task.WhenAny (pendingReadTask, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); + Assert.AreSame (pendingReadTask, readCompleted, "Aborting the response did not terminate the pending request body read."); + try { + 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 (); + var readCompleted = await Task.WhenAny (pendingReadTask, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); + Assert.AreSame (pendingReadTask, readCompleted, "The pending request body read did not finish during cleanup."); + try { + await pendingReadTask.ConfigureAwait (false); + } catch (IOException) { + } catch (HttpListenerException) { + } catch (ObjectDisposedException) { + } + } + } + [Test] public void ConnectionFailureThrowsHttpRequestException () { From 3070333cf858bfe589133d3a31ae61fc5d31b722 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 8 Sep 2026 21:54:25 +0200 Subject: [PATCH 5/8] [flaky-ci] Run cancellation rewind tests in NativeAOT Move the cancellation rewind regression and its focused helpers out of the SSL-tagged handler fixture so PublishAot lanes execute them. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Mono.Android.NET-Tests.csproj | 1 + ...dMessageHandlerCancellationTests.Rewind.cs | 448 ++++++++++++++++++ .../AndroidMessageHandlerCancellationTests.cs | 2 +- .../AndroidMessageHandlerTests.cs | 428 ----------------- 4 files changed, 450 insertions(+), 429 deletions(-) create mode 100644 tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerCancellationTests.Rewind.cs 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..774e1454495 --- /dev/null +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerCancellationTests.Rewind.cs @@ -0,0 +1,448 @@ +#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 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 requestBody = new byte [requestContentLength]; + for (int i = 0; i < requestBody.Length; i++) + requestBody [i] = (byte) (i % 251); + 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, "The first request body did not start uploading.").ConfigureAwait (false); + await WaitForTask (firstServerBodyRead.Task, "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 (); + var completedTask = await Task.WhenAny (firstResponseTask, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); + if (completedTask != firstResponseTask) { + cancellationObserved.TrySetResult (true); + await WaitForTask (firstServerTask, "The first server handler did not finish after releasing the request body.").ConfigureAwait (false); + Assert.Fail ($"The first request did not observe cancellation within {requestTimeoutMilliseconds}ms."); + } + + try { + using var firstResponse = await firstResponseTask.ConfigureAwait (false); + Assert.Fail ("The first request completed successfully instead of observing cancellation."); + } catch (OperationCanceledException) { + cancellationObserved.TrySetResult (true); + } + + cancellationObserved.TrySetResult (true); + await WaitForTask (firstServerTask, "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, "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 WaitForTask (Task task, string failureMessage) + { + var completed = await Task.WhenAny (task, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); + if (completed != task) + Assert.Fail ($"{failureMessage} Timeout: {requestTimeoutMilliseconds}ms."); + + await task.ConfigureAwait (false); + } + + async Task ObserveTaskAfterCleanup (Task task, string taskName, bool completedBeforeCleanup, bool cancellationExpected, bool listenerAbortExpected) + { + var completed = await Task.WhenAny (task, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); + if (completed != task) + Assert.Fail ($"The {taskName} did not finish during cleanup within {requestTimeoutMilliseconds}ms."); + + try { + await task.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); + var firstWriteCompleted = await Task.WhenAny (stream.FirstWriteCompletedTask, Task.Delay (copyTimeoutMilliseconds)).ConfigureAwait (false); + Assert.AreSame (stream.FirstWriteCompletedTask, firstWriteCompleted, "The controlled stream did not complete its first destination write."); + await stream.FirstWriteCompletedTask.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 (); + + var firstCopyCompleted = await Task.WhenAny (firstCopyTask, Task.Delay (copyTimeoutMilliseconds)).ConfigureAwait (false); + Assert.AreSame (firstCopyTask, firstCopyCompleted, "The first controlled copy did not finish during cleanup."); + try { + await firstCopyTask.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); + + var contextCompleted = await Task.WhenAny (contextTask, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); + Assert.AreSame (contextTask, contextCompleted, "The listener did not accept the partial fixed-length request."); + 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 (); + var readCompleted = await Task.WhenAny (pendingReadTask, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); + Assert.AreSame (pendingReadTask, readCompleted, "Aborting the response did not terminate the pending request body read."); + try { + 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 (); + var readCompleted = await Task.WhenAny (pendingReadTask, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); + Assert.AreSame (pendingReadTask, readCompleted, "The pending request body read did not finish during cleanup."); + try { + await pendingReadTask.ConfigureAwait (false); + } catch (IOException) { + } catch (HttpListenerException) { + } catch (ObjectDisposedException) { + } + } + } + + 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 73f054fb82c..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 @@ -6,8 +6,6 @@ using System.Net.Sockets; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -using System.Text; -using System.Threading; using System.Threading.Tasks; using Android.Runtime; @@ -353,305 +351,6 @@ private static X509Certificate2 BuildClientCertificate () return clientCert; } - [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 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 requestBody = new byte [requestContentLength]; - for (int i = 0; i < requestBody.Length; i++) - requestBody [i] = (byte) (i % 251); - 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, "The first request body did not start uploading.").ConfigureAwait (false); - await WaitForTask (firstServerBodyRead.Task, "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 (); - var completedTask = await Task.WhenAny (firstResponseTask, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); - if (completedTask != firstResponseTask) { - cancellationObserved.TrySetResult (true); - await WaitForTask (firstServerTask, "The first server handler did not finish after releasing the request body.").ConfigureAwait (false); - Assert.Fail ($"The first request did not observe cancellation within {requestTimeoutMilliseconds}ms."); - } - - try { - using var firstResponse = await firstResponseTask.ConfigureAwait (false); - Assert.Fail ("The first request completed successfully instead of observing cancellation."); - } catch (OperationCanceledException) { - cancellationObserved.TrySetResult (true); - } - - cancellationObserved.TrySetResult (true); - await WaitForTask (firstServerTask, "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, "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 WaitForTask (Task task, string failureMessage) - { - var completed = await Task.WhenAny (task, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); - if (completed != task) - Assert.Fail ($"{failureMessage} Timeout: {requestTimeoutMilliseconds}ms."); - - await task.ConfigureAwait (false); - } - - async Task ObserveTaskAfterCleanup (Task task, string taskName, bool completedBeforeCleanup, bool cancellationExpected, bool listenerAbortExpected) - { - var completed = await Task.WhenAny (task, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); - if (completed != task) - Assert.Fail ($"The {taskName} did not finish during cleanup within {requestTimeoutMilliseconds}ms."); - - try { - await task.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); - var firstWriteCompleted = await Task.WhenAny (stream.FirstWriteCompletedTask, Task.Delay (copyTimeoutMilliseconds)).ConfigureAwait (false); - Assert.AreSame (stream.FirstWriteCompletedTask, firstWriteCompleted, "The controlled stream did not complete its first destination write."); - await stream.FirstWriteCompletedTask.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 (); - - var firstCopyCompleted = await Task.WhenAny (firstCopyTask, Task.Delay (copyTimeoutMilliseconds)).ConfigureAwait (false); - Assert.AreSame (firstCopyTask, firstCopyCompleted, "The first controlled copy did not finish during cleanup."); - try { - await firstCopyTask.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); - - var contextCompleted = await Task.WhenAny (contextTask, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); - Assert.AreSame (contextTask, contextCompleted, "The listener did not accept the partial fixed-length request."); - 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 (); - var readCompleted = await Task.WhenAny (pendingReadTask, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); - Assert.AreSame (pendingReadTask, readCompleted, "Aborting the response did not terminate the pending request body read."); - try { - 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 (); - var readCompleted = await Task.WhenAny (pendingReadTask, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); - Assert.AreSame (pendingReadTask, readCompleted, "The pending request body read did not finish during cleanup."); - try { - await pendingReadTask.ConfigureAwait (false); - } catch (IOException) { - } catch (HttpListenerException) { - } catch (ObjectDisposedException) { - } - } - } [Test] public void ConnectionFailureThrowsHttpRequestException () @@ -699,132 +398,5 @@ public void ExceedingMaxAutomaticRedirectionsThrowsHttpRequestException () Assert.AreEqual (WebExceptionStatus.UnknownError, inner.Status, "Inner WebException should preserve UnknownError status"); } - 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); - } - } } } From a78db1b84ba3cd38f8e4576cc05c73fde04ca3f5 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 9 Sep 2026 17:12:42 +0200 Subject: [PATCH 6/8] [flaky-ci] Use bounded async waits in cancellation tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...dMessageHandlerCancellationTests.Rewind.cs | 60 +++++++------------ 1 file changed, 22 insertions(+), 38 deletions(-) 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 index 774e1454495..c0402d30e38 100644 --- 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 @@ -51,20 +51,14 @@ public async Task HttpContentStreamIsRewoundAfterCancellation () var firstResponseTask = client.SendAsync (request, cancellationTokenSource.Token); firstRequestTask = firstResponseTask; - await WaitForTask (contentStream.FirstWriteCompletedTask, "The first request body did not start uploading.").ConfigureAwait (false); - await WaitForTask (firstServerBodyRead.Task, "The first server handler did not receive the request body prefix.").ConfigureAwait (false); + 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 (); - var completedTask = await Task.WhenAny (firstResponseTask, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); - if (completedTask != firstResponseTask) { - cancellationObserved.TrySetResult (true); - await WaitForTask (firstServerTask, "The first server handler did not finish after releasing the request body.").ConfigureAwait (false); - Assert.Fail ($"The first request did not observe cancellation within {requestTimeoutMilliseconds}ms."); - } - 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) { @@ -72,7 +66,7 @@ public async Task HttpContentStreamIsRewoundAfterCancellation () } cancellationObserved.TrySetResult (true); - await WaitForTask (firstServerTask, "The first server handler did not finish after cancellation.").ConfigureAwait (false); + 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)"); @@ -83,7 +77,7 @@ public async Task HttpContentStreamIsRewoundAfterCancellation () var retryResponseTask = client.SendAsync (retryRequest, retryCancellationTokenSource.Token); retryRequestTask = retryResponseTask; var retryTasks = Task.WhenAll (retryResponseTask, retryServerTask); - await WaitForTask (retryTasks, "The retry request and server handler did not finish.").ConfigureAwait (false); + 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"); @@ -166,23 +160,10 @@ async Task HandleRetryRequest () response.Close (); } - async Task WaitForTask (Task task, string failureMessage) - { - var completed = await Task.WhenAny (task, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); - if (completed != task) - Assert.Fail ($"{failureMessage} Timeout: {requestTimeoutMilliseconds}ms."); - - await task.ConfigureAwait (false); - } - async Task ObserveTaskAfterCleanup (Task task, string taskName, bool completedBeforeCleanup, bool cancellationExpected, bool listenerAbortExpected) { - var completed = await Task.WhenAny (task, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); - if (completed != task) - Assert.Fail ($"The {taskName} did not finish during cleanup within {requestTimeoutMilliseconds}ms."); - try { - await task.ConfigureAwait (false); + 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) { @@ -206,9 +187,7 @@ public async Task ControlledSeekableStreamGatesOnlyFirstCopy () try { firstCopyTask = stream.CopyToAsync (firstDestination, 8, cancellationTokenSource.Token); - var firstWriteCompleted = await Task.WhenAny (stream.FirstWriteCompletedTask, Task.Delay (copyTimeoutMilliseconds)).ConfigureAwait (false); - Assert.AreSame (stream.FirstWriteCompletedTask, firstWriteCompleted, "The controlled stream did not complete its first destination write."); - await stream.FirstWriteCompletedTask.ConfigureAwait (false); + 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."); @@ -235,10 +214,8 @@ public async Task ControlledSeekableStreamGatesOnlyFirstCopy () cancellationTokenSource.Cancel (); stream.ReleaseFirstCopy (); - var firstCopyCompleted = await Task.WhenAny (firstCopyTask, Task.Delay (copyTimeoutMilliseconds)).ConfigureAwait (false); - Assert.AreSame (firstCopyTask, firstCopyCompleted, "The first controlled copy did not finish during cleanup."); try { - await firstCopyTask.ConfigureAwait (false); + await WaitForTask (firstCopyTask, copyTimeoutMilliseconds, "The first controlled copy did not finish during cleanup.").ConfigureAwait (false); } catch (OperationCanceledException) { } } @@ -267,8 +244,7 @@ public async Task HttpListenerAbortCompletesPendingRequestBodyRead () await clientStream.WriteAsync (bodyPrefix, 0, bodyPrefix.Length).ConfigureAwait (false); await clientStream.FlushAsync ().ConfigureAwait (false); - var contextCompleted = await Task.WhenAny (contextTask, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); - Assert.AreSame (contextTask, contextCompleted, "The listener did not accept the partial fixed-length request."); + 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); @@ -294,9 +270,8 @@ public async Task HttpListenerAbortCompletesPendingRequestBodyRead () Assert.AreNotSame (pendingReadTask, prematureCompletion, "The request body read should remain pending while the client keeps the incomplete request open."); response.Abort (); - var readCompleted = await Task.WhenAny (pendingReadTask, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); - Assert.AreSame (pendingReadTask, readCompleted, "Aborting the response did not terminate the pending request body read."); 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) { @@ -306,10 +281,8 @@ public async Task HttpListenerAbortCompletesPendingRequestBodyRead () } finally { response.Abort (); listener.Abort (); - var readCompleted = await Task.WhenAny (pendingReadTask, Task.Delay (requestTimeoutMilliseconds)).ConfigureAwait (false); - Assert.AreSame (pendingReadTask, readCompleted, "The pending request body read did not finish during cleanup."); try { - await pendingReadTask.ConfigureAwait (false); + await WaitForTask (pendingReadTask, requestTimeoutMilliseconds, "The pending request body read did not finish during cleanup.").ConfigureAwait (false); } catch (IOException) { } catch (HttpListenerException) { } catch (ObjectDisposedException) { @@ -317,6 +290,17 @@ public async Task HttpListenerAbortCompletesPendingRequestBodyRead () } } + 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); From 287ccf735f6b7816640945bfb3059385ddd577ed Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 9 Sep 2026 17:13:49 +0200 Subject: [PATCH 7/8] [flaky-ci] Remove duplicate cancellation signal Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AndroidMessageHandlerCancellationTests.Rewind.cs | 1 - 1 file changed, 1 deletion(-) 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 index c0402d30e38..bc3ccfbcd94 100644 --- 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 @@ -62,7 +62,6 @@ public async Task HttpContentStreamIsRewoundAfterCancellation () using var firstResponse = await firstResponseTask.ConfigureAwait (false); Assert.Fail ("The first request completed successfully instead of observing cancellation."); } catch (OperationCanceledException) { - cancellationObserved.TrySetResult (true); } cancellationObserved.TrySetResult (true); From 85e3c6186d9d2f90bd75e1f2a066727e185a48d7 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 11 Sep 2026 17:44:00 +0200 Subject: [PATCH 8/8] [flaky-ci] Initialize rewind request body before server Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AndroidMessageHandlerCancellationTests.Rewind.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index bc3ccfbcd94..2c7c9d945ad 100644 --- 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 @@ -28,6 +28,9 @@ public async Task HttpContentStreamIsRewoundAfterCancellation () 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 (); @@ -35,9 +38,6 @@ public async Task HttpContentStreamIsRewoundAfterCancellation () using var cancellationTokenSource = new CancellationTokenSource (); using var retryCancellationTokenSource = new CancellationTokenSource (); using var client = new HttpClient (new AndroidMessageHandler ()); - var requestBody = new byte [requestContentLength]; - for (int i = 0; i < requestBody.Length; i++) - requestBody [i] = (byte) (i % 251); var contentStream = new ControlledSeekableStream (requestBody); using var content = new StreamContent (contentStream); using var request = new HttpRequestMessage (HttpMethod.Post, $"http://localhost:{testPort}/") { Content = content };