From 6a40da47519eb0fba1aebc6df488119fff29c4d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:53:09 +0000 Subject: [PATCH 1/3] Initial plan From ffdaaf39c3e337c2b863aa3e4d6dfd323a111bf4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:17:33 +0000 Subject: [PATCH 2/3] [Mono.Android] Map cancelled request body upload to OperationCanceledException Co-authored-by: jonathanpeppers <840039+jonathanpeppers@users.noreply.github.com> --- .../AndroidMessageHandler.cs | 23 +++-- .../AndroidMessageHandlerCancellationTests.cs | 95 +++++++++++++++++++ 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs index e23258fa78f..7051f9b4e80 100644 --- a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs +++ b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs @@ -202,16 +202,16 @@ void DisposeStream () stream.Dispose (); } - 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; - } + } + 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; } internal const string LOG_APP = "monodroid-net"; @@ -788,6 +788,11 @@ protected virtual async Task WriteRequestContentToOutput (HttpRequestMessage req var stream = await request.Content.ReadAsStreamAsync ().ConfigureAwait (false); try { await stream.CopyToAsync(httpConnection.OutputStream!, 4096, cancellationToken).ConfigureAwait(false); + } catch (Exception ex) when (ShouldMapToCancellation (ex, cancellationToken)) { + // When the caller cancels the request while the body is being uploaded, the connection + // is disconnected which surfaces as a transport exception (e.g. "Socket closed"). Map it + // to an OperationCanceledException so callers observe cancellation instead of a WebException. + throw new OperationCanceledException ("Request body upload was canceled.", ex, cancellationToken); } finally { // // Rewind the stream to beginning in case the HttpContent implementation 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 0d6e5439a9a..1465cd76894 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 @@ -19,6 +19,7 @@ namespace Xamarin.Android.NetTests public class AndroidMessageHandlerCancellationTests { const int StalledResponseContentLength = 1024 * 1024; + const int UploadContentLength = 16 * 1024 * 1024; const int BodyReadBlockDelayMilliseconds = 250; const int PromptCancellationTimeoutMilliseconds = 3000; @@ -58,6 +59,26 @@ public async Task ResponseContentReadBodyReadCancellationIsPrompt () await AssertCanceledPromptly (readTask, server.ReleaseResponseBody).ConfigureAwait (false); } + [Test] + public async Task RequestBodyUploadCancellationIsPrompt () + { + using var uploadServer = new StalledRequestServer (); + using var handler = new AndroidMessageHandler (); + using var client = new HttpClient (handler); + using var cts = new CancellationTokenSource (); + using var request = new HttpRequestMessage (HttpMethod.Put, $"http://localhost:{uploadServer.Port}/upload") { + // A large body ensures the socket send buffer fills while the server stalls reading it, + // so the upload is still in progress when the caller cancels. + Content = new ByteArrayContent (new byte [UploadContentLength]), + }; + + Task sendTask = client.SendAsync (request, HttpCompletionOption.ResponseHeadersRead, cts.Token); + + await WaitForBodyReadToBlock (uploadServer.BodyStartedTask).ConfigureAwait (false); + cts.Cancel (); + await AssertCanceledPromptly (sendTask, uploadServer.ReleaseRequestBody).ConfigureAwait (false); + } + [Test] public async Task ResponseHeadersReadBodyReadCancellationIsPrompt () { @@ -199,5 +220,79 @@ async Task ObserveServerTask () await serverTask.ConfigureAwait (false); } } + + sealed class StalledRequestServer : IDisposable + { + readonly System.Net.Sockets.TcpListener listener; + readonly TaskCompletionSource bodyStarted = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + readonly TaskCompletionSource releaseBody = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); + readonly Task serverTask; + + public StalledRequestServer () + { + listener = new System.Net.Sockets.TcpListener (IPAddress.Loopback, 0); + listener.Start (); + Port = ((IPEndPoint) listener.LocalEndpoint).Port; + + serverTask = StallRequestBody (); + } + + public int Port { get; } + + public Task BodyStartedTask => bodyStarted.Task; + + public void ReleaseRequestBody () + { + releaseBody.TrySetResult (true); + } + + public void Dispose () + { + ReleaseRequestBody (); + try { + listener.Stop (); + } catch (Exception ex) { + Console.WriteLine ($"Exception while stopping the stalled request server: {ex}"); + } + } + + async Task StallRequestBody () + { + try { + using var client = await listener.AcceptTcpClientAsync ().ConfigureAwait (false); + using var stream = client.GetStream (); + + // Read just the request headers so the upload phase begins, then stop reading the body + // to keep the socket send buffer full on the client side until released. + await ReadRequestHeaders (stream).ConfigureAwait (false); + bodyStarted.TrySetResult (true); + + await releaseBody.Task.ConfigureAwait (false); + } catch (Exception ex) { + if (!BodyStartedTask.IsCompleted) { + bodyStarted.TrySetException (ex); + return; + } + Console.WriteLine ($"Exception while stalling the request body: {ex}"); + } + } + + static async Task ReadRequestHeaders (System.Net.Sockets.NetworkStream stream) + { + var buffer = new byte [1]; + int consecutiveLineEndChars = 0; + while (consecutiveLineEndChars < 4) { + int read = await stream.ReadAsync (buffer, 0, 1).ConfigureAwait (false); + if (read == 0) + break; + + byte b = buffer [0]; + if (b == '\r' || b == '\n') + consecutiveLineEndChars++; + else + consecutiveLineEndChars = 0; + } + } + } } } From 0ef2ceec09fae2cef933ec4f29e3628595c97b2a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:29:53 +0000 Subject: [PATCH 3/3] [tests] Stream upload body and use System.OperationCanceledException Co-authored-by: jonathanpeppers <840039+jonathanpeppers@users.noreply.github.com> --- .../AndroidMessageHandler.cs | 2 +- .../AndroidMessageHandlerCancellationTests.cs | 39 ++++++++++++++++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs index 7051f9b4e80..97283a044bf 100644 --- a/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs +++ b/src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs @@ -792,7 +792,7 @@ protected virtual async Task WriteRequestContentToOutput (HttpRequestMessage req // When the caller cancels the request while the body is being uploaded, the connection // is disconnected which surfaces as a transport exception (e.g. "Socket closed"). Map it // to an OperationCanceledException so callers observe cancellation instead of a WebException. - throw new OperationCanceledException ("Request body upload was canceled.", ex, cancellationToken); + throw new System.OperationCanceledException ("Request body upload was canceled.", ex, cancellationToken); } finally { // // Rewind the stream to beginning in case the HttpContent implementation 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 1465cd76894..55cae6016a6 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 @@ -1,6 +1,7 @@ #nullable enable using System; +using System.IO; using System.Net; using System.Net.Http; using System.Net.Sockets; @@ -68,8 +69,9 @@ public async Task RequestBodyUploadCancellationIsPrompt () using var cts = new CancellationTokenSource (); using var request = new HttpRequestMessage (HttpMethod.Put, $"http://localhost:{uploadServer.Port}/upload") { // A large body ensures the socket send buffer fills while the server stalls reading it, - // so the upload is still in progress when the caller cancels. - Content = new ByteArrayContent (new byte [UploadContentLength]), + // so the upload is still in progress when the caller cancels. The content streams the + // bytes in small chunks instead of allocating the whole body up front. + Content = new StreamingContent (UploadContentLength), }; Task sendTask = client.SendAsync (request, HttpCompletionOption.ResponseHeadersRead, cts.Token); @@ -221,16 +223,43 @@ async Task ObserveServerTask () } } + sealed class StreamingContent : HttpContent + { + readonly long length; + + public StreamingContent (long length) + { + this.length = length; + } + + protected override async Task SerializeToStreamAsync (Stream stream, System.Net.TransportContext? context) + { + var buffer = new byte [4096]; + long remaining = length; + while (remaining > 0) { + int toWrite = (int) Math.Min (remaining, buffer.Length); + await stream.WriteAsync (buffer, 0, toWrite).ConfigureAwait (false); + remaining -= toWrite; + } + } + + protected override bool TryComputeLength (out long computedLength) + { + computedLength = length; + return true; + } + } + sealed class StalledRequestServer : IDisposable { - readonly System.Net.Sockets.TcpListener listener; + readonly TcpListener listener; readonly TaskCompletionSource bodyStarted = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); readonly TaskCompletionSource releaseBody = new TaskCompletionSource (TaskCreationOptions.RunContinuationsAsynchronously); readonly Task serverTask; public StalledRequestServer () { - listener = new System.Net.Sockets.TcpListener (IPAddress.Loopback, 0); + listener = new TcpListener (IPAddress.Loopback, 0); listener.Start (); Port = ((IPEndPoint) listener.LocalEndpoint).Port; @@ -277,7 +306,7 @@ async Task StallRequestBody () } } - static async Task ReadRequestHeaders (System.Net.Sockets.NetworkStream stream) + static async Task ReadRequestHeaders (NetworkStream stream) { var buffer = new byte [1]; int consecutiveLineEndChars = 0;