From f20a13db192e6fdcd549695615e59b930c9f283e Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 1 Jun 2026 14:40:35 +0200 Subject: [PATCH 1/8] Fix AndroidMessageHandler response body cancellation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AndroidMessageHandler.cs | 131 +++++++++++- .../Mono.Android.NET-Tests.csproj | 1 + .../AndroidMessageHandlerCancellationTests.cs | 198 ++++++++++++++++++ 3 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerCancellationTests.cs diff --git a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs index 000c0d5f7c5..4923ccc4af5 100644 --- a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs +++ b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs @@ -104,6 +104,133 @@ public void Reset () } } + sealed class CancellationAwareResponseStream : Stream + { + readonly Stream stream; + readonly HttpURLConnection httpConnection; + + public CancellationAwareResponseStream (Stream stream, HttpURLConnection httpConnection) + { + this.stream = stream ?? throw new ArgumentNullException (nameof (stream)); + this.httpConnection = httpConnection ?? throw new ArgumentNullException (nameof (httpConnection)); + } + + public override bool CanRead => stream.CanRead; + public override bool CanSeek => stream.CanSeek; + public override bool CanWrite => stream.CanWrite; + public override long Length => stream.Length; + + public override long Position { + get => stream.Position; + set => stream.Position = value; + } + + protected override void Dispose (bool disposing) + { + if (disposing) { + stream.Dispose (); + httpConnection.Dispose (); + } + + base.Dispose (disposing); + } + + public override void Flush () + { + stream.Flush (); + } + + public override async Task CopyToAsync (Stream destination, int bufferSize, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested (); + + using (cancellationToken.Register (QueueAbortRead, useSynchronizationContext: false)) { + try { + await stream.CopyToAsync (destination, bufferSize, cancellationToken).ConfigureAwait (false); + } catch (global::System.OperationCanceledException) when (cancellationToken.IsCancellationRequested) { + throw; + } catch (Exception ex) when (ShouldMapToCancellation (ex, cancellationToken)) { + throw new global::System.OperationCanceledException ("Response body read was canceled.", ex, cancellationToken); + } + } + } + + public override int Read (byte[] buffer, int offset, int count) + { + return stream.Read (buffer, offset, count); + } + + public override Task ReadAsync (byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return ReadAsync (buffer.AsMemory (offset, count), cancellationToken).AsTask (); + } + + // StreamContent uses this overload on modern runtimes, so the wrapper must handle its ValueTask-based contract. + public override async ValueTask ReadAsync (Memory buffer, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested (); + + using (cancellationToken.Register (QueueAbortRead, useSynchronizationContext: false)) { + try { + return await stream.ReadAsync (buffer, cancellationToken).ConfigureAwait (false); + } catch (global::System.OperationCanceledException) when (cancellationToken.IsCancellationRequested) { + throw; + } catch (Exception ex) when (ShouldMapToCancellation (ex, cancellationToken)) { + throw new global::System.OperationCanceledException ("Response body read was canceled.", ex, cancellationToken); + } + } + } + + public override long Seek (long offset, SeekOrigin origin) + { + return stream.Seek (offset, origin); + } + + public override void SetLength (long value) + { + stream.SetLength (value); + } + + public override void Write (byte[] buffer, int offset, int count) + { + stream.Write (buffer, offset, count); + } + + void QueueAbortRead () + { + Task.Run (AbortRead).ContinueWith (t => { + if (t.Exception != null) + Logger.Log (LogLevel.Info, LOG_APP, $"Response body cancellation exception: {t.Exception}"); + }, TaskScheduler.Default); + } + + void AbortRead () + { + try { + httpConnection.Disconnect (); + } catch (Exception ex) { + Logger.Log (LogLevel.Info, LOG_APP, $"Disconnection exception: {ex}"); + } + + try { + stream.Dispose (); + } catch (Exception ex) { + Logger.Log (LogLevel.Info, LOG_APP, $"Response stream close exception: {ex}"); + } + } + + static bool ShouldMapToCancellation (Exception ex, CancellationToken cancellationToken) + { + return cancellationToken.IsCancellationRequested && ( + ex is global::System.IO.IOException || + ex is Java.IO.IOException || + ex is InvalidDataException || + ex is ObjectDisposedException || + ex is WebException + ); + } + } + internal const string LOG_APP = "monodroid-net"; const string GZIP_ENCODING = "gzip"; @@ -903,10 +1030,10 @@ Stream GetDecompressionWrapper (URLConnection httpConnection, Stream inputStream return ret ?? inputStream; } - HttpContent GetContent (URLConnection httpConnection, Stream contentStream, ContentState contentState) + HttpContent GetContent (HttpURLConnection httpConnection, Stream contentStream, ContentState contentState) { Stream inputStream = GetDecompressionWrapper (httpConnection, new BufferedStream (contentStream), contentState); - return new StreamContent (inputStream); + return new StreamContent (new CancellationAwareResponseStream (inputStream, httpConnection)); } bool HandleRedirect (HttpStatusCode redirectCode, HttpURLConnection httpConnection, RequestRedirectionState redirectState, out bool disposeRet) 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 c916be26314..a69ce7d5b53 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 @@ -136,6 +136,7 @@ + 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 new file mode 100644 index 00000000000..5050e05a53a --- /dev/null +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerCancellationTests.cs @@ -0,0 +1,198 @@ +#nullable enable + +using System; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +using Xamarin.Android.Net; + +using NUnit.Framework; + +namespace Xamarin.Android.NetTests +{ + [TestFixture] + [Category ("AndroidMessageHandlerCancellation")] + [Category ("InetAccess")] + public class AndroidMessageHandlerCancellationTests + { + const int StalledResponseContentLength = 1024 * 1024; + const int BodyReadBlockDelayMilliseconds = 250; + const int PromptCancellationTimeoutMilliseconds = 3000; + + static readonly byte[] InitialResponseChunk = new byte[] { 42 }; + + StalledResponseServer? stalledResponseServer; + + [SetUp] + public void SetUp () + { + stalledResponseServer = new StalledResponseServer (); + } + + [TearDown] + public void TearDown () + { + var server = stalledResponseServer; + stalledResponseServer = null; + + if (server != null) + server.StopAsync ().GetAwaiter ().GetResult (); + } + + [Test] + public async Task ResponseContentReadBodyReadCancellationIsPrompt () + { + var server = stalledResponseServer ?? throw new InvalidOperationException ("The stalled response server was not initialized."); + using var handler = new AndroidMessageHandler (); + using var client = new HttpClient (handler); + using var cts = new CancellationTokenSource (); + using var request = new HttpRequestMessage (HttpMethod.Get, $"http://localhost:{server.Port}/"); + + Task readTask = client.SendAsync (request, HttpCompletionOption.ResponseContentRead, cts.Token); + + await WaitForBodyReadToBlock (server.BodyStartedTask).ConfigureAwait (false); + cts.Cancel (); + await AssertCanceledPromptly (readTask, server.ReleaseBody).ConfigureAwait (false); + } + + [Test] + public async Task ResponseHeadersReadBodyReadCancellationIsPrompt () + { + var server = stalledResponseServer ?? throw new InvalidOperationException ("The stalled response server was not initialized."); + using var handler = new AndroidMessageHandler (); + using var client = new HttpClient (handler); + using var request = new HttpRequestMessage (HttpMethod.Get, $"http://localhost:{server.Port}/"); + using var response = await client.SendAsync (request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait (false); + using var readCts = new CancellationTokenSource (); + + Task readContentTask = response.Content.ReadAsByteArrayAsync (readCts.Token); + + await WaitForBodyReadToBlock (server.BodyStartedTask).ConfigureAwait (false); + readCts.Cancel (); + await AssertCanceledPromptly (readContentTask, server.ReleaseBody).ConfigureAwait (false); + } + + static int GetAvailablePort () + { + using var tcpListener = new TcpListener (IPAddress.Any, 0); + tcpListener.Start (); + int port = ((IPEndPoint) tcpListener.LocalEndpoint).Port; + tcpListener.Stop (); + return port; + } + + static async Task WriteRemainingResponseBody (HttpListenerResponse response) + { + var buffer = new byte [4096]; + int remainingBytes = StalledResponseContentLength - InitialResponseChunk.Length; + while (remainingBytes > 0) { + int bytesToWrite = Math.Min (remainingBytes, buffer.Length); + await response.OutputStream.WriteAsync (buffer, 0, bytesToWrite).ConfigureAwait (false); + remainingBytes -= bytesToWrite; + } + } + + static async Task WaitForBodyReadToBlock (Task bodyStarted) + { + var completed = await Task.WhenAny (bodyStarted, Task.Delay (PromptCancellationTimeoutMilliseconds)).ConfigureAwait (false); + if (completed != bodyStarted) + Assert.Fail ($"The test server did not start sending a response body within {PromptCancellationTimeoutMilliseconds}ms."); + + await bodyStarted.ConfigureAwait (false); + await Task.Delay (BodyReadBlockDelayMilliseconds).ConfigureAwait (false); + } + + static async Task AssertCanceledPromptly (Task readTask, TaskCompletionSource releaseBody) + { + var completed = await Task.WhenAny (readTask, Task.Delay (PromptCancellationTimeoutMilliseconds)).ConfigureAwait (false); + if (completed != readTask) { + releaseBody.TrySetResult (true); + await ObserveReadTaskAfterRelease (readTask).ConfigureAwait (false); + Assert.Fail ($"Response body read did not observe cancellation within {PromptCancellationTimeoutMilliseconds}ms."); + } + + try { + await readTask.ConfigureAwait (false); + Assert.Fail ("Response body read completed successfully after cancellation."); + } catch (OperationCanceledException) { + } + } + + static async Task ObserveReadTaskAfterRelease (Task readTask) + { + var completed = await Task.WhenAny (readTask, Task.Delay (PromptCancellationTimeoutMilliseconds)).ConfigureAwait (false); + if (completed != readTask) + return; + + try { + await readTask.ConfigureAwait (false); + } catch (Exception ex) { + Console.WriteLine ($"Exception after releasing stalled response body: {ex}"); + } + } + + static async Task ObserveServerTask (Task serverTask) + { + var completed = await Task.WhenAny (serverTask, Task.Delay (PromptCancellationTimeoutMilliseconds)).ConfigureAwait (false); + if (completed != serverTask) + return; + + await serverTask.ConfigureAwait (false); + } + + sealed class StalledResponseServer + { + readonly HttpListener listener; + readonly TaskCompletionSource bodyStarted = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + readonly Task serverTask; + + public StalledResponseServer () + { + Port = GetAvailablePort (); + listener = new HttpListener (); + listener.Prefixes.Add ($"http://+:{Port}/"); + listener.Start (); + + serverTask = ServeStalledResponseBody (); + } + + public int Port { get; } + + public Task BodyStartedTask => bodyStarted.Task; + + public TaskCompletionSource ReleaseBody { get; } = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + + public async Task StopAsync () + { + ReleaseBody.TrySetResult (true); + listener.Close (); + await ObserveServerTask (serverTask).ConfigureAwait (false); + } + + async Task ServeStalledResponseBody () + { + try { + var context = await listener.GetContextAsync ().ConfigureAwait (false); + using var response = context.Response; + response.StatusCode = 200; + response.ContentLength64 = StalledResponseContentLength; + await response.OutputStream.WriteAsync (InitialResponseChunk, 0, InitialResponseChunk.Length).ConfigureAwait (false); + await response.OutputStream.FlushAsync ().ConfigureAwait (false); + bodyStarted.TrySetResult (true); + + await ReleaseBody.Task.ConfigureAwait (false); + await WriteRemainingResponseBody (response).ConfigureAwait (false); + } catch (Exception ex) { + if (!BodyStartedTask.IsCompleted) { + bodyStarted.TrySetException (ex); + return; + } + Console.WriteLine ($"Exception while serving stalled response body: {ex}"); + } + } + } + } +} From 385d14fd003847ecb45803d1a128eee3e1009f0a Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 1 Jun 2026 15:03:21 +0200 Subject: [PATCH 2/8] Simplify AndroidMessageHandler cancellation changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AndroidMessageHandler.cs | 18 +++--- .../AndroidMessageHandlerCancellationTests.cs | 63 ++++++++++--------- 2 files changed, 44 insertions(+), 37 deletions(-) diff --git a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs index 4923ccc4af5..da046bb119c 100644 --- a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs +++ b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs @@ -147,8 +147,6 @@ public override async Task CopyToAsync (Stream destination, int bufferSize, Canc using (cancellationToken.Register (QueueAbortRead, useSynchronizationContext: false)) { try { await stream.CopyToAsync (destination, bufferSize, cancellationToken).ConfigureAwait (false); - } catch (global::System.OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - throw; } catch (Exception ex) when (ShouldMapToCancellation (ex, cancellationToken)) { throw new global::System.OperationCanceledException ("Response body read was canceled.", ex, cancellationToken); } @@ -173,8 +171,6 @@ public override async ValueTask ReadAsync (Memory buffer, Cancellatio using (cancellationToken.Register (QueueAbortRead, useSynchronizationContext: false)) { try { return await stream.ReadAsync (buffer, cancellationToken).ConfigureAwait (false); - } catch (global::System.OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - throw; } catch (Exception ex) when (ShouldMapToCancellation (ex, cancellationToken)) { throw new global::System.OperationCanceledException ("Response body read was canceled.", ex, cancellationToken); } @@ -198,10 +194,11 @@ public override void Write (byte[] buffer, int offset, int count) void QueueAbortRead () { - Task.Run (AbortRead).ContinueWith (t => { - if (t.Exception != null) - Logger.Log (LogLevel.Info, LOG_APP, $"Response body cancellation exception: {t.Exception}"); - }, TaskScheduler.Default); + Task.Run (AbortRead).ContinueWith ( + LogAbortReadException, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); } void AbortRead () @@ -229,6 +226,11 @@ ex is ObjectDisposedException || ex is WebException ); } + + static void LogAbortReadException (Task task) + { + Logger.Log (LogLevel.Info, LOG_APP, $"Response body cancellation exception: {task.Exception}"); + } } internal const string LOG_APP = "monodroid-net"; 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 5050e05a53a..45fee5dd898 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 @@ -55,7 +55,7 @@ public async Task ResponseContentReadBodyReadCancellationIsPrompt () await WaitForBodyReadToBlock (server.BodyStartedTask).ConfigureAwait (false); cts.Cancel (); - await AssertCanceledPromptly (readTask, server.ReleaseBody).ConfigureAwait (false); + await AssertCanceledPromptly (readTask, server.ReleaseResponseBody).ConfigureAwait (false); } [Test] @@ -72,7 +72,7 @@ public async Task ResponseHeadersReadBodyReadCancellationIsPrompt () await WaitForBodyReadToBlock (server.BodyStartedTask).ConfigureAwait (false); readCts.Cancel (); - await AssertCanceledPromptly (readContentTask, server.ReleaseBody).ConfigureAwait (false); + await AssertCanceledPromptly (readContentTask, server.ReleaseResponseBody).ConfigureAwait (false); } static int GetAvailablePort () @@ -84,17 +84,6 @@ static int GetAvailablePort () return port; } - static async Task WriteRemainingResponseBody (HttpListenerResponse response) - { - var buffer = new byte [4096]; - int remainingBytes = StalledResponseContentLength - InitialResponseChunk.Length; - while (remainingBytes > 0) { - int bytesToWrite = Math.Min (remainingBytes, buffer.Length); - await response.OutputStream.WriteAsync (buffer, 0, bytesToWrite).ConfigureAwait (false); - remainingBytes -= bytesToWrite; - } - } - static async Task WaitForBodyReadToBlock (Task bodyStarted) { var completed = await Task.WhenAny (bodyStarted, Task.Delay (PromptCancellationTimeoutMilliseconds)).ConfigureAwait (false); @@ -105,11 +94,11 @@ static async Task WaitForBodyReadToBlock (Task bodyStarted) await Task.Delay (BodyReadBlockDelayMilliseconds).ConfigureAwait (false); } - static async Task AssertCanceledPromptly (Task readTask, TaskCompletionSource releaseBody) + static async Task AssertCanceledPromptly (Task readTask, Action releaseBody) { var completed = await Task.WhenAny (readTask, Task.Delay (PromptCancellationTimeoutMilliseconds)).ConfigureAwait (false); if (completed != readTask) { - releaseBody.TrySetResult (true); + releaseBody (); await ObserveReadTaskAfterRelease (readTask).ConfigureAwait (false); Assert.Fail ($"Response body read did not observe cancellation within {PromptCancellationTimeoutMilliseconds}ms."); } @@ -118,6 +107,7 @@ static async Task AssertCanceledPromptly (Task readTask, TaskCompletionSource bodyStarted = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + readonly TaskCompletionSource releaseBody = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); readonly Task serverTask; public StalledResponseServer () @@ -163,13 +145,16 @@ public StalledResponseServer () public Task BodyStartedTask => bodyStarted.Task; - public TaskCompletionSource ReleaseBody { get; } = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); - public async Task StopAsync () { - ReleaseBody.TrySetResult (true); + ReleaseResponseBody (); listener.Close (); - await ObserveServerTask (serverTask).ConfigureAwait (false); + await ObserveServerTask ().ConfigureAwait (false); + } + + public void ReleaseResponseBody () + { + releaseBody.TrySetResult (true); } async Task ServeStalledResponseBody () @@ -183,7 +168,7 @@ async Task ServeStalledResponseBody () await response.OutputStream.FlushAsync ().ConfigureAwait (false); bodyStarted.TrySetResult (true); - await ReleaseBody.Task.ConfigureAwait (false); + await releaseBody.Task.ConfigureAwait (false); await WriteRemainingResponseBody (response).ConfigureAwait (false); } catch (Exception ex) { if (!BodyStartedTask.IsCompleted) { @@ -193,6 +178,26 @@ async Task ServeStalledResponseBody () Console.WriteLine ($"Exception while serving stalled response body: {ex}"); } } + + async Task WriteRemainingResponseBody (HttpListenerResponse response) + { + var buffer = new byte [4096]; + int remainingBytes = StalledResponseContentLength - InitialResponseChunk.Length; + while (remainingBytes > 0) { + int bytesToWrite = Math.Min (remainingBytes, buffer.Length); + await response.OutputStream.WriteAsync (buffer, 0, bytesToWrite).ConfigureAwait (false); + remainingBytes -= bytesToWrite; + } + } + + async Task ObserveServerTask () + { + var completed = await Task.WhenAny (serverTask, Task.Delay (PromptCancellationTimeoutMilliseconds)).ConfigureAwait (false); + if (completed != serverTask) + return; + + await serverTask.ConfigureAwait (false); + } } } } From bf883973a478f6ab3332d3de64f8b36d129cae17 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 1 Jun 2026 15:48:54 +0200 Subject: [PATCH 3/8] Address AndroidMessageHandler review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Xamarin.Android.Net/AndroidMessageHandler.cs | 12 +++++++++--- .../AndroidMessageHandlerCancellationTests.cs | 11 ++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs index da046bb119c..25c0135cd4d 100644 --- a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs +++ b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs @@ -108,6 +108,7 @@ sealed class CancellationAwareResponseStream : Stream { readonly Stream stream; readonly HttpURLConnection httpConnection; + int streamDisposed; public CancellationAwareResponseStream (Stream stream, HttpURLConnection httpConnection) { @@ -128,8 +129,7 @@ public override long Position { protected override void Dispose (bool disposing) { if (disposing) { - stream.Dispose (); - httpConnection.Dispose (); + DisposeStream (); } base.Dispose (disposing); @@ -210,12 +210,18 @@ void AbortRead () } try { - stream.Dispose (); + DisposeStream (); } catch (Exception ex) { Logger.Log (LogLevel.Info, LOG_APP, $"Response stream close exception: {ex}"); } } + void DisposeStream () + { + if (Interlocked.Exchange (ref streamDisposed, 1) == 0) + stream.Dispose (); + } + static bool ShouldMapToCancellation (Exception ex, CancellationToken cancellationToken) { return cancellationToken.IsCancellationRequested && ( 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 45fee5dd898..60833ff7ffb 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 @@ -38,8 +38,9 @@ public void TearDown () var server = stalledResponseServer; stalledResponseServer = null; + // NUnitLite used by the on-device tests does not support async TearDown methods. if (server != null) - server.StopAsync ().GetAwaiter ().GetResult (); + server.Stop (); } [Test] @@ -77,7 +78,7 @@ public async Task ResponseHeadersReadBodyReadCancellationIsPrompt () static int GetAvailablePort () { - using var tcpListener = new TcpListener (IPAddress.Any, 0); + using var tcpListener = new TcpListener (IPAddress.Loopback, 0); tcpListener.Start (); int port = ((IPEndPoint) tcpListener.LocalEndpoint).Port; tcpListener.Stop (); @@ -135,7 +136,7 @@ public StalledResponseServer () { Port = GetAvailablePort (); listener = new HttpListener (); - listener.Prefixes.Add ($"http://+:{Port}/"); + listener.Prefixes.Add ($"http://localhost:{Port}/"); listener.Start (); serverTask = ServeStalledResponseBody (); @@ -145,11 +146,11 @@ public StalledResponseServer () public Task BodyStartedTask => bodyStarted.Task; - public async Task StopAsync () + public void Stop () { ReleaseResponseBody (); listener.Close (); - await ObserveServerTask ().ConfigureAwait (false); + ObserveServerTask ().GetAwaiter ().GetResult (); } public void ReleaseResponseBody () From f520475faeb6801b8be879b6695e9e3336cb5e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Rozs=C3=ADval?= Date: Mon, 1 Jun 2026 17:14:22 +0200 Subject: [PATCH 4/8] Apply suggestions from code review Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../Xamarin.Android.Net/AndroidMessageHandler.cs | 8 ++++++++ .../AndroidMessageHandlerCancellationTests.cs | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs index 25c0135cd4d..23dbfc97bb5 100644 --- a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs +++ b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs @@ -223,6 +223,14 @@ void DisposeStream () } static bool ShouldMapToCancellation (Exception ex, CancellationToken cancellationToken) + { + return cancellationToken.IsCancellationRequested && + ex is global::System.IO.IOException + or Java.IO.IOException + or InvalidDataException + or ObjectDisposedException + or WebException; + } { return cancellationToken.IsCancellationRequested && ( ex is global::System.IO.IOException || 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 60833ff7ffb..82cb39fa99e 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 @@ -24,7 +24,7 @@ public class AndroidMessageHandlerCancellationTests static readonly byte[] InitialResponseChunk = new byte[] { 42 }; - StalledResponseServer? stalledResponseServer; + static readonly byte[] InitialResponseChunk = [42]; [SetUp] public void SetUp () From ad6951bf6fb6de5c9fafec92490a377610b87c58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:16:48 +0000 Subject: [PATCH 5/8] Fix duplicate cancellation helper body Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com> --- .../Xamarin.Android.Net/AndroidMessageHandler.cs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs index 23dbfc97bb5..f8752712d80 100644 --- a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs +++ b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs @@ -231,15 +231,6 @@ or InvalidDataException or ObjectDisposedException or WebException; } - { - return cancellationToken.IsCancellationRequested && ( - ex is global::System.IO.IOException || - ex is Java.IO.IOException || - ex is InvalidDataException || - ex is ObjectDisposedException || - ex is WebException - ); - } static void LogAbortReadException (Task task) { From 1a2b50661a46449bed5c9569c50acf2bf479e5f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:54:04 +0000 Subject: [PATCH 6/8] Remove duplicate test field Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com> --- .../AndroidMessageHandlerCancellationTests.cs | 2 -- 1 file changed, 2 deletions(-) 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 82cb39fa99e..89b9a759c9d 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 @@ -24,8 +24,6 @@ public class AndroidMessageHandlerCancellationTests static readonly byte[] InitialResponseChunk = new byte[] { 42 }; - static readonly byte[] InitialResponseChunk = [42]; - [SetUp] public void SetUp () { From 5494513330e8f3fdcb951c8f0c8aca2a12457500 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:55:17 +0000 Subject: [PATCH 7/8] Use collection expression in test field Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com> --- .../AndroidMessageHandlerCancellationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 89b9a759c9d..5aa75f515be 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 @@ -22,7 +22,7 @@ public class AndroidMessageHandlerCancellationTests const int BodyReadBlockDelayMilliseconds = 250; const int PromptCancellationTimeoutMilliseconds = 3000; - static readonly byte[] InitialResponseChunk = new byte[] { 42 }; + static readonly byte[] InitialResponseChunk = [42]; [SetUp] public void SetUp () From 982e626b24d7e27959d84afbdf86cb329f81d9d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:38:59 +0000 Subject: [PATCH 8/8] Address AndroidMessageHandler review feedback Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com> --- .../AndroidMessageHandler.cs | 44 +++++-------------- .../AndroidMessageHandlerCancellationTests.cs | 1 + 2 files changed, 11 insertions(+), 34 deletions(-) diff --git a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs index f8752712d80..e23258fa78f 100644 --- a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs +++ b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs @@ -135,10 +135,7 @@ protected override void Dispose (bool disposing) base.Dispose (disposing); } - public override void Flush () - { - stream.Flush (); - } + public override void Flush () => stream.Flush (); public override async Task CopyToAsync (Stream destination, int bufferSize, CancellationToken cancellationToken) { @@ -148,20 +145,14 @@ public override async Task CopyToAsync (Stream destination, int bufferSize, Canc try { await stream.CopyToAsync (destination, bufferSize, cancellationToken).ConfigureAwait (false); } catch (Exception ex) when (ShouldMapToCancellation (ex, cancellationToken)) { - throw new global::System.OperationCanceledException ("Response body read was canceled.", ex, cancellationToken); + throw new System.OperationCanceledException ("Response body read was canceled.", ex, cancellationToken); } } } - public override int Read (byte[] buffer, int offset, int count) - { - return stream.Read (buffer, offset, count); - } + public override int Read (byte[] buffer, int offset, int count) => stream.Read (buffer, offset, count); - public override Task ReadAsync (byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - return ReadAsync (buffer.AsMemory (offset, count), cancellationToken).AsTask (); - } + public override Task ReadAsync (byte[] buffer, int offset, int count, CancellationToken cancellationToken) => ReadAsync (buffer.AsMemory (offset, count), cancellationToken).AsTask (); // StreamContent uses this overload on modern runtimes, so the wrapper must handle its ValueTask-based contract. public override async ValueTask ReadAsync (Memory buffer, CancellationToken cancellationToken = default) @@ -172,34 +163,23 @@ public override async ValueTask ReadAsync (Memory buffer, Cancellatio try { return await stream.ReadAsync (buffer, cancellationToken).ConfigureAwait (false); } catch (Exception ex) when (ShouldMapToCancellation (ex, cancellationToken)) { - throw new global::System.OperationCanceledException ("Response body read was canceled.", ex, cancellationToken); + throw new System.OperationCanceledException ("Response body read was canceled.", ex, cancellationToken); } } } - public override long Seek (long offset, SeekOrigin origin) - { - return stream.Seek (offset, origin); - } + public override long Seek (long offset, SeekOrigin origin) => stream.Seek (offset, origin); - public override void SetLength (long value) - { - stream.SetLength (value); - } + public override void SetLength (long value) => stream.SetLength (value); - public override void Write (byte[] buffer, int offset, int count) - { - stream.Write (buffer, offset, count); - } + public override void Write (byte[] buffer, int offset, int count) => stream.Write (buffer, offset, count); - void QueueAbortRead () - { + void QueueAbortRead () => Task.Run (AbortRead).ContinueWith ( - LogAbortReadException, + task => Logger.Log (LogLevel.Info, LOG_APP, $"Response body cancellation exception: {task.Exception}"), CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); - } void AbortRead () { @@ -232,10 +212,6 @@ or ObjectDisposedException or WebException; } - static void LogAbortReadException (Task task) - { - Logger.Log (LogLevel.Info, LOG_APP, $"Response body cancellation exception: {task.Exception}"); - } } internal const string LOG_APP = "monodroid-net"; 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 5aa75f515be..0d6e5439a9a 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 @@ -23,6 +23,7 @@ public class AndroidMessageHandlerCancellationTests const int PromptCancellationTimeoutMilliseconds = 3000; static readonly byte[] InitialResponseChunk = [42]; + StalledResponseServer? stalledResponseServer; [SetUp] public void SetUp ()