Describe the bug
HandlerSubscriber.maybeRequestMore() calls subscription.request(...) with no null check, and channelWritabilityChanged() calls it unconditionally (unlike channelActive(), which is state-gated). subscription is assigned only in onSubscribe() and never reset to null.
For Expect: 100-continue requests, HttpStreamsClientHandler.subscribeSubscriberToStream() deliberately defers the body subscription until the 100 Continue response arrives. The HandlerSubscriber is nonetheless already in the pipeline (added by HttpStreamsHandler.unbufferedWrite()), so it keeps receiving channel events while subscription == null — for a full server round-trip. If channelWritabilityChanged fires in that window, maybeRequestMore() NPEs.
The S3 client sets Expect: 100-continue on every PutObject/UploadPart (StreamingRequestInterceptor), so every async S3 upload takes this path.
Regression Issue
Expected Behavior
The async client should stream the body and complete the upload. During the Expect: 100-continue wait, channelWritabilityChanged must not dereference a not-yet-assigned subscription; it should no-op until onSubscribe runs, then request demand normally.
Current Behavior
Non-retryable failure of the upload:
Caused by: java.lang.NullPointerException: Cannot invoke "org.reactivestreams.Subscription.request(long)" because "this.subscription" is null
at ...netty.internal.nrs.HandlerSubscriber.maybeRequestMore(HandlerSubscriber.java:303)
at ...netty.internal.nrs.HandlerSubscriber.channelWritabilityChanged(HandlerSubscriber.java:157)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelWritabilityChanged(...)
...
Wrapped as SdkClientException: Unable to execute HTTP request: ... "this.subscription" is null and classified non-retryable, so the operation fails.
Reproduction Steps
No standalone repro attached yet. Deterministic conditions: S3AsyncClient PutObject/UploadPart (so Expect: 100-continue is set) against a server that delays the 100 Continue, with a channel writability transition forced during the wait (small write-buffer high-water mark / backpressured body). Happy to contribute a minimal repro.
Possible Solution
Null-guard the deref. (Illustrative — not yet compiled or covered by a test.) When subscription == null the method simply returns; demand is requested when onSubscribe later runs (provideSubscription → maybeStart → maybeRequestMore):
private void maybeRequestMore() {
if (subscription != null
&& outstandingDemand <= demandLowWatermark
&& ctx.channel().isWritable()) {
long toRequest = demandHighWatermark - outstandingDemand;
outstandingDemand = demandHighWatermark;
subscription.request(toRequest);
}
}
Alternatively (or additionally), gate channelWritabilityChanged on state == RUNNING, mirroring channelActive.
Note: S3Configuration.expectContinueEnabled(false) (newer SDK versions) suppresses the header and hides the symptom, but that's a workaround — the unguarded deref in HandlerSubscriber is the actual defect and stays reachable by any path that defers the body subscription past the current event-loop task.
Additional Information/Context
Root cause (line numbers from tag 2.30.31; master has identical channelWritabilityChanged / maybeRequestMore / channelActive bodies)
HandlerSubscriber#maybeRequestMore — subscription.request(toRequest) guarded only by isWritable(), no null/state check.
HandlerSubscriber#channelWritabilityChanged — calls maybeRequestMore() unguarded (contrast channelActive, which checks state).
HttpStreamsHandler#unbufferedWrite — adds the HandlerSubscriber to the pipeline, then calls subscribeSubscriberToStream(...).
HttpStreamsClientHandler#subscribeSubscriberToStream — when is100ContinueExpected(msg), stashes the subscriber and does not subscribe; the subscribe (→ onSubscribe, which sets subscription) happens later in channelRead() on the 100 Continue.
Since subscription is set only in onSubscribe and never nulled: subscription == null ⇔ onSubscribe hasn't run ⇔ (for these requests) still awaiting 100 Continue. Any channelWritabilityChanged in that window hits the unguarded deref.
Why this isn't seen more widely
- Only
PutObject/UploadPart carry Expect: 100-continue, so only uploads defer the subscription; reads never reach this state. (In our workload, 100% of occurrences are UploadPart, none on reads.)
- It also requires a writability change (channel becoming writable) inside that round-trip window — most plausibly the request-head/TLS write draining back below the low-water mark; we haven't traced the exact per-occurrence trigger. That's rare on an idle connection but frequent under high-concurrency uploads with backpressured sockets, so low-volume callers effectively never hit it.
- The same defect was reported upstream long ago (
playframework/netty-reactive-streams#30) and never fixed; the code was vendored here and is still unguarded on master.
AWS Java SDK version used
2.30.31 (also on master); client: netty-nio-client; Netty 4.1.135.Final
JDK version used
21
Operating System and version
Linux
Additional context (caller-side use case, domain load, upload code path, observed signature): see comment below (#7271 (comment))
Describe the bug
HandlerSubscriber.maybeRequestMore()callssubscription.request(...)with no null check, andchannelWritabilityChanged()calls it unconditionally (unlikechannelActive(), which is state-gated).subscriptionis assigned only inonSubscribe()and never reset to null.For
Expect: 100-continuerequests,HttpStreamsClientHandler.subscribeSubscriberToStream()deliberately defers the body subscription until the100 Continueresponse arrives. TheHandlerSubscriberis nonetheless already in the pipeline (added byHttpStreamsHandler.unbufferedWrite()), so it keeps receiving channel events whilesubscription == null— for a full server round-trip. IfchannelWritabilityChangedfires in that window,maybeRequestMore()NPEs.The S3 client sets
Expect: 100-continueon everyPutObject/UploadPart(StreamingRequestInterceptor), so every async S3 upload takes this path.Regression Issue
Expected Behavior
The async client should stream the body and complete the upload. During the
Expect: 100-continuewait,channelWritabilityChangedmust not dereference a not-yet-assigned subscription; it should no-op until onSubscribe runs, then request demand normally.Current Behavior
Non-retryable failure of the upload:
Wrapped as
SdkClientException: Unable to execute HTTP request: ... "this.subscription" is nulland classified non-retryable, so the operation fails.Reproduction Steps
No standalone repro attached yet. Deterministic conditions: S3AsyncClient
PutObject/UploadPart(soExpect: 100-continueis set) against a server that delays the100 Continue, with a channel writability transition forced during the wait (small write-buffer high-water mark / backpressured body). Happy to contribute a minimal repro.Possible Solution
Null-guard the deref. (Illustrative — not yet compiled or covered by a test.) When
subscription == nullthe method simply returns; demand is requested whenonSubscribelater runs (provideSubscription→maybeStart→maybeRequestMore):Alternatively (or additionally), gate
channelWritabilityChangedonstate == RUNNING, mirroringchannelActive.Note:
S3Configuration.expectContinueEnabled(false)(newer SDK versions) suppresses the header and hides the symptom, but that's a workaround — the unguarded deref inHandlerSubscriberis the actual defect and stays reachable by any path that defers the body subscription past the current event-loop task.Additional Information/Context
Root cause (line numbers from tag
2.30.31;masterhas identicalchannelWritabilityChanged/maybeRequestMore/channelActivebodies)HandlerSubscriber#maybeRequestMore—subscription.request(toRequest)guarded only byisWritable(), no null/state check.HandlerSubscriber#channelWritabilityChanged— callsmaybeRequestMore()unguarded (contrastchannelActive, which checksstate).HttpStreamsHandler#unbufferedWrite— adds theHandlerSubscriberto the pipeline, then callssubscribeSubscriberToStream(...).HttpStreamsClientHandler#subscribeSubscriberToStream— whenis100ContinueExpected(msg), stashes the subscriber and does not subscribe; the subscribe (→onSubscribe, which setssubscription) happens later inchannelRead()on the100 Continue.Since
subscriptionis set only inonSubscribeand never nulled:subscription == null⇔onSubscribehasn't run ⇔ (for these requests) still awaiting100 Continue. AnychannelWritabilityChangedin that window hits the unguarded deref.Why this isn't seen more widely
PutObject/UploadPartcarryExpect: 100-continue, so only uploads defer the subscription; reads never reach this state. (In our workload, 100% of occurrences areUploadPart, none on reads.)playframework/netty-reactive-streams#30) and never fixed; the code was vendored here and is still unguarded onmaster.AWS Java SDK version used
2.30.31 (also on master); client: netty-nio-client; Netty 4.1.135.Final
JDK version used
21
Operating System and version
Linux
Additional context (caller-side use case, domain load, upload code path, observed signature): see comment below (#7271 (comment))