[Mono.Android] Abort in-flight body I/O on response dispose#12105
[Mono.Android] Abort in-flight body I/O on response dispose#12105simonrozsival wants to merge 11 commits into
Conversation
AndroidMessageHandler could close a response BufferedStream while its underlying Java InputStream was still reading. This raced OkHttp's read and close paths, causing `Unbalanced enter/exit` or corrupting managed BufferedStream state when gRPC canceled and disposed a streaming call. Track active reads in CancellationAwareResponseStream. Disconnect the HttpURLConnection to abort in-flight I/O, but defer closing the stream until the final read unwinds. Add on-device coverage for disposing a response during canceled and uncanceled body reads. Fixes: https://github.com/dotnet/runtime/issues/69464 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5c23194f-2fa2-40a9-a93b-1d8900bda4d7
There was a problem hiding this comment.
Pull request overview
This PR fixes a race in AndroidMessageHandler where disposing an HttpResponseMessage while a response-body read is in progress could concurrently close the managed BufferedStream and the underlying Java InputStream, leading to OkHttp timeout-state corruption (Java.Lang.IllegalStateException: Unbalanced enter/exit) and potentially corrupted managed buffering state.
Changes:
- Serialize response-stream disposal by tracking active reads in
CancellationAwareResponseStream, deferring the underlying stream close until the final read unwinds. - On disposal during an active read, abort I/O via
HttpURLConnection.Disconnect()and reject new reads once disposal is requested. - Add on-device tests that cover “cancel-then-dispose” (gRPC-like) and “dispose-during-read” sequences.
Show a summary per file
| File | Description |
|---|---|
| tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.Net/AndroidMessageHandlerCancellationTests.cs | Adds on-device regression tests for disposal during an in-flight response-body read (with and without cancellation). |
| src/Mono.Android/Xamarin.Android.Net/AndroidMessageHandler.cs | Updates CancellationAwareResponseStream to coordinate read/dispose, abort active I/O via Disconnect(), and defer stream closure until reads complete. |
Copilot's findings
- Files reviewed: 2/2 changed files
- Comments generated: 0
Redesign the disposal handling in CancellationAwareResponseStream so that
disposing the response never closes the body stream while a read/write is
still parked in the Java InputStream, replacing the connection-lifetime
coordinator from the previous commit.
Instead of serializing disposal by blocking, the wrapper now brackets every
read/write/copy/flush with BeginUse()/EndUse() and:
* Dispose() records the request and cancels an internal
CancellationTokenSource to abort the parked operation via a backgrounded
HttpURLConnection.Disconnect(). It never blocks.
* The last in-flight operation closes the underlying stream as it unwinds
(EndUse -> DisposeCore), which is the only point at which closing the Java
stream cannot collide with an active read and trigger okhttp's
"Unbalanced enter/exit".
* When no operation is in flight, Dispose() closes directly.
* Operations started after disposal throw ObjectDisposedException.
* The HttpURLConnection Java peer is Disconnect()-ed, never disposed, so
deleting its JNI global reference cannot race a still-unwinding read.
The internal token is linked with the caller's token, so a read/write also
aborts (and maps to OperationCanceledException) when the caller cancels
directly. Writes and Flush are drain-aware too, not just reads: any blocking
socket operation races dispose identically.
Add deterministic (no-network) unit tests over the wrapper covering
dispose-during-read, dispose-with-no-active-read, and
ObjectDisposedException-after-dispose, alongside the existing on-device
cancellation tests.
Fixes: #12106
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 00f852a2-5601-4d63-aff0-33aaf414151a
Convert the documentation comments on CancellationAwareResponseStream, its members, and the CancellationAwareResponseStreamTests fixture from plain // blocks to /// <summary> XML doc comments. No behavioral change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00f852a2-5601-4d63-aff0-33aaf414151a
…ration In CancellationAwareResponseStream.Dispose, only cancel the abort token when an operation is actually in flight. Once disposeRequested is set under the lock no new operation can begin, and each operation's cancellation registration is torn down before inUseCount reaches 0, so when inUseCount == 0 there is nothing to abort -- abortCts.Cancel() was a no-op there. Close directly instead. No behavioral change; the AndroidMessageHandlerCancellation suite remains 11/11 on an Android 16 / API 36 device. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00f852a2-5601-4d63-aff0-33aaf414151a
The BeginUse/abort-token/Register/map-cancellation/EndUse bracket was repeated across Flush, CopyToAsync, Read, ReadAsync, Write, and WriteAsync -- six copies of the safety-critical pattern, each a place to get it subtly wrong. Extract it into RunOperationAsync<T> (async, links the caller token) and RunOperation<T> (sync, internal abort token only); the operation methods now delegate to them. No behavioral change; the AndroidMessageHandlerCancellation suite remains 11/11 on an Android 16 / API 36 device. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00f852a2-5601-4d63-aff0-33aaf414151a
Two follow-up simplifications to CancellationAwareResponseStream:
* Drop the separate 'disposed' field. It only guarded single teardown, which
is already guaranteed by the unique disposeRequested false->true transition
(detected in Dispose to make a second Dispose a no-op) plus the unique
EndUse that returns inUseCount to 0. One flag now expresses the state.
* Replace the sync/async helper pair with void + value-returning overloads of
a single async RunOperation core, plus thin synchronous wrappers
(Func<T>/Action) that unwrap a synchronously-completed operation via
GetAwaiter().GetResult() -- which cannot block, since no await suspends.
Read/Write/Flush call the sync wrappers; CopyToAsync/ReadAsync/WriteAsync
call the async core directly.
No behavioral change; the AndroidMessageHandlerCancellation suite remains 11/11
on an Android 16 / API 36 device.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 00f852a2-5601-4d63-aff0-33aaf414151a
….Dispose The response-level connection teardown was dispatched to a background thread on the assumption that Disconnect() must not run on the UI thread. That is not required: closing a socket (unlike connect/read/write) does not trigger NetworkOnMainThreadException, main disposed the connection synchronously on this same path, and the stream's DisposeCore already calls Disconnect() synchronously. Call it inline instead, which is simpler and deterministic (no fire-and-forget). The stream's RequestDisconnect keeps its background dispatch: it runs from CancellationTokenSource.Cancel() callbacks -- including the caller's linked token -- where synchronous socket I/O inside a foreign Cancel() is undesirable. No behavioral change to the fix; AndroidMessageHandlerCancellation remains 11/11 on an Android 16 / API 36 device. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00f852a2-5601-4d63-aff0-33aaf414151a
Reverts the single-flag experiment: detecting the disposeRequested false->true transition with a local made Dispose() harder to read than simply guarding teardown with a dedicated 'disposed' field. Bring the field back; Dispose() and EndUse() use '&& !disposed' to run DisposeCore() exactly once, and a second Dispose() is naturally a no-op. The RunOperation void/value overload unification from the previous commit is kept. AndroidMessageHandlerCancellation remains 11/11 on an Android 16 / API 36 device. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00f852a2-5601-4d63-aff0-33aaf414151a
Switch the RunOperation helpers to Task<T>/Task. This removes the pointless Task -> ValueTask -> Task round-trip in CopyToAsync, and lets CopyToAsync and the byte[] Read/WriteAsync overrides call the inner stream's Task-returning methods directly with no conversion. The Memory<byte>/ReadOnlyMemory<byte> overrides bridge to the Task-based helper with a single AsTask() plus a ValueTask wrap, as their Stream contract requires. The synchronous wrappers now use Task.FromResult / Task.CompletedTask. Also make the test's BlockingReadStream implement the byte[] ReadAsync overload (delegating to its Memory overload), mirroring real body streams so the wrapper's byte[] path exercises it. AndroidMessageHandlerCancellation remains 11/11 on an Android 16 / API 36 device. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 00f852a2-5601-4d63-aff0-33aaf414151a
Address review feedback:
* CancellationAwareResponseStream was widened to 'internal' only so unit tests
could construct it directly. Revert it to a private nested class with a
public constructor (as before) and drop the direct-construction
CancellationAwareResponseStreamTests. The dispose-during-read behavior is
still covered end-to-end by AndroidMessageHandlerCancellationTests, whose
dispose/cancel cases crash on main and pass here.
* Implement the void RunOperation overload in terms of the generic one
(async ct => { await operation(ct); return true; }) so the
BeginUse/try/finally/EndUse bracket exists in exactly one place.
AndroidMessageHandlerCancellation remains green on an Android 16 / API 36 device
(7/7 after removing the deterministic tests).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 00f852a2-5601-4d63-aff0-33aaf414151a
|
/review |
|
✅ Android PR Reviewer completed successfully! |
There was a problem hiding this comment.
⚠️ Review — thoughtful fix, a few polish items
Thanks for the very well-documented change. I independently traced the CancellationAwareResponseStream rework and the "drain, then close" scheme is sound: the BeginUse/EndUse bracket guarantees stream.Dispose() (the okhttp drain-read) only runs when inUseCount == 0, which is exactly the invariant needed to avoid the Unbalanced enter/exit crash. Dispose() correctly stays non-blocking by handing the close to the last in-flight operation and backgrounding Disconnect() off the (possibly UI) thread. The exactly-once disposed guard and the deliberate non-disposal of the JNI peer are both well-reasoned. The synchronous wrappers running the operation to completion inside the bracket (so GetAwaiter().GetResult() doesn't block) also checks out.
Findings (all inline):
⚠️ 1 performance — per-read linked-CTS +.AsTask()allocations added to the hotReadAsync(Memory<byte>)path.- 💡 2 suggestions — a garbled XML
<summary>onGetAbortToken, and cleaning up the "EXPERIMENT 1/2" scratch comments in the new tests.
Positives: genuinely reproducing regression tests (confirmed to crash on main), broad coverage of the dispose/cancel/concurrent shapes, drain-awareness extended to writes/flush, and excellent inline rationale for non-obvious ordering.
CI has not reported results yet (status pending, no checks). This isn't mergeable until the Azure DevOps pipeline is green — please confirm the on-device AndroidMessageHandlerCancellation category passes in CI, not just locally.
Verdict:
Generated by Android PR Reviewer for #12105 · 111.3 AIC · ⌖ 13.4 AIC · ⊞ 6.8K
Comment /review to run again
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 70f63eb7-6599-414c-a947-d860705aa0fa
|
/review |
|
✅ Android PR Reviewer completed successfully! |
There was a problem hiding this comment.
🤖 Code Review — ✅ LGTM (with minor comments)
Verdict: ✅ LGTM · Issues: 0 ❌ · 1
I reviewed the reworked CancellationAwareResponseStream "drain, then close" scheme against the full source and traced the concurrency paths. The design is sound and the state machine is carefully guarded:
- The
stateLock-protectedinUseCount/disposeRequested/disposedmachine correctly guarantees the underlying Java stream is closed exactly once and never while an operation is in flight —DisposeCoreruns either fromDispose(nothing in flight) or from the lastEndUse(parked op unwinds), so no leak and noClose()-during-read race. BeginUsethrowingObjectDisposedExceptionunder the lock closes the start-vs-dispose window cleanly.RequestDisconnectcoalescing viaInterlocked.Exchangeand mapping transport exceptions toOperationCanceledExceptionwith the correct causing token (GetCanceledToken) both look right.- The three race scenarios (dispose-during-read, cancel-during-read, concurrent cancel+dispose) are covered by genuine regression tests that the PR confirms crash on
main— good evidence the fix addresses root cause, not symptoms.
Positive callouts: excellent inline documentation of a genuinely subtle native/JNI race; the decision to Disconnect() rather than Dispose() the peer (avoiding a JNI global-ref race with an unwinding native read) is well justified.
Comments (non-blocking):
⚠️ Contradictory rationale between the synchronousDisconnect()inAndroidHttpResponseMessage.Disposeand the backgrounded one inRequestDisconnectregarding UI-thread socket I/O, plus a slightly inaccurate "already disconnected" comment for the in-flight path.- 💡 Minor duplication in the sync void
RunOperationbracket.
CI note: no required checks have reported yet on the head commit (status pending) — not a blocker for review, but confirm the on-device AndroidMessageHandlerCancellation suite is green before merge.
Generated by Android PR Reviewer for #12105 · 145.1 AIC · ⌖ 13.3 AIC · ⊞ 6.8K
Comment /review to run again
| // cancelling from a UI callback): closing a socket is fast and, unlike connect/read/write, | ||
| // does not trigger NetworkOnMainThreadException. | ||
| try { | ||
| httpConnection.Disconnect (); |
There was a problem hiding this comment.
🤖 HttpURLConnection.Disconnect() on the (possibly UI) disposing thread. Here the comment says the synchronous Disconnect() is safe on the UI thread because "closing a socket ... does not trigger NetworkOnMainThreadException", yet CancellationAwareResponseStream.RequestDisconnect goes out of its way to background the very same call because "Disconnect() performs socket I/O that must not run there." Both can't be true — please reconcile.
Relatedly, the comment on the lines above ("for a streaming response the content already disconnected via base.Dispose above") is inaccurate for the in-flight case: when a body read is parked, base.Dispose only schedules a backgrounded disconnect and defers the close, so this synchronous Disconnect() is frequently the call that actually aborts the parked read — on the disposing thread. Worth clarifying so the "Dispose() never blocks" invariant in the class docs isn't misleading.
(Verify: same httpConnection peer is shared by AndroidHttpResponseMessage and CancellationAwareResponseStream.)
| /// <summary> | ||
| /// Synchronous, void counterpart of <see cref="RunOperation{T}(Func{T},string)"/>. | ||
| /// </summary> | ||
| void RunOperation (Action operation, string canceledMessage) |
There was a problem hiding this comment.
🤖 💡 Code organization — This sync void RunOperation duplicates the BeginUse/try/catch-map/finally/EndUse bracket verbatim from the value-returning RunOperation<T>(Func<T>, ...) above. The async pair already delegates to keep the bracket "in exactly one place"; consider the same here to match that stated design intent:
void RunOperation (Action operation, string canceledMessage) =>
RunOperation<bool> (() => { operation (); return true; }, canceledMessage);(Rule: Reduce duplication — consolidate near-identical helpers.)
Description
AndroidMessageHandlerexposes the response body as aBufferedStreamoverAndroid's Java
InputStream(backed by okhttp).HttpResponseMessage.Dispose()can be called from any thread — and, importantly, is how gRPC server-streaming
cancellation tears a call down: it disposes the response rather than cancelling
the individual read.
Dispose()disposes the content, which callsClose()onthe body stream, which calls
Close()on the JavaInputStream.okhttp implements
InputStream.close()on a chunked/fixed-length body bydraining it — issuing another
read()so the connection can be reused. ItsAsyncTimeoutkeeps a process-global list of timeout nodes that each readenters/exits. When the drain-read runs while the original read is still parked
in
AsyncTimeout.enter(), the node is entered twice and okhttp throwsjava.lang.IllegalStateException: Unbalanced enter/exit, crashing theprocess. (The same concurrent close could also corrupt managed
BufferedStreamstate and surface as anArgumentOutOfRangeException.)The invariant that avoids it: never call
Close()on the underlying streamwhile a body operation is in flight. A managed
CancellationTokencannot byitself interrupt the blocking Java read — only
HttpURLConnection.Disconnect()(closing the socket) makes a parked read throw and unwind.
Fix
Reworked
CancellationAwareResponseStreaminto a "drain, then close" schemethat mirrors the idiomatic Java/OkHttp pattern (the reader owns
close(); aseparate thread only signals cancellation via
disconnect()):BeginUse()/EndUse().Dispose()records the request and cancels an internalCancellationTokenSource, which aborts the parked operation via abackgrounded
HttpURLConnection.Disconnect().Dispose()never blocks.(
EndUse→DisposeCore) — the only point at which closing the Java streamcannot collide with an active read. When nothing is in flight,
Dispose()closes directly. This guarantees the stream is always closed exactly once
(no leak) and never during an active operation.
ObjectDisposedException.aborts (mapping to
OperationCanceledException) when the caller cancelsdirectly.
Flushare drain-aware too, not just reads — any blocking socketoperation races dispose identically.
HttpURLConnectionJava peer isDisconnect()-ed, never disposed:deleting its JNI global reference could race a still-unwinding read. The peer
is reclaimed on finalization.
AndroidHttpResponseMessage.Dispose()disconnectssynchronously as the backstop for non-streaming (buffered) responses; closing a
socket does not trigger
NetworkOnMainThreadException, so it is safe on thedisposing (possibly UI) thread.
The reasoning is documented inline, since the ordering is non-obvious.
Fixes #12106
Testing
New on-device tests in
AndroidMessageHandlerCancellationTestsexercise thedispose/cancel sequences against a stalled local server.
Baseline confirmed against
main: built the tests against unmodifiedmain—
ResponseHeadersReadDisposeResponseDuringBodyRead...,ResponseHeadersReadDisposeResponseDuringCanceledBodyRead..., andResponseHeadersReadTokenPassedToReadCancelAndDisposeConcurrently...all crashwith the reported
Unbalanced enter/exit, proving these are genuine regressiontests. Notably,
ResponseHeadersReadTokenPassedToReadCancelOnly(no dispose)passes on
main: flowing the token to the read is neither the cause nor asufficient fix — the crash is
Dispose()racing an in-flight read.With the fix, the full
AndroidMessageHandlerCancellationcategory is 7/7green on an Android 16 / API 36 physical device, covering:
ResponseContentReadandResponseHeadersReadcompletion options) and of a request-body upload;cancellation-aborted body read, without racing the stream close;
HttpResponseMessage.Dispose()firingconcurrently — completing without a native crash.
Note
This pull request description was prepared with GitHub Copilot.