Skip to content

Fix AndroidMessageHandler response body cancellation#11554

Open
simonrozsival wants to merge 8 commits into
mainfrom
dev/simonrozsival/11548-androidmessagehandler-cancel-body-reads
Open

Fix AndroidMessageHandler response body cancellation#11554
simonrozsival wants to merge 8 commits into
mainfrom
dev/simonrozsival/11548-androidmessagehandler-cancel-body-reads

Conversation

@simonrozsival
Copy link
Copy Markdown
Member

Fixes #11548

Summary

  • make response-content reads cancellation-aware in AndroidMessageHandler
  • abort stalled body reads by disconnecting the HttpURLConnection and disposing the response stream when the read token is canceled
  • add dedicated HttpListener-based coverage for ResponseContentRead and ResponseHeadersRead body cancellation

Testing

  • make all
  • ANDROID_SERIAL=R58Y30HZ65V ./dotnet-local.sh build -t:RunTestApp tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj -p:IncludeCategories=AndroidMessageHandlerCancellation

simonrozsival and others added 2 commits June 1, 2026 14:40
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@simonrozsival simonrozsival marked this pull request as ready for review June 1, 2026 13:31
Copilot AI review requested due to automatic review settings June 1, 2026 13:31
@simonrozsival
Copy link
Copy Markdown
Member Author

/review

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 1, 2026

Android PR Reviewer completed successfully!

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes delayed cancellation observation when AndroidMessageHandler is blocked reading the response body (after headers are received). It does so by wrapping the response stream to actively disconnect/close the underlying HttpURLConnection when the read cancellation token is canceled, and adds regression coverage for both ResponseContentRead and ResponseHeadersRead scenarios (body read canceled after headers).

Changes:

  • Add a cancellation-aware response body stream wrapper that aborts stalled reads by calling HttpURLConnection.Disconnect() and disposing the response stream when cancellation is requested.
  • Wire the wrapped stream into AndroidMessageHandler response content creation.
  • Add HttpListener-based device tests covering prompt body-read cancellation for both completion options.
Show a summary per file
File Description
src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs Wraps response content stream to make body reads cancellation-aware by aborting the underlying Java connection/stream.
tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerCancellationTests.cs Adds new tests verifying response body cancellation is observed promptly for ResponseContentRead and ResponseHeadersRead.
tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj Includes the new cancellation test file in the test project build.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 1

Comment thread src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Needs Changes

Summary: This PR adds cancellation support for response body reads in AndroidMessageHandler by wrapping the response stream in a CancellationAwareResponseStream that disconnects the HttpURLConnection when a CancellationToken is canceled. The approach is sound — registering a cancellation callback to force-close the connection is the right pattern for unblocking stalled Java I/O.

Issues found:

Severity Count Category
⚠️ warning 2 Resource management, Thread safety
💡 suggestion 2 Testing

Key concern: The CancellationAwareResponseStream takes dispose ownership of httpConnection, but AndroidHttpResponseMessage also disposes the same connection instance — creating double-dispose and unclear ownership. The AbortRead callback and Dispose method also race on stream.Dispose().

Positive callouts:

  • Clean separation of the cancellation wrapper as a nested class
  • Good use of CancellationToken.Register + exception filter pattern
  • The ShouldMapToCancellation exception mapping covers the right exception types from the Java interop layer
  • Well-structured test server with proper synchronization via TaskCompletionSource
  • Tests cover both ResponseContentRead and ResponseHeadersRead completion options

Generated by Android PR Reviewer for issue #11554 · ● 14.1M

Comment thread src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs Outdated
Comment thread src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@simonrozsival
Copy link
Copy Markdown
Member Author

/review

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 1, 2026

Android PR Reviewer completed successfully!

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — Clean, well-designed fix

Summary: The PR wraps the response stream in a CancellationAwareResponseStream that registers a cancellation callback to disconnect the HttpURLConnection and dispose the stream, unblocking stalled reads. The approach is sound — it correctly handles the ReadAsync/CopyToAsync paths that StreamContent actually uses, maps the resulting exceptions to OperationCanceledException, and uses Interlocked for thread-safe double-dispose protection.

Positive callouts:

  • The QueueAbortReadTask.Run(AbortRead) pattern avoids blocking the cancellation callback thread — good attention to detail
  • TaskCreationOptions.RunContinuationsAsynchronously on the TaskCompletionSource prevents deadlocks in the test server
  • The test infrastructure (StalledResponseServer) is well-structured with proper cleanup in TearDown
  • Good coverage of both ResponseContentRead and ResponseHeadersRead completion options

CI: dotnet-android ✅ passed. Xamarin.Android-PR not yet visible (expected for a recently pushed direct PR).

Issues: 0 ❌ · 0 ⚠️ · 3 💡

Generated by Android PR Reviewer for issue #11554 · ● 12.5M

Comment thread src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs
Comment thread src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
@simonrozsival

This comment was marked as resolved.

Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI commented Jun 2, 2026

@copilot fix this build failure:

@simonrozsival
Copy link
Copy Markdown
Member Author

@copilot fix the build error:

All test configurations in both the Package Tests and MSBuild Emulator Tests stages fail due to a single compile error:

error CS0102: The type 'AndroidMessageHandlerCancellationTests' already contains a definition for 'InitialResponseChunk'
File: tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerCancellationTests.cs (line 27)

The field or property InitialResponseChunk is declared twice in the AndroidMessageHandlerCancellationTests class, causing Mono.Android.NET-Tests.csproj to fail to compile.

Recommended Fix
Remove the duplicate definition of InitialResponseChunk in AndroidMessageHandlerCancellationTests.cs at line 27.

Copilot AI and others added 2 commits June 2, 2026 12:54
Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com>
Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI commented Jun 2, 2026

@copilot fix the build error:

All test configurations in both the Package Tests and MSBuild Emulator Tests stages fail due to a single compile error:
...

Fixed in 5494513.

I removed the duplicate InitialResponseChunk definition from AndroidMessageHandlerCancellationTests.cs and kept the remaining initializer as [42].

Comment on lines +156 to +159
public override int Read (byte[] buffer, int offset, int count)
{
return stream.Read (buffer, offset, count);
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are several methods in here, we could shorten to a single line using =>.

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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this really need global::, is there a second OperationCanceledException?

void QueueAbortRead ()
{
Task.Run (AbortRead).ContinueWith (
LogAbortReadException,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is used in one place, could it be a lambda instead?

@jonathanpeppers
Copy link
Copy Markdown
Member

/review

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 2, 2026

Android PR Reviewer completed successfully!

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Needs Changes

Nice fix for response body cancellation — the CancellationAwareResponseStream approach with Disconnect() + stream disposal is a clean way to unblock stalled reads. The test design with a stalled HTTP server is well thought out.

Issues found

Severity Count
❌ Error 1
💡 Suggestion 2

❌ Missing field declarationstalledResponseServer is used but never declared in the test class. This will fail to compile.

💡 Per-read CancellationToken.Register — The registration/disposal happens on every ReadAsync call in a tight loop. Consider registering once and disposing in Dispose.

💡 Test coverage — Consider a positive test (non-canceled read completes normally) to verify the wrapper doesn't break the happy path.

CI Status

  • dotnet-android — succeeded
  • license/cla — succeeded
  • Xamarin.Android-PR — not yet reported (will likely surface the compile error above)

Generated by Android PR Reviewer for issue #11554 · ● 17.4M

const int BodyReadBlockDelayMilliseconds = 250;
const int PromptCancellationTimeoutMilliseconds = 3000;

static readonly byte[] InitialResponseChunk = [42];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 ❌ Bugs & correctness — The stalledResponseServer instance field is used in SetUp, TearDown, and both test methods, but it is never declared. This will be a compile error. Add the field declaration:

Suggested change
static readonly byte[] InitialResponseChunk = [42];
static readonly byte[] InitialResponseChunk = [42];
StalledResponseServer? stalledResponseServer;

Rule: Missing field declaration

{
cancellationToken.ThrowIfCancellationRequested ();

using (cancellationToken.Register (QueueAbortRead, useSynchronizationContext: false)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 💡 Performance — The cancellationToken.Register call allocates a CancellationTokenRegistration on every ReadAsync invocation. For StreamContent draining a response body, this is called in a tight loop (once per buffer-sized chunk). Consider registering the callback once (e.g., in the constructor or via a lazy field) and disposing it in Dispose, rather than registering/unregistering per read.

Rule: Avoid closures in hot paths

releaseBody.TrySetResult (true);
}

async Task ServeStalledResponseBody ()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 💡 Testing — The StalledResponseServer only handles a single HTTP request (GetContextAsync is called once). If a future test reuses the server for multiple requests, it will hang silently. A comment noting this single-request design would help future maintainers.

Also, consider adding a negative test: verify that a non-canceled read completes successfully through the CancellationAwareResponseStream (i.e., the wrapper doesn't break normal reads).

Rule: Test edge cases

@simonrozsival
Copy link
Copy Markdown
Member Author

@copilot address code reviews

Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI commented Jun 2, 2026

@copilot address code reviews

Addressed in 982e626.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AndroidMessageHandler does not promptly cancel response body reads while waiting for body data

4 participants