Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 System.OperationCanceledException ("Request body upload was canceled.", ex, cancellationToken);
} finally {
//
// Rewind the stream to beginning in case the HttpContent implementation
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#nullable enable

using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
Expand All @@ -19,6 +20,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;

Expand Down Expand Up @@ -58,6 +60,27 @@ 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. 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);

await WaitForBodyReadToBlock (uploadServer.BodyStartedTask).ConfigureAwait (false);
cts.Cancel ();
await AssertCanceledPromptly (sendTask, uploadServer.ReleaseRequestBody).ConfigureAwait (false);
}

[Test]
public async Task ResponseHeadersReadBodyReadCancellationIsPrompt ()
{
Expand Down Expand Up @@ -199,5 +222,106 @@ async Task ObserveServerTask ()
await serverTask.ConfigureAwait (false);
}
}

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 TcpListener listener;
readonly TaskCompletionSource<bool> bodyStarted = new TaskCompletionSource<bool> (TaskCreationOptions.RunContinuationsAsynchronously);
readonly TaskCompletionSource<bool> releaseBody = new TaskCompletionSource<bool> (TaskCreationOptions.RunContinuationsAsynchronously);
readonly Task serverTask;

public StalledRequestServer ()
{
listener = new 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 (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;
}
}
}
}
}