Skip to content

Commit 858c1dc

Browse files
authored
fix: prevent NPE in netty HandlerSubscriber (#7297)
A channelWritabilityChanged event during the Expect: 100-continue subscribe-deferral window called maybeRequestMore() while the body subscription was still null, throwing a non-retryable NPE that failed async S3 uploads under high concurrency. Only call maybeRequestMore() once the subscriber is RUNNING. Fixes #7271
1 parent 159da39 commit 858c1dc

3 files changed

Lines changed: 175 additions & 1 deletion

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "bugfix",
3+
"category": "Netty NIO HTTP Client",
4+
"contributor": "",
5+
"description": "Fixed a `NullPointerException` in `HandlerSubscriber` that could intermittently fail async requests (such as S3 `PutObject`/`UploadPart`) when a channel writability change occurred during the `Expect: 100-continue` window before the request body subscription was established. See [#7271](https://github.com/aws/aws-sdk-java-v2/issues/7271)."
6+
}

http-clients/netty-nio-client/src/main/java/software/amazon/awssdk/http/nio/netty/internal/nrs/HandlerSubscriber.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,12 @@ private void verifyRegisteredWithRightExecutor(ChannelHandlerContext ctx) {
154154

155155
@Override
156156
public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {
157-
maybeRequestMore();
157+
// Only request demand once RUNNING. A writability change can arrive while the subscription is still pending
158+
// (the Expect: 100-continue deferral window, where the subscribe is held back until the server answers), when
159+
// maybeRequestMore() would dereference a null subscription. See #7271.
160+
if (state == HandlerSubscriber.State.RUNNING) {
161+
maybeRequestMore();
162+
}
158163
ctx.fireChannelWritabilityChanged();
159164
}
160165

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package software.amazon.awssdk.http.nio.netty.internal.nrs;
17+
18+
import static java.nio.charset.StandardCharsets.UTF_8;
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
21+
import io.netty.buffer.ByteBuf;
22+
import io.netty.buffer.Unpooled;
23+
import io.netty.channel.ChannelHandlerContext;
24+
import io.netty.channel.ChannelInboundHandlerAdapter;
25+
import io.netty.channel.embedded.EmbeddedChannel;
26+
import io.netty.handler.codec.http.DefaultFullHttpResponse;
27+
import io.netty.handler.codec.http.DefaultHttpContent;
28+
import io.netty.handler.codec.http.HttpContent;
29+
import io.netty.handler.codec.http.HttpMethod;
30+
import io.netty.handler.codec.http.HttpResponse;
31+
import io.netty.handler.codec.http.HttpResponseStatus;
32+
import io.netty.handler.codec.http.HttpUtil;
33+
import io.netty.handler.codec.http.HttpVersion;
34+
import java.util.concurrent.atomic.AtomicBoolean;
35+
import java.util.concurrent.atomic.AtomicReference;
36+
import org.junit.jupiter.api.AfterEach;
37+
import org.junit.jupiter.api.BeforeEach;
38+
import org.junit.jupiter.api.Test;
39+
import org.reactivestreams.Publisher;
40+
import org.reactivestreams.Subscriber;
41+
import org.reactivestreams.Subscription;
42+
43+
/**
44+
* Regression coverage for the NPE reported in
45+
* <a href="https://github.com/aws/aws-sdk-java-v2/issues/7271">#7271</a>: when a request carries
46+
* {@code Expect: 100-continue}, {@link HttpStreamsClientHandler} defers subscribing the {@link HandlerSubscriber} to the
47+
* body until the server answers {@code 100 Continue}. A {@code channelWritabilityChanged} event fired during that window
48+
* used to dereference the still-null subscription and throw.
49+
*/
50+
public class HandlerSubscriberExpectContinueTest {
51+
52+
private EmbeddedChannel channel;
53+
private ExceptionCapturingHandler exceptionCapture;
54+
55+
@BeforeEach
56+
public void setup() {
57+
channel = new EmbeddedChannel(new HttpStreamsClientHandler());
58+
exceptionCapture = new ExceptionCapturingHandler();
59+
channel.pipeline().addLast(exceptionCapture);
60+
}
61+
62+
@AfterEach
63+
public void teardown() {
64+
channel.finishAndReleaseAll();
65+
}
66+
67+
@Test
68+
public void channelWritabilityChanged_whenSubscriptionPending_doesNotRouteExceptionToPipeline() {
69+
writeExpectContinueRequest(new SingleChunkPublisher("body"));
70+
71+
channel.pipeline().fireChannelWritabilityChanged();
72+
73+
assertThat(exceptionCapture.captured()).isNull();
74+
}
75+
76+
@Test
77+
public void channelWritabilityChanged_whenSubscriptionPending_bodyStreamsAfter100Continue() {
78+
SingleChunkPublisher body = new SingleChunkPublisher("body");
79+
writeExpectContinueRequest(body);
80+
81+
channel.pipeline().fireChannelWritabilityChanged();
82+
deliver100Continue();
83+
84+
assertThat(body.subscribed()).isTrue();
85+
assertThat(streamedBody()).isEqualTo("body");
86+
assertThat(exceptionCapture.captured()).isNull();
87+
}
88+
89+
private void writeExpectContinueRequest(Publisher<HttpContent> body) {
90+
DefaultStreamedHttpRequest request =
91+
new DefaultStreamedHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.PUT, "/", body);
92+
HttpUtil.set100ContinueExpected(request, true);
93+
channel.writeOutbound(request);
94+
}
95+
96+
private void deliver100Continue() {
97+
HttpResponse continueResponse =
98+
new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.CONTINUE);
99+
channel.writeInbound(continueResponse);
100+
channel.runPendingTasks();
101+
}
102+
103+
private String streamedBody() {
104+
StringBuilder body = new StringBuilder();
105+
Object msg;
106+
while ((msg = channel.readOutbound()) != null) {
107+
if (msg instanceof HttpContent) {
108+
body.append(((HttpContent) msg).content().toString(UTF_8));
109+
}
110+
}
111+
return body.toString();
112+
}
113+
114+
private static final class ExceptionCapturingHandler extends ChannelInboundHandlerAdapter {
115+
private final AtomicReference<Throwable> captured = new AtomicReference<>();
116+
117+
@Override
118+
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
119+
captured.compareAndSet(null, cause);
120+
}
121+
122+
Throwable captured() {
123+
return captured.get();
124+
}
125+
}
126+
127+
private static final class SingleChunkPublisher implements Publisher<HttpContent> {
128+
private final byte[] payload;
129+
private final AtomicBoolean subscribed = new AtomicBoolean(false);
130+
131+
private SingleChunkPublisher(String payload) {
132+
this.payload = payload.getBytes(UTF_8);
133+
}
134+
135+
boolean subscribed() {
136+
return subscribed.get();
137+
}
138+
139+
@Override
140+
public void subscribe(Subscriber<? super HttpContent> subscriber) {
141+
subscribed.set(true);
142+
subscriber.onSubscribe(new Subscription() {
143+
private boolean delivered;
144+
145+
@Override
146+
public void request(long n) {
147+
if (n <= 0 || delivered) {
148+
return;
149+
}
150+
delivered = true;
151+
ByteBuf buf = Unpooled.wrappedBuffer(payload);
152+
subscriber.onNext(new DefaultHttpContent(buf));
153+
subscriber.onComplete();
154+
}
155+
156+
@Override
157+
public void cancel() {
158+
delivered = true;
159+
}
160+
});
161+
}
162+
}
163+
}

0 commit comments

Comments
 (0)