-
Notifications
You must be signed in to change notification settings - Fork 571
Fix AndroidMessageHandler response body cancellation #11554
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
jonathanpeppers
merged 8 commits into
main
from
dev/simonrozsival/11548-androidmessagehandler-cancel-body-reads
Jun 3, 2026
+316
−2
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
f20a13d
Fix AndroidMessageHandler response body cancellation
simonrozsival 385d14f
Simplify AndroidMessageHandler cancellation changes
simonrozsival bf88397
Address AndroidMessageHandler review feedback
simonrozsival f520475
Apply suggestions from code review
simonrozsival ad6951b
Fix duplicate cancellation helper body
Copilot 1a2b506
Remove duplicate test field
Copilot 5494513
Use collection expression in test field
Copilot 982e626
Address AndroidMessageHandler review feedback
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
203 changes: 203 additions & 0 deletions
203
...id-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerCancellationTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| #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 = [42]; | ||
|
jonathanpeppers marked this conversation as resolved.
|
||
| StalledResponseServer? stalledResponseServer; | ||
|
|
||
| [SetUp] | ||
| public void SetUp () | ||
| { | ||
| stalledResponseServer = new StalledResponseServer (); | ||
| } | ||
|
|
||
| [TearDown] | ||
| 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.Stop (); | ||
| } | ||
|
|
||
| [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.ReleaseResponseBody).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.ReleaseResponseBody).ConfigureAwait (false); | ||
| } | ||
|
|
||
| static int GetAvailablePort () | ||
| { | ||
| using var tcpListener = new TcpListener (IPAddress.Loopback, 0); | ||
| tcpListener.Start (); | ||
| int port = ((IPEndPoint) tcpListener.LocalEndpoint).Port; | ||
| tcpListener.Stop (); | ||
| return port; | ||
| } | ||
|
|
||
| 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, Action releaseBody) | ||
| { | ||
| var completed = await Task.WhenAny (readTask, Task.Delay (PromptCancellationTimeoutMilliseconds)).ConfigureAwait (false); | ||
| if (completed != readTask) { | ||
| releaseBody (); | ||
| 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) { | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| 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}"); | ||
| } | ||
| } | ||
|
|
||
| sealed class StalledResponseServer | ||
| { | ||
| readonly HttpListener listener; | ||
| readonly TaskCompletionSource<bool> bodyStarted = new TaskCompletionSource<bool> (TaskCreationOptions.RunContinuationsAsynchronously); | ||
| readonly TaskCompletionSource<bool> releaseBody = new TaskCompletionSource<bool> (TaskCreationOptions.RunContinuationsAsynchronously); | ||
| readonly Task serverTask; | ||
|
|
||
| public StalledResponseServer () | ||
| { | ||
| Port = GetAvailablePort (); | ||
| listener = new HttpListener (); | ||
| listener.Prefixes.Add ($"http://localhost:{Port}/"); | ||
| listener.Start (); | ||
|
|
||
| serverTask = ServeStalledResponseBody (); | ||
| } | ||
|
|
||
| public int Port { get; } | ||
|
|
||
| public Task BodyStartedTask => bodyStarted.Task; | ||
|
|
||
| public void Stop () | ||
| { | ||
| ReleaseResponseBody (); | ||
| listener.Close (); | ||
| ObserveServerTask ().GetAwaiter ().GetResult (); | ||
| } | ||
|
|
||
| public void ReleaseResponseBody () | ||
|
simonrozsival marked this conversation as resolved.
|
||
| { | ||
| releaseBody.TrySetResult (true); | ||
| } | ||
|
|
||
| async Task ServeStalledResponseBody () | ||
|
jonathanpeppers marked this conversation as resolved.
|
||
| { | ||
| 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}"); | ||
| } | ||
| } | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
| } | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.