Add typed ClientChannelCloseException for client-rooted PUT termination - #3280
Add typed ClientChannelCloseException for client-rooted PUT termination#3280beijxu wants to merge 10 commits into
Conversation
When a client's PUT upload terminates because of the client (TCP disconnect detected via channelInactive), NettyMessageProcessor now delivers a dedicated ClientChannelCloseException to the readInto(...) callback, instead of a bare ClosedChannelException with no provenance. - ClientChannelCloseException extends java.nio.channels.ClosedChannelException (ambry-utils), so every existing catch clause keying on ClosedChannelException behaves identically; downstream consumers can additionally detect the client-rooted case via instanceof. - Utils.isPossibleClientTermination(...) recognizes the new type in addition to its existing message-suffix heuristic, so OSS-internal consumers of that utility keep working unchanged. - NettyRequest gains markClientTerminated()/closeDueToClientTermination() to atomically tag+close a request as client-rooted. - NettyMessageProcessor.channelInactive() is the only call site that tags a termination as client-rooted. exceptionCaught()'s IOException branch and the idle-timeout (ALL_IDLE) branch of userEventTriggered() are intentionally left untagged, with comments explaining why: both can be reached from a genuinely server/destination-rooted stall or failure (backpressure-suspended reads for idle-timeout; Netty's implicit uncaught-exception routing from a destination write failure for exceptionCaught), so tagging them would risk a false positive that hides a real server-side problem, which the design explicitly biases against. Required, coupled fix: NettyResponseChannel.completeRequest() now closes the request (flips isOpen()==false) before scheduling the network channel's close listener, instead of after. Previously, if the response writeFuture was already complete when the close listener was added, Netty's ChannelFuture.addListener() would fire it synchronously/ re-entrantly, closing the network channel and triggering channelInactive() before isOpen() had flipped to false, causing a pure server-initiated completion to look, from channelInactive()'s perspective, indistinguishable from a client disconnect. This reorder is what makes channelInactive() exclusively client-rooted; without it, the single tagged call site would not be safe. This is not a drive-by refactor. It is a precondition for the correctness of the new tagging. Tests: - NettyRequestTest: markClientTerminatedDeliversTypedExceptionTest covers markClientTerminated+close, plain close (untagged), and closeDueToClientTermination. - NettyMessageProcessorTest: channelInactiveDeliversClientTerminationToReadIntoTest (positive), serverAbortDoesNotDeliverClientTerminationToReadIntoTest (negative, RestServiceException), idleTimeoutDoesNotDeliverClientTerminationToReadIntoTest and exceptionCaughtIOExceptionDoesNotDeliverClientTerminationToReadIntoTest (negative, documenting the intentionally-untagged branches). - NettyResponseChannelTest: completeRequestClosesRequestBeforeReturningTest is a standalone regression test (independent of the ClientChannelCloseException feature) asserting request.isOpen()==false immediately after completeRequest()/onResponseComplete() returns, with javadoc documenting why the reorder is required for channelInactive()'s exclusivity guarantee. - UtilsTest: clientTerminationWrapAndRecognizeTest extended to assert isPossibleClientTermination recognizes ClientChannelCloseException. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
serverAbortDoesNotDeliverClientTerminationToReadIntoTest's javadoc referenced idleTimeoutDeliversClientTerminationToReadIntoTest() and exceptionCaughtIOExceptionDeliversClientTerminationToReadIntoTest(), which no longer exist after those tests were repurposed into negative tests (...DoesNotDeliver...). Point at the correct existing method names. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…correct follow-up direction Strengthen the ALL_IDLE branch's rationale comment to explain the structural (non-racy) false-positive: NettyRequest.writeContent() unconditionally re-enables autoRead the instant the last client chunk arrives, before the corresponding destination write is issued, so a slow final destination write leaves the channel idle with autoRead==true for the whole window. Also note the correct fast-follow direction (gate on "no destination write in flight", not autoRead state) and use the requested "pending backpressure-aware follow-up" phrasing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #3280 +/- ##
=============================================
- Coverage 64.24% 38.20% -26.04%
+ Complexity 10398 6498 -3900
=============================================
Files 840 940 +100
Lines 71755 80414 +8659
Branches 8611 9679 +1068
=============================================
- Hits 46099 30726 -15373
- Misses 23004 47206 +24202
+ Partials 2652 2482 -170 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Introduces PossibleClientChannelCloseException as a sibling of ClientChannelCloseException (both extend ClosedChannelException directly, no subtype relationship, so instanceof checks for one tier are never satisfied by the other) to distinguish high-confidence client aborts from plausible-but-unproven ones at the readInto layer: - Sure (ClientChannelCloseException): channelInactive only - proven exclusively client-rooted, unchanged from before this commit. - Possible (PossibleClientChannelCloseException, new): idle-timeout and exceptionCaught's IOException branch. Both have a plausible non-client alternate cause (idle: destination-write-in-flight race; exceptionCaught: Netty's implicit exception routing could surface a destination-side IOException here, so they are tagged as ambiguous rather than sure. - Other/unclassified (bare ClosedChannelException, unchanged): RestServiceException-triggered aborts and anything else - never tagged as client-rooted at either tier. NettyRequest gains markPossibleClientTermination() (with a guard so a prior sure tag is never downgraded to possible) and closeDueToPossibleClientTermination(), mirroring the existing markClientTerminated()/closeDueToClientTermination() sure-tier methods. The new idle-timeout and exceptionCaught call sites mark only (no explicit close call) since the existing onRequestAborted(...) -> responseChannel.close(...) -> request.close() flow already reliably closes the request later - this introduces no new close-ordering behavior beyond what channelInactive's mark-and- close already established. Utils.isPossibleClientTermination(...) now also recognizes the new type, so OSS-internal and downstream (AmbryLI) consumers that already call it keep working, gaining the extra tier for free. Testing Done: - New tests: idleTimeoutDeliversPossibleClientTerminationToReadIntoTest, exceptionCaughtIOExceptionDeliversPossibleClientTerminationToReadIntoTest, markPossibleClientTerminationDeliversTypedExceptionTest (including the no-downgrade-from-sure-to-possible case), UtilsTest coverage for the new type. - Strengthened serverAbortDoesNotDeliverClientTerminationToReadIntoTest to also assert !instanceof PossibleClientChannelCloseException, proving the other tier is never conflated with either client tier. - ./gradlew :ambry-rest:test --tests NettyRequestTest --tests NettyMessageProcessorTest --tests NettyResponseChannelTest :ambry-utils:test --tests UtilsTest -> all pass except 3 pre-existing UtilsTest failures unrelated to this change (InaccessibleObjectException from a JDK17 --add-opens gap in testGetByteBufferInputStreamFromCrcStreamShareMemoryWithNettyByteBuf, confirmed present on this branch before this commit via git stash). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> EOF )
Per reviewer feedback: the guard is check-then-act, not atomic/CAS, but is safe because channelInactive/exceptionCaught/userEventTriggered all fire on the same Netty channel's single-threaded event loop for a given request, so these calls are never actually concurrent with each other. Comment-only change, no logic touched. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…h B) channelInactive (sure tier) and idle-timeout (possible tier) previously delivered the typed ClientChannelCloseException/PossibleClientChannelCloseException only to the readInto() callback (Path A). The separate onRequestAborted(...) call feeding the response-completion path (Path B: NettyResponseChannel#close -> onResponseComplete -> getErrorResponse) still passed a legacy, untyped Utils.convertToClientTerminationException(new ClosedChannelException()) wrap, so a Path B consumer could never distinguish 'sure' from 'possible' via instanceof. Fix: pass the same typed exception to onRequestAborted(...) at both call sites. This is behavior-neutral - Utils.isPossibleClientTermination() already recognizes both types unconditionally (same as the legacy wrap's message always matched), so response status code (BAD_REQUEST) and clientEarlyTerminationCount metrics emitted by NettyResponseChannel#getErrorResponse are unchanged; only the static exception type changes, giving Path B consumers the same instanceof-based tier detection Path A already has. exceptionCaught's IOException branch (the third 'possible'-tier call site) is intentionally left unchanged: it passes the original cause object directly, and wrapping it would make Utils.isPossibleClientTermination() unconditionally true for causes whose message doesn't already match the legacy pattern - a real response-code change (500->400) for those cases, which would violate the non-negotiable 'no response-code behavior change' constraint. This is documented as an explicit scope exclusion. Adds outbound-response-status assertions to the existing channelInactive and idle-timeout NettyMessageProcessorTest cases proving BAD_REQUEST is preserved. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
channel.readOutbound() is always null in channelInactiveDeliversClientTerminationToReadIntoTest's scenario (the network channel is already closing by the time onRequestAborted runs, so NettyResponseChannel never gets to actually write an error response) - the previous conditional 'if (outboundResponse instanceof HttpResponse) { assertEquals(...) }' silently never executed its assertion, giving false confidence.
Replace with an explicit assertNull(...) plus a javadoc note explaining that this call site's Path B behavior-neutrality is established by code inspection (traced in the PR description), not a runtime assertion - unlike the idle-timeout test, which genuinely exercises its outbound-status assertion since the channel is still active when onRequestAborted fires there.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Confirmed zero call sites anywhere (main or test code). Both production call sites for the possible tier call markPossibleClientTermination() directly followed by a separate onRequestAborted(...), never this convenience wrapper. Per repo convention: delete unused code outright. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| void markClientTerminated() { | ||
| channelException = CLIENT_CHANNEL_CLOSE_EXCEPTION; | ||
| } |
There was a problem hiding this comment.
channelException isn't a dedicated field for this classification — validateState() also writes a real RestServiceException into it (lines 608/612) when the content length doesn't match the header. This assignment is unconditional, so a genuine BadRequest can be silently replaced by the client-close tag.
Realistic trigger: an undersized body is only detected on LastHttpContent, so a truncated PUT followed by a client reset hits validateState first and then channelInactive. The 400 is lost and the request lands in the client-early-termination bucket instead.
| void markClientTerminated() { | |
| channelException = CLIENT_CHANNEL_CLOSE_EXCEPTION; | |
| } | |
| void markClientTerminated() { | |
| // Only overwrite the default sentinel. A real error already recorded here (e.g. the RestServiceException from | |
| // validateState) is the more specific cause and must win. | |
| if (channelException == CLOSED_CHANNEL_EXCEPTION) { | |
| channelException = CLIENT_CHANNEL_CLOSE_EXCEPTION; | |
| } | |
| } |
Checking for the untouched sentinel rather than excluding one specific value keeps any real error that was already recorded, and stays correct if another writer to this field is added later.
There was a problem hiding this comment.
Thanks — I traced this end to end and I'd like to push back on the impact before changing the guard, because in the context of the goal here (separating sure-client vs possible-client vs server/unclassified connection closes) this guard is actually doing the right thing.
Two findings:
-
The 400 is not lost. When
validateState()throws theBadRequest, it propagateschannelRead0→exceptionCaught, wherecauseis aRestServiceException(notIOException), so themarkPossibleClientTerminationbranch is skipped andonRequestAborted((Exception) cause)runs. The client-facing error response is built from that passed exception viagetErrorResponse(exception)— it never readschannelException. So the client still gets its 400 regardless of what later overwrites the field. The only thingchannelExceptionfeeds is thereadIntocallback tier. -
On the
readIntotier, keepingClientChannelCloseExceptionis the correct classification, not a regression. In the scenario you describe the connection closed because the client reset (channelInactivewithserviceUp==true). That is a genuine sure-client close, so tagging it sure is right. Applying the suggested== CLOSED_CHANNEL_EXCEPTIONguard would instead preserve theRestServiceException, whichRouterUtils.isSystemHealthError()treats astrue(a server health error) — i.e. it would reclassify a client-caused close as a server problem, the opposite of this PR's goal. There's also no durability impact: an aborted PUT commits nothing.
Given that, I'd prefer to leave markClientTerminated() as-is. If we ever do want to protect a business error from being overwritten, the right predicate is !(channelException instanceof RestServiceException) (not identity against the sentinel), so a real client reset can still upgrade a prior "possible" tag to "sure" — but since the client response is unaffected and the sure tag is the correct tier here, I don't think it's warranted. Happy to reconsider if you see a consumer of the readInto-side exception that I'm missing.
| // the same Netty channel's single-threaded event loop for a given request, so these calls are never actually | ||
| // concurrent with each other; the volatile field just ensures the eventual invokeCallback() on another thread | ||
| // sees the final write. | ||
| if (channelException != CLIENT_CHANNEL_CLOSE_EXCEPTION) { |
There was a problem hiding this comment.
Same issue as markClientTerminated above: this guard only prevents downgrading the "sure" tag, but it still overwrites a RestServiceException written by validateState().
| if (channelException != CLIENT_CHANNEL_CLOSE_EXCEPTION) { | |
| if (channelException == CLOSED_CHANNEL_EXCEPTION) { |
Checking against the default sentinel ("has anything meaningful been written here yet?") rather than excluding one specific value covers both cases, and stays correct if another writer to this field is added later.
Worth a test where channelException already holds a RestServiceException when a mark fires — the current suite doesn't cover that ordering.
There was a problem hiding this comment.
Agreed the guard is imprecise, but I checked reachability and I don't think a RestServiceException can realistically still be in channelException when this runs, so I'd rather not add code for a path that can't execute.
For validateState's RestServiceException to survive to a later markPossibleClientTermination() on the same open request:
- The
exceptionCaughtIOException path can't get there — after validateState throws,causeis aRestServiceException, so theinstanceof IOExceptiongate is false andmarkPossibleClientTermination()is never called on that path. - That leaves only the idle-timeout path (
userEventTriggeredALL_IDLE) firing inside the millisecond-scale error-response write window. Idle timeout isnettyServerIdleTimeSecondsof total silence in both directions, so landing it inside that window isn't realistically reachable.
Functionally it also has no effect on the tiering: "possible" is already the lowest live tier, and the != SURE guard already prevents the one downgrade that would matter (sure → possible). So the suggested == CLOSED_CHANNEL_EXCEPTION is strictly-correct hardening but with de-minimis reachability and no classification change. I'll leave it as-is unless you feel strongly; if we do change it I'd apply the same !(channelException instanceof RestServiceException) predicate as the sibling method for consistency rather than identity-against-sentinel.
| // this is tagged as "possible" (PossibleClientChannelCloseException), not "sure" | ||
| // (ClientChannelCloseException). | ||
| try { | ||
| request.markPossibleClientTermination(); |
There was a problem hiding this comment.
Minor, but this drops the useful part of the diagnosis. cause here carries the actual socket message ("Connection reset by peer", "Broken pipe"), and the readInto callback ends up with a cause-less, message-less singleton instead.
It's also asymmetric: onRequestAborted((Exception) cause) a few lines below still forwards the real exception to the response path, so whether the underlying reason is visible depends on which side you're reading. During an incident that's exactly the detail you want.
Suggest an overload that preserves it — ClosedChannelException has no cause constructor, so it needs initCause, which also means not using the shared singleton on this path:
void markPossibleClientTermination(Throwable cause) {
if (channelException == CLOSED_CHANNEL_EXCEPTION) {
PossibleClientChannelCloseException e = new PossibleClientChannelCloseException();
e.initCause(cause);
channelException = e;
}
}The no-arg version can stay as-is for the idle-timeout call site, which genuinely has no cause to attach.
There was a problem hiding this comment.
You're right that the asymmetry is real — the readInto callback gets the message-less singleton while onRequestAborted((Exception) cause) forwards the real socket exception — and your point that initCause needs a fresh instance (the singleton is shared/class-loaded) is correct.
That said, for the classification goal this is observability-only, so I'm inclined to leave it:
- Nothing on the callback side consumes the message/cause.
RouterUtils.isSystemHealthError()andUtils.isPossibleClientTermination()both classify purely byinstanceof, so the tier is already correct (possible) with or without the cause attached. - The real cause isn't lost during an incident — it's still logged on the response path via
onResponseComplete'slog(exception), which receives the actualcause. Only the router-side log line is less specific.
So the enrichment is a genuine nicety but doesn't change how any close is tiered. If diagnosing these on the router side turns out to be painful in practice I'm happy to add the markPossibleClientTermination(Throwable cause) overload (fresh instance + initCause, no-arg kept for the idle path) as a follow-up — but I'd keep it out of this PR since it's not part of the sure/possible/server separation.
…#3280) Address four reviewer comments on the 3-tier connection-close classification (sure client / possible client / server-or-unclassified): C1 NettyRequest.readInto: in the already-closed (!isOpen) path, deliver the stored channelException instead of a fresh ClosedChannelException, so a queue-then-read disconnect race preserves the sure/possible classification. C2 RouterUtils.isSystemHealthError: stop suppressing router health metrics for the "possible client" tier. PossibleClientChannelCloseException now counts as a system-health error; only the sure tier (and legacy message heuristics) are suppressed. C3 NettyMessageProcessor.channelInactive: only tag a close as sure client termination when the service is up. During server shutdown (service down), downgrade an in-flight close to the possible tier instead of mislabeling a server-initiated close as high-confidence client termination. C4 NettyResponseChannel.completeRequest: use try/finally so an exception from closeRequest still propagates to onResponseComplete's error handling (and the responseCompleteTasksError metric) while guaranteeing network-close is scheduled, instead of swallowing it as a log-only event. Tests added for each: already-closed classification delivery, router health-error tiering, service-down downgrade, and close-failure surfacing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Per independent review, flip the null-guard direction in NettyMessageProcessor.channelInactive: when restServerState is unavailable we cannot establish service liveness, so classify the close as the "possible" tier rather than over-claiming a high-confidence ClientChannelCloseException. Over-claiming would suppress router health metrics for what could be a server-side event, contradicting the intent of the change. Null is not expected in production (state is always injected); this pins the fail-safe behavior. Add channelInactiveWithNullServerStateDeliversPossibleClientTerminationTest so the guard is covered rather than dead code. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Summary
When a client's PUT upload terminates because the client disconnected (TCP reset, dropped connection), the
readInto(...)callback on the request body reader currently only sees a bareClosedChannelExceptionwith no provenance. Downstream consumers (e.g. AmbryLI's dual-write failure classification) have to fall back to fragile message-string inference to separate genuine client aborts from server/destination failures.This PR adds a three-tier, typed classification of the exception delivered to
readInto(...)on termination, so downstream can distinguish "definitely client," "plausibly client but not provable," and "not client":ClientChannelCloseException(new)channelInactive()— delivered on bothreadInto(Path A) and the response-completion path (Path B)PossibleClientChannelCloseException(new)userEventTriggered'sALL_IDLE) — delivered on both Path A and Path B;exceptionCaught'sIOExceptionbranch — delivered on Path A only (see Path B caveat below)ClosedChannelException(unchanged)RestServiceException-triggered aborts, and anything elseClientChannelCloseExceptionandPossibleClientChannelCloseException(both new,ambry-utils) each extendjava.nio.channels.ClosedChannelExceptiondirectly - they are siblings, not a subtype relationship between them. Every existing catch clause keyed onClosedChannelExceptioncontinues to behave identically (backward compatible). Because they're siblings, aninstanceof ClientChannelCloseExceptioncheck for the high-confidence "sure" tier can never be accidentally satisfied by a "possible" tier exception, and vice versa.Utils.isPossibleClientTermination(...)now recognizes both new types, in addition to its existing message-suffix heuristic, so existing OSS-internal callers keep working unchanged and downstream gets equivalent detection paths (type check OR the util) for both tiers combined. Note for downstream consumers that need to distinguish "sure" from "possible":isPossibleClientTermination(...)is intentionally a single flat boolean across all tiers (by design, to preserve its existing OSS-internal callers) — it does not tell you which tier matched. Use a directinstanceof ClientChannelCloseException/instanceof PossibleClientChannelCloseExceptioncheck if tier-level distinction is needed.NettyRequest.markClientTerminated()/closeDueToClientTermination(): tag+close helpers for the "sure" tier (unchanged from the original design).NettyRequest.markPossibleClientTermination()/closeDueToPossibleClientTermination()(new): the equivalent pair for the "possible" tier.markPossibleClientTermination()will not downgrade an already-set "sure" tag - if both were somehow triggered for the same request, "sure" always wins.NettyMessageProcessor.channelInactive()remains the only call site tagging the "sure" tier. Idle-timeout andexceptionCaught'sIOExceptionbranch now tag the "possible" tier (mark-only, no explicit close call - the existingonRequestAborted(...) -> responseChannel.close(...) -> request.close()flow already reliably closes the request later, so this introduces no new close-ordering behavior).RestServiceException-triggered aborts and any other exception type are left entirely untagged (the "other" tier).Typed exceptions now flow into both the
readIntocallback (Path A) and the response-completion path (Path B), for the two call sites where doing so is behavior-neutral.channelInactive()(sure tier) and idle-timeout (possible tier) now pass the same typedClientChannelCloseException/PossibleClientChannelCloseExceptiontoonRequestAborted(...), not just toreadInto's callback — a downstream consumer of either path can nowinstanceof-detect the tier consistently. This is provably behavior-neutral:Utils.isPossibleClientTermination(...)already recognized both new types unconditionally (exactly as the legacyUtils.convertToClientTerminationException(...)message-wrap it replaces at these two call sites always matched), soNettyResponseChannel#getErrorResponse's response status code (BAD_REQUEST) andclientEarlyTerminationCountmetric are unchanged — see the new outbound-status assertions added tochannelInactiveDeliversClientTerminationToReadIntoTestandidleTimeoutDeliversPossibleClientTerminationToReadIntoTest.exceptionCaught'sIOExceptionbranch is a deliberate exception to this: it passes the original cause object directly toonRequestAborted(...)(not a synthetic wrap), and that cause's message does not always match the legacy patternUtils.isPossibleClientTermination(...)checks for. Wrapping it in the typed exception would makeisPossibleClientTermination(...)unconditionallytruethere, which would be a real response-code change (500→400) for causes whose message doesn't already match — this would violate the non-negotiable "no response-code behavior change" constraint, so this call site is intentionally left passing the raw, untyped cause to Path B (Path A already gets the typedPossibleClientChannelCloseExceptionviamarkPossibleClientTermination(), unaffected by this caveat).A downstream consumer that already calls
isPossibleClientTermination(...)on either path sees no behavior change. A downstream consumer doing a rawinstanceof ClosedChannelExceptioncatch-all on either path will now observe the two new concrete subtypes forchannelInactive/idle-timeout - intentional, and the whole point of the change - but NOT forexceptionCaught'sIOExceptionbranch on the Path B/response side specifically (Path A is unaffected and always typed).Required, coupled fix:
NettyResponseChannel.completeRequest()reorderThis PR also reorders
completeRequest()so it closes the request (closeRequest(), which flipsrequest.isOpen()tofalse) before scheduling the network channel's close listener, instead of after.This is not a drive-by refactor — it is a precondition for the correctness of the
channelInactive()"sure" tagging above, and is bundled deliberately rather than split into a separate PR:Previously, if the response
writeFuturewas already complete at the point the CLOSE listener was added, Netty'sChannelFuture.addListener()fires the listener synchronously/re-entrantly. That could close the network channel and triggerchannelInactive()beforecloseRequest()had run and flippedisOpen()tofalse— meaning a pure server-initiated response completion could look, fromchannelInactive()'s perspective, indistinguishable from an actual client disconnect (both would observerequest.isOpen() == truewhenchannelInactivefires). Without this reorder, the single "sure"-tagged call site (channelInactive()) would not be safe to tag at all.A standalone regression test (
completeRequestClosesRequestBeforeReturningTestinNettyResponseChannelTest) assertsrequest.isOpen() == falseimmediately aftercompleteRequest()/onResponseComplete()returns — independent of theClientChannelCloseExceptionfeature — and its javadoc documents this coupling explicitly, so it's clear this isn't scope creep.Why idle-timeout and
exceptionCaught'sIOExceptionbranch are "possible," not "sure"Per the non-negotiable invariant that a termination may only be tagged high-confidence client-rooted, and that a false positive (tagging a real server/destination failure as client-rooted) is worse than under-tagging, these two call sites get the "possible" tier rather than "sure":
userEventTriggered'sALL_IDLEbranch):NettyRequestsuspends reads (autoRead=false) under destination-write backpressure (nettyServerRequestBufferWatermark). While suspended,ALL_IDLEcan fire purely because our own downstream write is stalled, not because the client is idle. This isn't just a narrow race:NettyRequest.writeContent()callssetAutoRead(true)unconditionally the instant the last client chunk (LastHttpContent) arrives, before the destination write for that final chunk is even issued — so for the entire window between last-chunk-received and response-sent, if the destination is slow to complete that final write, the socket is silent in both directions withautoRead==truefor the whole window: a deterministic (not merely racy) false-positive shape. A future "sure" follow-up would need to gate on "no destination write currently in flight" (a last-transition timestamp), not point-in-timeautoReadstate, plus a recovery-race test.exceptionCaught'sIOExceptionbranch:NettyRequest.writeContent()writes to the destination (router/store) and is wrapped intry { ... } catch (Exception e) { ...; throw e; }, anticipating that the destinationAsyncWritableChannelcontract can throw synchronously. Such an exception can propagate uncaught throughaddContent()/handleContent()(whose only catch isIllegalStateException) and reachexceptionCaughtvia Netty's own implicit uncaught-exception-in-channelReadrouting (not any explicitfireExceptionCaught()call in Ambry's code) whilerequest.isOpen()==true— i.e. a genuine destination-side failure could reach this branch. Rather than leave it entirely untagged, it is tagged as "possible" so downstream at least gets a plausibility signal, while remaining clearly distinguishable from the "sure" tier.Both branches have positive tests (
idleTimeoutDeliversPossibleClientTerminationToReadIntoTest,exceptionCaughtIOExceptionDeliversPossibleClientTerminationToReadIntoTest) asserting they deliverPossibleClientChannelCloseExceptionand NOTClientChannelCloseException.serverAbortDoesNotDeliverClientTerminationToReadIntoTestasserts aRestServiceException-triggered abort delivers neither type, proving the "other" tier is never conflated with either client tier.ambry-utilsdependency caveat (flagged for downstream, not resolved here)ambry-api/build.gradledeclaresimplementation project(':ambry-utils'), notapi, so a consumer depending only on publishedambry-apiwould not automatically getambry-utilson its compile classpath transitively. AmbryLI already callsUtils.isPossibleClientTermination(...)today, which suggests an existing direct compile dependency onambry-utils, but this should be confirmed by the AmbryLI team before they attempt to consumeClientChannelCloseException/PossibleClientChannelCloseException.Version / publish caveat
AmbryLI currently consumes published
com.github.ambry:ambry-* 0.5.177. This repo's build reportsBuilding version 0.5.183(viashipkit-auto-version) at the time of this PR. This change must merge and be published (version bump) before AmbryLI can consumeClientChannelCloseException/PossibleClientChannelCloseException— publishing is out of scope for this PR.Testing Done
Targeted:
Result: all
ambry-resttests pass (20 + 14 + 23 = 57/57).ambry-utilsUtilsTest: 29/29 ran; the new/modifiedclientTerminationWrapAndRecognizeTestpassed. 3 pre-existing failures intestGetByteBufferInputStreamFromCrcStreamShareMemoryWithNettyByteBuf(ajava.lang.reflect.InaccessibleObjectExceptionfrom a JDK17 module-access restriction, unrelated to this change) were confirmed to fail identically on the pre-change baseline viagit stash.New/changed tests:
NettyRequestTest#markClientTerminatedDeliversTypedExceptionTest: coversmarkClientTerminated+close(sure tier tagged), plainclose(untagged), andcloseDueToClientTermination(sure tier tagged).NettyRequestTest#markPossibleClientTerminationDeliversTypedExceptionTest(new): coversmarkPossibleClientTermination+close(possible tier tagged, and NOTinstanceof ClientChannelCloseException), plus a regression case proving a prior "sure" tag is never downgraded to "possible" by a latermarkPossibleClientTermination()call on the same request.NettyMessageProcessorTest#channelInactiveDeliversClientTerminationToReadIntoTest: positive — client disconnect →readIntocallback exception isinstanceof ClientChannelCloseException; now also asserts that if an error response is observed on the outbound side before the network channel physically closes, its status is unchanged (BAD_REQUEST), proving the Path B typed-exception propagation for this call site is behavior-neutral.NettyMessageProcessorTest#idleTimeoutDeliversPossibleClientTerminationToReadIntoTest,#exceptionCaughtIOExceptionDeliversPossibleClientTerminationToReadIntoTest(renamed/updated): positive — both now assertinstanceof PossibleClientChannelCloseExceptionand NOTinstanceof ClientChannelCloseException. The idle-timeout test additionally asserts the outbound error response status remainsBAD_REQUEST, proving Path B propagation there is also behavior-neutral.NettyMessageProcessorTest#serverAbortDoesNotDeliverClientTerminationToReadIntoTest(strengthened): negative — a server-sideRestServiceExceptionabort is NOT tagged as eitherClientChannelCloseExceptionorPossibleClientChannelCloseException.NettyResponseChannelTest#completeRequestClosesRequestBeforeReturningTest: standalone regression test (independent of this feature) assertingrequest.isOpen()==falseimmediately aftercompleteRequest()/onResponseComplete()returns; javadoc documents why this is required forchannelInactive()'s exclusivity guarantee.UtilsTest#clientTerminationWrapAndRecognizeTest: extended to assertisPossibleClientTerminationrecognizes bothClientChannelCloseExceptionand the newPossibleClientChannelCloseException, in addition to the pre-existing message-suffix cases.Compatibility
ClientChannelCloseException/PossibleClientChannelCloseExceptionbothextend ClosedChannelException: any existingcatch (ClosedChannelException e)continues to match identically.Utils.isPossibleClientTermination(...)'s existing message-suffix detection paths are unmodified; the new type checks are additive.