Map cancelled request body uploads to OperationCanceledException in AndroidMessageHandler#11683
Map cancelled request body uploads to OperationCanceledException in AndroidMessageHandler#11683Copilot wants to merge 5 commits into
Conversation
…Exception Co-authored-by: jonathanpeppers <840039+jonathanpeppers@users.noreply.github.com>
Co-authored-by: jonathanpeppers <840039+jonathanpeppers@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR aligns Xamarin.Android.Net.AndroidMessageHandler cancellation behavior with SocketsHttpHandler by ensuring that cancellations during request body uploads surface as an OperationCanceledException (instead of transport-level WebException/IOException), matching the already-existing behavior for canceled response-body reads.
Changes:
- Hoists
ShouldMapToCancellation()toAndroidMessageHandlerso both upload and response-read paths share the same cancellation mapping logic. - Updates
WriteRequestContentToOutput()to map transport exceptions during request-body copy to anOperationCanceledExceptionwhen cancellation is requested. - Adds an on-device test that stalls request-body reads server-side, cancels mid-upload, and asserts prompt cancellation.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs |
Maps request-body upload transport failures (triggered by cancellation disconnect) to OperationCanceledException; shares cancellation-mapping helper with response-read path. |
tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerCancellationTests.cs |
Adds a stalled-request upload-cancellation test and supporting server/content helpers. |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
/review |
|
✅ Android PR Reviewer completed successfully! |
There was a problem hiding this comment.
🤖 Android PR Review
Verdict: 👍 Looks correct — a few optional 💡 nits (final sign-off pending CI).
I reviewed the change independently before reading the description, then reconciled. The fix is well-targeted and addresses the root cause.
What the change does (verified)
WriteRequestContentToOutput uploads the request body via stream.CopyToAsync (httpConnection.OutputStream!, ..., cancellationToken). When the caller cancels mid-upload, the cancellationToken.Register callback in DoProcessRequest disconnects the socket, so the in-flight write throws Java.IO.IOException ("Socket closed"). Previously that exception bubbled up to DoSendAsync's catch (Java.IO.IOException ...) and was re-wrapped into a WebException — diverging from SocketsHttpHandler. The new catch ... when (ShouldMapToCancellation (ex, cancellationToken)) intercepts it first and rethrows OperationCanceledException tied to the token, so it propagates past the WebException mappers unchanged. ✅ Root cause, not symptom.
- ✅ Hoisting
ShouldMapToCancellationfrom the nestedCancellationAwareResponseStreamtoAndroidMessageHandleris safe — it'sstatic, and both existing call sites (response-read path) resolve to the enclosing type. No behavior change to the response path. - ✅ Consistent with the existing response-read mapping (
"Response body read was canceled.") — same exception type and shape. - ✅ Also improves the
HttpClient.Timeoutpath: a timeout-triggered cancellation now surfaces as cancellation rather thanWebException. - ✅ Includes a focused regression test (
RequestBodyUploadCancellationIsPrompt) that stalls a raw-socket server after the headers, fills the send buffer with a 16 MB body, cancels mid-upload, and asserts a promptOperationCanceledException. The test fails on the old code and passes on the new code.
Findings
| Severity | Count |
|---|---|
| ❌ error | 0 |
| 0 | |
| 💡 suggestion | 3 |
All three are optional, test-only polish (posted inline):
- 💡
serverTaskis stored but never observed inDispose ()— unlike the siblingStalledResponseServer.Stop (). - 💡
StalledRequestServerduplicates much ofStalledResponseServer's TCS scaffolding. - 💡
ZeroStream.ReadAsyncignores itscancellationToken(harmless, contract-only).
None block merge.
CI
The Azure DevOps dotnet-android pipeline is still in progress for the latest commit (all completed legs are green so far; the new test runs under the MSBuild+Emulator legs). This is a COMMENT, not an approval — please confirm the pipeline goes green (especially the emulator leg exercising AndroidMessageHandlerCancellation) before merging.
Nice, surgical fix with a real regression test. 👍
Generated by Android PR Reviewer for issue #11683 · 545 AIC · ⌖ 50.9 AIC · ⊞ 37.8K
Comment /review to run again
| readonly TcpListener listener; | ||
| readonly TaskCompletionSource<bool> bodyStarted = new TaskCompletionSource<bool> (TaskCreationOptions.RunContinuationsAsynchronously); | ||
| readonly TaskCompletionSource<bool> releaseBody = new TaskCompletionSource<bool> (TaskCreationOptions.RunContinuationsAsynchronously); | ||
| readonly Task serverTask; |
There was a problem hiding this comment.
🤖 💡 Code organization — serverTask is stored but never observed. Dispose () releases the body and stops the listener, but unlike the sibling StalledResponseServer.Stop () (which calls ObserveServerTask ().GetAwaiter ().GetResult ()), it never awaits serverTask. As written the field only roots the task, and any unexpected server-side fault raised after bodyStarted completes is silently swallowed by StallRequestBody's catch. Consider observing serverTask in Dispose () for parity and to surface real failures.
Rule: Consistency with sibling helpers / observe background tasks
| } | ||
| } | ||
|
|
||
| sealed class StalledRequestServer : IDisposable |
There was a problem hiding this comment.
🤖 💡 Patterns — StalledRequestServer largely mirrors StalledResponseServer: the same bodyStarted/releaseBody TaskCompletionSource setup, Port, BodyStartedTask, a release method, and the identical "fault before body started → TrySetException, else log" handling. Consider extracting the shared stall/signal scaffolding into a small base helper so the two servers can't drift. Optional — skip if it would over-complicate the harness.
Rule: Centralize duplicate algorithms (Postmortem #54)
| return toRead; | ||
| } | ||
|
|
||
| public override ValueTask<int> ReadAsync (Memory<byte> buffer, CancellationToken cancellationToken = default) |
There was a problem hiding this comment.
🤖 💡 Testing — ZeroStream.ReadAsync ignores its cancellationToken. Harmless here because reads complete synchronously, but for Stream contract correctness consider honoring it, e.g. if (cancellationToken.IsCancellationRequested) return ValueTask.FromCanceled<int> (cancellationToken);. Low priority.
Rule: Honor the token (csharp-rules: Async & Cancellation)
When a caller cancels the
CancellationTokenwhileAndroidMessageHandleris still uploading a request body (e.g. aPUTwhose body the server delays reading), the cancellation surfaced asWebException/IOException("Socket closed") instead of anOperationCanceledExceptionsubtype, diverging fromSocketsHttpHandler.The cancellation callback disconnects the socket, so the in-flight
CopyToAsynctohttpConnection.OutputStreamthrowsJava.IO.IOException, which was then wrapped into aWebExceptionbyDoSendAsync. The response-body read path already mapped these to cancellation; the request-upload path did not.Changes
WriteRequestContentToOutput: catch transport exceptions thrown during the body copy when cancellation has been requested, and rethrow asSystem.OperationCanceledExceptiontied to the caller's token.ShouldMapToCancellation: hoisted from the nestedCancellationAwareResponseStreamto the enclosingAndroidMessageHandlerso the upload and response-read paths share one mapping rule (no change to the existing response-read behavior).RequestBodyUploadCancellationIsPromptstalls a raw-socket server after reading the request headers, streams a large body in chunks until the send buffer blocks, cancels mid-upload, and asserts a promptOperationCanceledException.