diff --git a/servicetalk-http-api/src/testFixtures/java/io/servicetalk/http/api/Matchers.java b/servicetalk-http-api/src/testFixtures/java/io/servicetalk/http/api/Matchers.java new file mode 100644 index 0000000000..8c1c6e48ce --- /dev/null +++ b/servicetalk-http-api/src/testFixtures/java/io/servicetalk/http/api/Matchers.java @@ -0,0 +1,58 @@ +/* + * Copyright © 2020 Apple Inc. and the ServiceTalk project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.servicetalk.http.api; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; + +import static io.servicetalk.http.api.CharSequences.contentEquals; +import static java.util.Objects.requireNonNull; + +/** + * Custom {@link Matcher}s specific to http-api. + */ +public final class Matchers { + + private Matchers() { + // No instances + } + + /** + * {@link Matcher} representation for {@link CharSequences#contentEquals(CharSequence, CharSequence)}. + * + * @param expected expected {@link CharSequence} value + * @return a {@link Matcher} to verify content equality of two {@link CharSequence}s + */ + public static Matcher contentEqualTo(final CharSequence expected) { + requireNonNull(expected); + return new TypeSafeMatcher() { + + @Override + protected boolean matchesSafely(final CharSequence item) { + if (item == null) { + return false; + } + return contentEquals(expected, item); + } + + @Override + public void describeTo(final Description description) { + description.appendValue(expected); + } + }; + } +} diff --git a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/H1ProtocolConfig.java b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/H1ProtocolConfig.java index 169636b27f..4e4b14de17 100644 --- a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/H1ProtocolConfig.java +++ b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/H1ProtocolConfig.java @@ -1,5 +1,5 @@ /* - * Copyright © 2019 Apple Inc. and the ServiceTalk project authors + * Copyright © 2019-2020 Apple Inc. and the ServiceTalk project authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -87,4 +87,11 @@ default String alpnId() { * trailer fields */ int trailersEncodedSizeEstimate(); + + /** + * Additional exceptions for HTTP/1.1 specification. + * + * @return exceptions for HTTP/1.1 specification + */ + H1SpecExceptions specExceptions(); } diff --git a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/H1ProtocolConfigBuilder.java b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/H1ProtocolConfigBuilder.java index b71d6f1298..4c430062fc 100644 --- a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/H1ProtocolConfigBuilder.java +++ b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/H1ProtocolConfigBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright © 2019 Apple Inc. and the ServiceTalk project authors + * Copyright © 2019-2020 Apple Inc. and the ServiceTalk project authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,12 +29,15 @@ */ public final class H1ProtocolConfigBuilder { + private static final H1SpecExceptions DEFAULT_H1_SPEC_EXCEPTIONS = new H1SpecExceptions.Builder().build(); + private int maxPipelinedRequests = 1; private int maxStartLineLength = 4096; private int maxHeaderFieldLength = 8192; private HttpHeadersFactory headersFactory = DefaultHttpHeadersFactory.INSTANCE; private int headersEncodedSizeEstimate = 256; private int trailersEncodedSizeEstimate = 256; + private H1SpecExceptions specExceptions = DEFAULT_H1_SPEC_EXCEPTIONS; H1ProtocolConfigBuilder() { } @@ -130,6 +133,17 @@ public H1ProtocolConfigBuilder trailersEncodedSizeEstimate(final int trailersEnc return this; } + /** + * Sets additional exceptions for HTTP/1.1 specification. + * + * @param specExceptions exceptions for HTTP/1.1 specification + * @return {@code this} + */ + public H1ProtocolConfigBuilder specExceptions(final H1SpecExceptions specExceptions) { + this.specExceptions = requireNonNull(specExceptions); + return this; + } + /** * Builds {@link H1ProtocolConfig}. * @@ -137,7 +151,7 @@ public H1ProtocolConfigBuilder trailersEncodedSizeEstimate(final int trailersEnc */ public H1ProtocolConfig build() { return new DefaultH1ProtocolConfig(headersFactory, maxPipelinedRequests, maxStartLineLength, - maxHeaderFieldLength, headersEncodedSizeEstimate, trailersEncodedSizeEstimate); + maxHeaderFieldLength, headersEncodedSizeEstimate, trailersEncodedSizeEstimate, specExceptions); } private static final class DefaultH1ProtocolConfig implements H1ProtocolConfig { @@ -148,16 +162,19 @@ private static final class DefaultH1ProtocolConfig implements H1ProtocolConfig { private final int maxHeaderFieldLength; private final int headersEncodedSizeEstimate; private final int trailersEncodedSizeEstimate; + private final H1SpecExceptions specExceptions; DefaultH1ProtocolConfig(final HttpHeadersFactory headersFactory, final int maxPipelinedRequests, final int maxStartLineLength, final int maxHeaderFieldLength, - final int headersEncodedSizeEstimate, final int trailersEncodedSizeEstimate) { + final int headersEncodedSizeEstimate, final int trailersEncodedSizeEstimate, + final H1SpecExceptions specExceptions) { this.headersFactory = headersFactory; this.maxPipelinedRequests = maxPipelinedRequests; this.maxStartLineLength = maxStartLineLength; this.maxHeaderFieldLength = maxHeaderFieldLength; this.headersEncodedSizeEstimate = headersEncodedSizeEstimate; this.trailersEncodedSizeEstimate = trailersEncodedSizeEstimate; + this.specExceptions = specExceptions; } @Override @@ -189,5 +206,10 @@ public int headersEncodedSizeEstimate() { public int trailersEncodedSizeEstimate() { return trailersEncodedSizeEstimate; } + + @Override + public H1SpecExceptions specExceptions() { + return specExceptions; + } } } diff --git a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/H1SpecExceptions.java b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/H1SpecExceptions.java new file mode 100644 index 0000000000..b08762b965 --- /dev/null +++ b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/H1SpecExceptions.java @@ -0,0 +1,67 @@ +/* + * Copyright © 2020 Apple Inc. and the ServiceTalk project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.servicetalk.http.netty; + +/** + * Additional exceptions for HTTP/1.1 specification. + */ +public final class H1SpecExceptions { + + private final boolean allowPrematureClosureBeforePayloadBody; + + private H1SpecExceptions(final boolean allowPrematureClosureBeforePayloadBody) { + this.allowPrematureClosureBeforePayloadBody = allowPrematureClosureBeforePayloadBody; + } + + /** + * Allows interpreting connection closures as the end of HTTP/1.1 messages if the receiver did not receive any part + * of the payload body before the connection closure. + * + * @return {@code true} if the receiver should interpret connection closures as the end of HTTP/1.1 messages if it + * did not receive any part of the payload body before the connection closure + */ + public boolean allowPrematureClosureBeforePayloadBody() { + return allowPrematureClosureBeforePayloadBody; + } + + /** + * Builder for {@link H1SpecExceptions}. + */ + public static final class Builder { + + private boolean allowPrematureClosureBeforePayloadBody; + + /** + * Allows interpreting connection closures as the end of HTTP/1.1 messages if the receiver did not receive any + * part of the payload body before the connection closure. + * + * @return {@code this} + */ + public Builder allowPrematureClosureBeforePayloadBody() { + this.allowPrematureClosureBeforePayloadBody = true; + return this; + } + + /** + * Builds {@link H1SpecExceptions}. + * + * @return a new {@link H1SpecExceptions} + */ + public H1SpecExceptions build() { + return new H1SpecExceptions(allowPrematureClosureBeforePayloadBody); + } + } +} diff --git a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpClientChannelInitializer.java b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpClientChannelInitializer.java index 7d0e402e7d..0cdfe9c41d 100644 --- a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpClientChannelInitializer.java +++ b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpClientChannelInitializer.java @@ -46,7 +46,8 @@ final class HttpClientChannelInitializer implements ChannelInitializer { final Queue methodQueue = new ArrayDeque<>(min(8, config.maxPipelinedRequests())); final ChannelPipeline pipeline = channel.pipeline(); pipeline.addLast(new HttpResponseDecoder(methodQueue, alloc, config.headersFactory(), - config.maxStartLineLength(), config.maxHeaderFieldLength(), closeHandler)); + config.maxStartLineLength(), config.maxHeaderFieldLength(), + config.specExceptions().allowPrematureClosureBeforePayloadBody(), closeHandler)); pipeline.addLast(new HttpRequestEncoder(methodQueue, config.headersEncodedSizeEstimate(), config.trailersEncodedSizeEstimate(), closeHandler)); }); diff --git a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpObjectDecoder.java b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpObjectDecoder.java index a22b0b14f0..9e9eb6c4c4 100644 --- a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpObjectDecoder.java +++ b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpObjectDecoder.java @@ -126,6 +126,7 @@ abstract class HttpObjectDecoder extends ByteToMessageDe private final HttpHeadersFactory headersFactory; private final CloseHandler closeHandler; + private final boolean allowPrematureClosureBeforePayloadBody; @Nullable private T message; @Nullable @@ -158,7 +159,7 @@ private enum State { */ protected HttpObjectDecoder(final ByteBufAllocator alloc, final HttpHeadersFactory headersFactory, final int maxStartLineLength, final int maxHeaderFieldLength, - final CloseHandler closeHandler) { + final boolean allowPrematureClosureBeforePayloadBody, final CloseHandler closeHandler) { super(alloc); this.closeHandler = closeHandler; if (maxStartLineLength <= 0) { @@ -170,6 +171,7 @@ protected HttpObjectDecoder(final ByteBufAllocator alloc, final HttpHeadersFacto this.headersFactory = requireNonNull(headersFactory); this.maxStartLineLength = maxStartLineLength; this.maxHeaderFieldLength = maxHeaderFieldLength; + this.allowPrematureClosureBeforePayloadBody = allowPrematureClosureBeforePayloadBody; } final HttpHeadersFactory headersFactory() { @@ -448,7 +450,9 @@ protected final void decodeLast(final ChannelHandlerContext ctx, final ByteBuf i // Handle the last unfinished message. if (message != null) { boolean chunked = isTransferEncodingChunked(message.headers()); - if (currentState == State.READ_VARIABLE_LENGTH_CONTENT && !in.isReadable() && !chunked) { + if (!in.isReadable() && ( + (currentState == State.READ_VARIABLE_LENGTH_CONTENT && !chunked) || + (currentState == State.READ_CHUNK_SIZE && chunked && allowPrematureClosureBeforePayloadBody))) { // End of connection. ctx.fireChannelRead(EmptyHttpHeaders.INSTANCE); closeHandler.protocolPayloadEndInbound(ctx); diff --git a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpRequestDecoder.java b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpRequestDecoder.java index 2520ede97e..cca94df303 100644 --- a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpRequestDecoder.java +++ b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpRequestDecoder.java @@ -52,13 +52,15 @@ final class HttpRequestDecoder extends HttpObjectDecoder { final HttpHeadersFactory headersFactory, final int maxStartLineLength, final int maxHeaderFieldLength) { this(methodQueue, alloc, headersFactory, maxStartLineLength, maxHeaderFieldLength, - UNSUPPORTED_PROTOCOL_CLOSE_HANDLER); + false, UNSUPPORTED_PROTOCOL_CLOSE_HANDLER); } HttpRequestDecoder(final Queue methodQueue, final ByteBufAllocator alloc, final HttpHeadersFactory headersFactory, final int maxStartLineLength, - final int maxHeaderFieldLength, final CloseHandler closeHandler) { - super(alloc, headersFactory, maxStartLineLength, maxHeaderFieldLength, closeHandler); + final int maxHeaderFieldLength, final boolean allowPrematureClosureBeforePayloadBody, + final CloseHandler closeHandler) { + super(alloc, headersFactory, maxStartLineLength, maxHeaderFieldLength, allowPrematureClosureBeforePayloadBody, + closeHandler); this.methodQueue = requireNonNull(methodQueue); } diff --git a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpResponseDecoder.java b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpResponseDecoder.java index 08aa7bc9e4..293661e8ae 100644 --- a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpResponseDecoder.java +++ b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/HttpResponseDecoder.java @@ -61,13 +61,14 @@ final class HttpResponseDecoder extends HttpObjectDecoder HttpResponseDecoder(final Queue methodQueue, final ByteBufAllocator alloc, final HttpHeadersFactory headersFactory, int maxStartLineLength, int maxHeaderFieldLength) { this(methodQueue, alloc, headersFactory, maxStartLineLength, maxHeaderFieldLength, - UNSUPPORTED_PROTOCOL_CLOSE_HANDLER); + false, UNSUPPORTED_PROTOCOL_CLOSE_HANDLER); } HttpResponseDecoder(final Queue methodQueue, final ByteBufAllocator alloc, final HttpHeadersFactory headersFactory, final int maxStartLineLength, int maxHeaderFieldLength, - final CloseHandler closeHandler) { - super(alloc, headersFactory, maxStartLineLength, maxHeaderFieldLength, closeHandler); + final boolean allowPrematureClosureBeforePayloadBody, final CloseHandler closeHandler) { + super(alloc, headersFactory, maxStartLineLength, maxHeaderFieldLength, allowPrematureClosureBeforePayloadBody, + closeHandler); this.methodQueue = requireNonNull(methodQueue); } diff --git a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/NettyHttpServer.java b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/NettyHttpServer.java index 0db7e7ccd3..2d24747f3f 100644 --- a/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/NettyHttpServer.java +++ b/servicetalk-http-netty/src/main/java/io/servicetalk/http/netty/NettyHttpServer.java @@ -169,7 +169,8 @@ private static ChannelInitializer getChannelInitializer(final ByteBufAllocator a Queue methodQueue = new ArrayDeque<>(2); final ChannelPipeline pipeline = channel.pipeline(); pipeline.addLast(new HttpRequestDecoder(methodQueue, alloc, config.headersFactory(), - config.maxStartLineLength(), config.maxHeaderFieldLength(), closeHandler)); + config.maxStartLineLength(), config.maxHeaderFieldLength(), + config.specExceptions().allowPrematureClosureBeforePayloadBody(), closeHandler)); pipeline.addLast(new HttpResponseEncoder(methodQueue, config.headersEncodedSizeEstimate(), config.trailersEncodedSizeEstimate(), closeHandler)); }); diff --git a/servicetalk-http-netty/src/test/java/io/servicetalk/http/netty/PrematureClosureBeforeResponsePayloadBodyTest.java b/servicetalk-http-netty/src/test/java/io/servicetalk/http/netty/PrematureClosureBeforeResponsePayloadBodyTest.java new file mode 100644 index 0000000000..b69e8d8183 --- /dev/null +++ b/servicetalk-http-netty/src/test/java/io/servicetalk/http/netty/PrematureClosureBeforeResponsePayloadBodyTest.java @@ -0,0 +1,379 @@ +/* + * Copyright © 2020 Apple Inc. and the ServiceTalk project authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.servicetalk.http.netty; + +import io.servicetalk.concurrent.internal.ServiceTalkTestTimeout; +import io.servicetalk.http.api.BlockingHttpClient; +import io.servicetalk.http.api.HttpRequest; +import io.servicetalk.http.api.HttpResponse; +import io.servicetalk.http.api.ReservedBlockingHttpConnection; +import io.servicetalk.transport.api.HostAndPort; +import io.servicetalk.transport.netty.internal.EventLoopAwareNettyIoExecutor; + +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.ByteBufUtil; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.EventLoop; +import io.netty.channel.socket.ServerSocketChannel; +import io.netty.handler.codec.PrematureChannelClosureException; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpRequestDecoder; +import io.netty.util.ReferenceCountUtil; +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; + +import java.net.InetSocketAddress; +import java.nio.channels.ClosedChannelException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +import static io.netty.channel.ChannelOption.ALLOW_HALF_CLOSURE; +import static io.netty.channel.ChannelOption.AUTO_CLOSE; +import static io.netty.channel.ChannelOption.AUTO_READ; +import static io.servicetalk.http.api.HttpHeaderNames.CONNECTION; +import static io.servicetalk.http.api.HttpHeaderNames.CONTENT_LENGTH; +import static io.servicetalk.http.api.HttpHeaderNames.TRANSFER_ENCODING; +import static io.servicetalk.http.api.HttpHeaderValues.CHUNKED; +import static io.servicetalk.http.api.HttpHeaderValues.CLOSE; +import static io.servicetalk.http.api.HttpResponseStatus.OK; +import static io.servicetalk.http.api.Matchers.contentEqualTo; +import static io.servicetalk.http.netty.HttpProtocolConfigs.h1; +import static io.servicetalk.transport.netty.internal.AddressUtils.localAddress; +import static io.servicetalk.transport.netty.internal.BuilderUtils.serverChannel; +import static io.servicetalk.transport.netty.internal.EventLoopAwareNettyIoExecutors.toEventLoopAwareNettyIoExecutor; +import static io.servicetalk.transport.netty.internal.GlobalExecutionContext.globalExecutionContext; +import static java.lang.Integer.MAX_VALUE; +import static java.nio.charset.StandardCharsets.US_ASCII; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThrows; + +public class PrematureClosureBeforeResponsePayloadBodyTest { + + @Rule + public final Timeout timeout = new ServiceTalkTestTimeout(); + + private final ServerSocketChannel server; + private final BlockingHttpClient client; + private final AtomicReference encodedResponse = new AtomicReference<>(); + private final CountDownLatch connectionClosedLatch = new CountDownLatch(1); + + public PrematureClosureBeforeResponsePayloadBodyTest() { + EventLoopAwareNettyIoExecutor eventLoopAwareNettyIoExecutor = + toEventLoopAwareNettyIoExecutor(globalExecutionContext().ioExecutor()); + EventLoop loop = eventLoopAwareNettyIoExecutor.eventLoopGroup().next(); + + ServerBootstrap bs = new ServerBootstrap(); + bs.group(loop); + bs.channel(serverChannel(loop, InetSocketAddress.class)); + bs.childHandler(new ChannelInitializer() { + @Override + protected void initChannel(Channel ch) { + ch.pipeline().addLast(new HttpRequestDecoder()); + ch.pipeline().addLast(new HttpObjectAggregator(MAX_VALUE)); + ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { + @Override + public void channelRead(final ChannelHandlerContext ctx, final Object msg) { + if (msg instanceof FullHttpRequest) { + ctx.writeAndFlush(ByteBufUtil.writeAscii(ctx.alloc(), encodedResponse.get())) + .addListener(ChannelFutureListener.CLOSE); + } + ReferenceCountUtil.release(msg); + } + }); + } + }); + bs.childOption(AUTO_READ, true); + bs.childOption(ALLOW_HALF_CLOSURE, true); + bs.childOption(AUTO_CLOSE, false); + server = (ServerSocketChannel) bs.bind(localAddress(0)) + .syncUninterruptibly().channel(); + + client = HttpClients.forSingleAddress(HostAndPort.of(server.localAddress())) + .protocols(h1() + .specExceptions(new H1SpecExceptions.Builder().allowPrematureClosureBeforePayloadBody().build()) + .build()) + .buildBlocking(); + } + + @After + public void turnDown() throws Exception { + try { + client.closeGracefully(); + } finally { + server.close().syncUninterruptibly(); + } + } + + @Test + public void notAllHeadersReceived() throws Exception { + encodedResponse.set("HTTP/1.1 200 OK\r\n" + + "Transfer-Encoding: chunked\r\n" + + "Connection: close\r\n"); // no final CRLF after headers + + HttpRequest request = client.get("/"); + ReservedBlockingHttpConnection connection = client.reserveConnection(request); + // Wait until a server closes the connection: + connection.connectionContext().onClose().whenFinally(connectionClosedLatch::countDown).subscribe(); + + assertThrows(PrematureChannelClosureException.class, () -> connection.request(request)); + connectionClosedLatch.await(); + } + + @Test + public void noPayloadNoMessageLengthHeader() throws Exception { + encodedResponse.set("HTTP/1.1 200 OK\r\n" + + "Connection: close\r\n" + "\r\n"); + + HttpRequest request = client.get("/"); + ReservedBlockingHttpConnection connection = client.reserveConnection(request); + // Wait until a server closes the connection: + connection.connectionContext().onClose().whenFinally(connectionClosedLatch::countDown).subscribe(); + + HttpResponse response = connection.request(request); + assertThat(response.status(), is(OK)); + assertThat(response.headers().get(CONNECTION), contentEqualTo(CLOSE)); + assertThat(response.payloadBody().readableBytes(), is(0)); + connectionClosedLatch.await(); + } + + @Test + public void payloadWithoutMessageLengthHeader() throws Exception { + encodedResponse.set("HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "Connection: close\r\n" + "\r\n" + + "hello"); + + HttpRequest request = client.get("/"); + ReservedBlockingHttpConnection connection = client.reserveConnection(request); + // Wait until a server closes the connection: + connection.connectionContext().onClose().whenFinally(connectionClosedLatch::countDown).subscribe(); + + HttpResponse response = connection.request(request); + assertThat(response.status(), is(OK)); + assertThat(response.headers().get(CONNECTION), contentEqualTo(CLOSE)); + assertThat(response.payloadBody().toString(US_ASCII), equalTo("hello")); + connectionClosedLatch.await(); + } + + @Test + public void noPayloadWithContentLength() throws Exception { + encodedResponse.set("HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n" + "\r\n"); + + HttpRequest request = client.get("/"); + ReservedBlockingHttpConnection connection = client.reserveConnection(request); + // Wait until a server closes the connection: + connection.connectionContext().onClose().whenFinally(connectionClosedLatch::countDown).subscribe(); + + HttpResponse response = connection.request(request); + assertThat(response.status(), is(OK)); + assertThat(response.headers().get(CONTENT_LENGTH), contentEqualTo("0")); + assertThat(response.headers().get(CONNECTION), contentEqualTo(CLOSE)); + assertThat(response.payloadBody().readableBytes(), is(0)); + connectionClosedLatch.await(); + } + + @Test + public void payloadWithContentLength() throws Exception { + encodedResponse.set("HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Length: 5\r\n" + + "Connection: close\r\n" + "\r\n" + + "hello"); + + HttpRequest request = client.get("/"); + ReservedBlockingHttpConnection connection = client.reserveConnection(request); + // Wait until a server closes the connection: + connection.connectionContext().onClose().whenFinally(connectionClosedLatch::countDown).subscribe(); + + HttpResponse response = connection.request(request); + assertThat(response.status(), is(OK)); + assertThat(response.headers().get(CONTENT_LENGTH), contentEqualTo("5")); + assertThat(response.headers().get(CONNECTION), contentEqualTo(CLOSE)); + assertThat(response.payloadBody().toString(US_ASCII), equalTo("hello")); + connectionClosedLatch.await(); + } + + @Test + public void truncatedPayloadWithContentLength() throws Exception { + encodedResponse.set("HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Length: 5\r\n" + + "Connection: close\r\n" + "\r\n" + + "he"); // not the whole payload body + + HttpRequest request = client.get("/"); + ReservedBlockingHttpConnection connection = client.reserveConnection(request); + // Wait until a server closes the connection: + connection.connectionContext().onClose().whenFinally(connectionClosedLatch::countDown).subscribe(); + + assertThrows(ClosedChannelException.class, () -> connection.request(request)); + connectionClosedLatch.await(); + } + + /** + * Some old servers may close the connection right after sending meta-data if the payload body is empty. + */ + @Test + public void chunkedWithoutBody() throws Exception { + encodedResponse.set("HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "Transfer-Encoding: chunked\r\n" + + "Connection: close\r\n" + "\r\n"); + + HttpRequest request = client.get("/"); + ReservedBlockingHttpConnection connection = client.reserveConnection(request); + // Wait until a server closes the connection: + connection.connectionContext().onClose().whenFinally(connectionClosedLatch::countDown).subscribe(); + + HttpResponse response = connection.request(request); + assertThat(response.status(), is(OK)); + assertThat(response.headers().get(CONNECTION), contentEqualTo(CLOSE)); + assertThat(response.headers().get(TRANSFER_ENCODING), contentEqualTo(CHUNKED)); + assertThat(response.payloadBody().readableBytes(), is(0)); + connectionClosedLatch.await(); + } + + @Test + public void chunkedWithEmptyPayload() throws Exception { + encodedResponse.set("HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "Transfer-Encoding: chunked\r\n" + + "Connection: close\r\n" + "\r\n" + + "0\r\n" + "\r\n"); + + HttpRequest request = client.get("/"); + ReservedBlockingHttpConnection connection = client.reserveConnection(request); + // Wait until a server closes the connection: + connection.connectionContext().onClose().whenFinally(connectionClosedLatch::countDown).subscribe(); + + HttpResponse response = connection.request(request); + assertThat(response.status(), is(OK)); + assertThat(response.headers().get(CONNECTION), contentEqualTo(CLOSE)); + assertThat(response.headers().get(TRANSFER_ENCODING), contentEqualTo(CHUNKED)); + assertThat(response.payloadBody().readableBytes(), is(0)); + connectionClosedLatch.await(); + } + + @Test + public void chunkedWithPayload() throws Exception { + encodedResponse.set("HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "Transfer-Encoding: chunked\r\n" + + "Connection: close\r\n" + "\r\n" + + "5\r\n" + + "hello\r\n" + + "0\r\n" + "\r\n"); + + HttpRequest request = client.get("/"); + ReservedBlockingHttpConnection connection = client.reserveConnection(request); + // Wait until a server closes the connection: + connection.connectionContext().onClose().whenFinally(connectionClosedLatch::countDown).subscribe(); + + HttpResponse response = connection.request(request); + assertThat(response.status(), is(OK)); + assertThat(response.headers().get(CONNECTION), contentEqualTo(CLOSE)); + assertThat(response.headers().get(TRANSFER_ENCODING), contentEqualTo(CHUNKED)); + assertThat(response.payloadBody().toString(US_ASCII), equalTo("hello")); + connectionClosedLatch.await(); + } + + @Test + public void chunkedWithSomeBytesAfterHeaders() throws Exception { + encodedResponse.set("HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "Transfer-Encoding: chunked\r\n" + + "Connection: close\r\n" + "\r\n" + + "5"); // can be a chunk-size, but impossible to interpret it correctly + + HttpRequest request = client.get("/"); + ReservedBlockingHttpConnection connection = client.reserveConnection(request); + // Wait until a server closes the connection: + connection.connectionContext().onClose().whenFinally(connectionClosedLatch::countDown).subscribe(); + + assertThrows(ClosedChannelException.class, () -> connection.request(request)); + connectionClosedLatch.await(); + } + + @Test + public void chunkedWithoutChunkData() throws Exception { + encodedResponse.set("HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "Transfer-Encoding: chunked\r\n" + + "Connection: close\r\n" + "\r\n" + + "5\r\n" + + // no chunk data of size 5 (e.g. "hello\r\n") and no last-chunk: 0\r\n + "\r\n"); + + HttpRequest request = client.get("/"); + ReservedBlockingHttpConnection connection = client.reserveConnection(request); + // Wait until a server closes the connection: + connection.connectionContext().onClose().whenFinally(connectionClosedLatch::countDown).subscribe(); + + assertThrows(ClosedChannelException.class, () -> connection.request(request)); + connectionClosedLatch.await(); + } + + @Test + public void chunkedWithoutLastChunk() throws Exception { + encodedResponse.set("HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "Transfer-Encoding: chunked\r\n" + + "Connection: close\r\n" + "\r\n" + + "5\r\n" + + "hello\r\n" + + // no last-chunk: 0\r\n + "\r\n"); + + HttpRequest request = client.get("/"); + ReservedBlockingHttpConnection connection = client.reserveConnection(request); + // Wait until a server closes the connection: + connection.connectionContext().onClose().whenFinally(connectionClosedLatch::countDown).subscribe(); + + assertThrows(ClosedChannelException.class, () -> connection.request(request)); + connectionClosedLatch.await(); + } + + @Test + public void chunkedWithoutFinalCRLF() throws Exception { + encodedResponse.set("HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "Transfer-Encoding: chunked\r\n" + + "Connection: close\r\n" + "\r\n" + + "5\r\n" + + "hello\r\n" + + "0\r\n"); // no final CRLF + + HttpRequest request = client.get("/"); + ReservedBlockingHttpConnection connection = client.reserveConnection(request); + // Wait until a server closes the connection: + connection.connectionContext().onClose().whenFinally(connectionClosedLatch::countDown).subscribe(); + + assertThrows(ClosedChannelException.class, () -> connection.request(request)); + connectionClosedLatch.await(); + } +}