Skip to content

NettyNioAsyncHttpClient:NPE in HandlerSubscriber.maybeRequestMore (null subscription) when channelWritabilityChanged fires during the Expect: 100-continue window #7271

Description

@Adiiigo

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

  • Select this option if this issue appears to be a regression.

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 (provideSubscriptionmaybeStartmaybeRequestMore):

  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)

  1. HandlerSubscriber#maybeRequestMoresubscription.request(toRequest) guarded only by isWritable(), no null/state check.
  2. HandlerSubscriber#channelWritabilityChanged — calls maybeRequestMore() unguarded (contrast channelActive, which checks state).
  3. HttpStreamsHandler#unbufferedWrite — adds the HandlerSubscriber to the pipeline, then calls subscribeSubscriberToStream(...).
  4. 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 == nullonSubscribe 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))

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugThis issue is a bug.needs-reviewThis issue or PR needs review from the team.p2This is a standard priority issue

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions