Skip to content

Commit a374830

Browse files
authored
Stabilize HTTP/2: fix resource leaks and RFC conformance (#2197)
Motivation: The HTTP/2 path is newer and less hardened than HTTP/1.1. Bring its resource lifecycle, RFC 9113/9110 conformance, and connection/stream management to parity, covering flow control, multiplexing, and GOAWAY/RST/SETTINGS edge cases. Modification: Bind stream-slot and request-body release to the channel lifecycle, stream request bodies under flow-control backpressure, enforce RFC 9113/9110 conformance (RST_STREAM codes, 1xx interim, Expect 100-continue, TE, :authority, MAX_CONCURRENT_STREAMS=min), gate WebSocket off HTTP/2, drain pendingOpeners on GOAWAY/SETTINGS, and restore Http2ConnectionState binary compatibility. HTTP/1.1 behaviour and public API are unchanged. Result: Stablize HTTP/2 even futher for edge cases Fixes #2160
1 parent e6955c1 commit a374830

33 files changed

Lines changed: 3675 additions & 245 deletions

client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,16 @@ default int getHttp2MaxConcurrentStreams() {
321321
return -1;
322322
}
323323

324+
/**
325+
* @return the maximum number of bytes a single HTTP/2 response body may decompress to before the stream
326+
* is failed. Guards against decompression-bomb responses — a tiny compressed body that inflates
327+
* to gigabytes and can OOM the client (the limit is enforced transparently when automatic
328+
* decompression is enabled). {@code 0} disables the limit. Defaults to 256 MiB.
329+
*/
330+
default long getHttp2MaxDecompressedResponseSize() {
331+
return 256L * 1024 * 1024;
332+
}
333+
324334
/**
325335
* @return the interval between HTTP/2 PING keepalive frames, {@link Duration#ZERO} disables pinging
326336
*/

client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultHttp2HeaderTableSize;
108108
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultHttp2InitialWindowSize;
109109
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultHttp2MaxConcurrentStreams;
110+
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultHttp2MaxDecompressedResponseSize;
110111
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultHttp2MaxFrameSize;
111112
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultHttp2MaxHeaderListSize;
112113
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultHttp2PingInterval;
@@ -181,6 +182,7 @@ public class DefaultAsyncHttpClientConfig implements AsyncHttpClientConfig {
181182
private final int http2HeaderTableSize;
182183
private final int http2MaxHeaderListSize;
183184
private final int http2MaxConcurrentStreams;
185+
private final long http2MaxDecompressedResponseSize;
184186
private final Duration http2PingInterval;
185187
private final boolean http2CleartextEnabled;
186188

@@ -277,6 +279,7 @@ private DefaultAsyncHttpClientConfig(// http
277279
int http2HeaderTableSize,
278280
int http2MaxHeaderListSize,
279281
int http2MaxConcurrentStreams,
282+
long http2MaxDecompressedResponseSize,
280283
Duration http2PingInterval,
281284
boolean http2CleartextEnabled,
282285

@@ -381,6 +384,7 @@ private DefaultAsyncHttpClientConfig(// http
381384
this.http2HeaderTableSize = http2HeaderTableSize;
382385
this.http2MaxHeaderListSize = http2MaxHeaderListSize;
383386
this.http2MaxConcurrentStreams = http2MaxConcurrentStreams;
387+
this.http2MaxDecompressedResponseSize = http2MaxDecompressedResponseSize;
384388
this.http2PingInterval = http2PingInterval;
385389
this.http2CleartextEnabled = http2CleartextEnabled;
386390

@@ -682,6 +686,11 @@ public int getHttp2MaxConcurrentStreams() {
682686
return http2MaxConcurrentStreams;
683687
}
684688

689+
@Override
690+
public long getHttp2MaxDecompressedResponseSize() {
691+
return http2MaxDecompressedResponseSize;
692+
}
693+
685694
@Override
686695
public Duration getHttp2PingInterval() {
687696
return http2PingInterval;
@@ -942,6 +951,7 @@ public static class Builder {
942951
private int http2HeaderTableSize = defaultHttp2HeaderTableSize();
943952
private int http2MaxHeaderListSize = defaultHttp2MaxHeaderListSize();
944953
private int http2MaxConcurrentStreams = defaultHttp2MaxConcurrentStreams();
954+
private long http2MaxDecompressedResponseSize = defaultHttp2MaxDecompressedResponseSize();
945955
private Duration http2PingInterval = defaultHttp2PingInterval();
946956
private boolean http2CleartextEnabled = defaultHttp2CleartextEnabled();
947957

@@ -1043,6 +1053,7 @@ public Builder(AsyncHttpClientConfig config) {
10431053
http2HeaderTableSize = config.getHttp2HeaderTableSize();
10441054
http2MaxHeaderListSize = config.getHttp2MaxHeaderListSize();
10451055
http2MaxConcurrentStreams = config.getHttp2MaxConcurrentStreams();
1056+
http2MaxDecompressedResponseSize = config.getHttp2MaxDecompressedResponseSize();
10461057
http2PingInterval = config.getHttp2PingInterval();
10471058
http2CleartextEnabled = config.isHttp2CleartextEnabled();
10481059

@@ -1391,6 +1402,11 @@ public Builder setHttp2MaxConcurrentStreams(int http2MaxConcurrentStreams) {
13911402
return this;
13921403
}
13931404

1405+
public Builder setHttp2MaxDecompressedResponseSize(long http2MaxDecompressedResponseSize) {
1406+
this.http2MaxDecompressedResponseSize = http2MaxDecompressedResponseSize;
1407+
return this;
1408+
}
1409+
13941410
public Builder setHttp2PingInterval(Duration http2PingInterval) {
13951411
this.http2PingInterval = http2PingInterval;
13961412
return this;
@@ -1658,6 +1674,7 @@ public DefaultAsyncHttpClientConfig build() {
16581674
http2HeaderTableSize,
16591675
http2MaxHeaderListSize,
16601676
http2MaxConcurrentStreams,
1677+
http2MaxDecompressedResponseSize,
16611678
http2PingInterval,
16621679
http2CleartextEnabled,
16631680
requestFilters.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(requestFilters),

client/src/main/java/org/asynchttpclient/RequestBuilderBase.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,18 @@ public T setBody(ByteBuffer data) {
478478
return asDerivedType();
479479
}
480480

481+
/**
482+
* Sets the request body from a Netty {@link ByteBuf}.
483+
* <p>
484+
* <strong>Ownership:</strong> the caller retains ownership of {@code data}. AsyncHttpClient sends a
485+
* retained duplicate per attempt (so redirects, auth replays and retries each get their own reference and
486+
* the body survives across them) and never releases {@code data} itself. The caller is responsible for
487+
* releasing {@code data} once the request has completed. (This differs from older releases, which consumed
488+
* and released the buffer on the first send — and double-freed it on any retry.)
489+
*
490+
* @param data the request body; the caller keeps ownership and must release it after the request completes
491+
* @return this builder
492+
*/
481493
public T setBody(ByteBuf data) {
482494
resetBody();
483495
byteBufData = data;

client/src/main/java/org/asynchttpclient/SslEngineFactory.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,26 @@ public interface SslEngineFactory {
3131
*/
3232
SSLEngine newSslEngine(AsyncHttpClientConfig config, String peerHost, int peerPort);
3333

34+
/**
35+
* Creates a new {@link SSLEngine}, optionally permitting HTTP/2 (ALPN {@code h2}) negotiation.
36+
* <p>
37+
* WebSocket connections pass {@code http2Allowed = false}: AsyncHttpClient does not implement RFC 8441
38+
* (WebSocket over HTTP/2), so a WebSocket connection must not negotiate {@code h2} — otherwise the
39+
* handshake is written as a plain HTTP/2 request and corrupts the connection. The default implementation
40+
* ignores the flag and delegates to {@link #newSslEngine(AsyncHttpClientConfig, String, int)} for
41+
* backwards compatibility; {@code DefaultSslEngineFactory} overrides it to advertise only {@code http/1.1}
42+
* in ALPN when {@code http2Allowed} is {@code false}.
43+
*
44+
* @param config the client config
45+
* @param peerHost the peer hostname
46+
* @param peerPort the peer port
47+
* @param http2Allowed whether HTTP/2 (ALPN {@code h2}) may be negotiated on this connection
48+
* @return new engine
49+
*/
50+
default SSLEngine newSslEngine(AsyncHttpClientConfig config, String peerHost, int peerPort, boolean http2Allowed) {
51+
return newSslEngine(config, peerHost, peerPort);
52+
}
53+
3454
/**
3555
* Perform any necessary one-time configuration. This will be called just once before {@code newSslEngine} is called
3656
* for the first time.

client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ public final class AsyncHttpClientConfigDefaults {
8888
public static final String HTTP2_HEADER_TABLE_SIZE_CONFIG = "http2HeaderTableSize";
8989
public static final String HTTP2_MAX_HEADER_LIST_SIZE_CONFIG = "http2MaxHeaderListSize";
9090
public static final String HTTP2_MAX_CONCURRENT_STREAMS_CONFIG = "http2MaxConcurrentStreams";
91+
public static final String HTTP2_MAX_DECOMPRESSED_RESPONSE_SIZE_CONFIG = "http2MaxDecompressedResponseSize";
9192
public static final String HTTP2_PING_INTERVAL_CONFIG = "http2PingInterval";
9293
public static final String HTTP2_CLEARTEXT_ENABLED_CONFIG = "http2CleartextEnabled";
9394

@@ -360,6 +361,11 @@ public static int defaultHttp2MaxConcurrentStreams() {
360361
return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getInt(ASYNC_CLIENT_CONFIG_ROOT + HTTP2_MAX_CONCURRENT_STREAMS_CONFIG);
361362
}
362363

364+
public static long defaultHttp2MaxDecompressedResponseSize() {
365+
// getInt suffices for the 256 MiB default; values above Integer.MAX_VALUE are set via the builder.
366+
return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getInt(ASYNC_CLIENT_CONFIG_ROOT + HTTP2_MAX_DECOMPRESSED_RESPONSE_SIZE_CONFIG);
367+
}
368+
363369
public static Duration defaultHttp2PingInterval() {
364370
return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getDuration(ASYNC_CLIENT_CONFIG_ROOT + HTTP2_PING_INTERVAL_CONFIG);
365371
}

client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ public boolean cancel(boolean force) {
189189
return false;
190190
}
191191

192+
releaseRequestIfNotHandedToChannel();
193+
192194
final Channel ch = channel; //atomic read, so that it won't end up in TOCTOU
193195
if (ch != null) {
194196
Channels.setDiscard(ch);
@@ -256,7 +258,29 @@ private boolean terminateAndExit() {
256258
cancelTimeouts();
257259
channel = null;
258260
reuseChannel = false;
259-
return IS_DONE_FIELD.getAndSet(this, 1) != 0 || isCancelled != 0;
261+
boolean alreadyTerminated = IS_DONE_FIELD.getAndSet(this, 1) != 0 || isCancelled != 0;
262+
if (!alreadyTerminated) {
263+
releaseRequestIfNotHandedToChannel();
264+
}
265+
return alreadyTerminated;
266+
}
267+
268+
/**
269+
* Frees the request body buffer when the request was never handed to a channel encoder. On the HTTP/1.1
270+
* success path Netty's encoder releases {@code httpRequest} after the write; but on an abort/cancel BEFORE
271+
* the write (connect failure, onRequestSend crash, pool closed, cancellation) — or on replacement during a
272+
* redirect/retry — nothing else would, leaking a {@code setBody(ByteBuf)} retained duplicate. In the
273+
* HTTP/2 path {@code httpRequest} is never written to a channel, so AHC always owns its release.
274+
* {@link NettyRequest#release()} is idempotent (CAS), so this never double-frees the encoder or the
275+
* explicit HTTP/2 releases.
276+
*/
277+
private void releaseRequestIfNotHandedToChannel() {
278+
NettyRequest request = nettyRequest;
279+
if (request != null) {
280+
// release() atomically no-ops if the request was already handed to the channel encoder (which then
281+
// owns the release), so there is no check-then-act race with the concurrent event-loop write.
282+
request.release();
283+
}
260284
}
261285

262286
@Override
@@ -353,6 +377,13 @@ public NettyRequest getNettyRequest() {
353377
}
354378

355379
public void setNettyRequest(NettyRequest nettyRequest) {
380+
// On a redirect/auth/retry the request is rebuilt; release the previous one if it was never written,
381+
// so its body buffer is not leaked. The replaced request is normally already written (handed to the
382+
// channel) or already released, in which case this is a no-op (release() is idempotent).
383+
NettyRequest previous = this.nettyRequest;
384+
if (previous != null && previous != nettyRequest && !previous.isHandedToChannel()) {
385+
previous.release();
386+
}
356387
this.nettyRequest = nettyRequest;
357388
}
358389

client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -360,20 +360,44 @@ public void registerHttp2Connection(Object partitionKey, Channel channel) {
360360
if (state != null) {
361361
state.setPartitionKey(partitionKey);
362362
}
363-
http2Connections.put(partitionKey, channel);
363+
// Coalesce connections opened concurrently for the same partition (thundering herd): keep the
364+
// first live one canonical in the registry. A connection that lost the race is marked redundant,
365+
// so it serves only its own opening request and then closes (its stream's closeFuture listener
366+
// closes the parent once activeStreams hits 0) instead of lingering open and unregistered. A dead
367+
// registered entry is replaced.
368+
Channel existing = http2Connections.putIfAbsent(partitionKey, channel);
369+
if (existing != null && existing != channel) {
370+
boolean replacedDead = !existing.isActive() && http2Connections.replace(partitionKey, existing, channel);
371+
if (!replacedDead && state != null) {
372+
state.markRedundant();
373+
}
374+
}
364375
// When the connection closes, remove it from the registry AND fail any requests still queued
365376
// for a stream slot. Without the latter, requests sitting in pendingOpeners when the parent
366377
// connection drops have no stream channel (so no channelInactive is ever delivered for them)
367378
// and would survive only until the request timeout fires — the silent-timeout bug of #2160.
368379
channel.closeFuture().addListener(future -> {
369380
removeHttp2Connection(partitionKey, channel);
370381
if (state != null) {
371-
state.failPendingOpeners(orphan ->
372-
orphan.abort(new IOException("HTTP/2 connection closed before a stream could be opened")));
382+
state.failPendingOpeners(orphan -> failOrphanedH2Opener(orphan,
383+
"HTTP/2 connection closed before a stream could be opened"));
373384
}
374385
});
375386
}
376387

388+
/**
389+
* Fails a request that was queued in {@link Http2ConnectionState} waiting for a stream slot but can
390+
* never get one (the connection dropped or started draining). Releases its request body first —
391+
* {@code sendHttp2Frames} never ran for it, so nothing else will — to avoid leaking the body ByteBuf.
392+
* {@code NettyRequest.release()} is idempotent, so this is safe even if another path also releases.
393+
*/
394+
private static void failOrphanedH2Opener(NettyResponseFuture<?> orphan, String message) {
395+
if (orphan.getNettyRequest() != null) {
396+
orphan.getNettyRequest().release();
397+
}
398+
orphan.abort(new IOException(message));
399+
}
400+
377401
/**
378402
* Removes an HTTP/2 connection from the registry, but only if it's the currently registered
379403
* connection for that partition key (avoids removing a replacement connection).
@@ -466,8 +490,8 @@ private HttpClientCodec newHttpClientCodec() {
466490
config.getHttpClientCodecInitialBufferSize());
467491
}
468492

469-
private SslHandler createSslHandler(String peerHost, int peerPort) {
470-
SSLEngine sslEngine = sslEngineFactory.newSslEngine(config, peerHost, peerPort);
493+
private SslHandler createSslHandler(String peerHost, int peerPort, boolean http2Allowed) {
494+
SSLEngine sslEngine = sslEngineFactory.newSslEngine(config, peerHost, peerPort, http2Allowed);
471495
SslHandler sslHandler = new SslHandler(sslEngine);
472496
if (handshakeTimeout > 0) {
473497
sslHandler.setHandshakeTimeoutMillis(handshakeTimeout);
@@ -489,7 +513,7 @@ public Future<Channel> updatePipelineForHttpTunneling(ChannelPipeline pipeline,
489513
// Remove existing SSL handler (for proxy) and replace with SSL handler for target
490514
pipeline.remove(SSL_HANDLER);
491515
}
492-
SslHandler sslHandler = createSslHandler(requestUri.getHost(), requestUri.getExplicitPort());
516+
SslHandler sslHandler = createSslHandler(requestUri.getHost(), requestUri.getExplicitPort(), !requestUri.isWebSocket());
493517
whenHandshaked = sslHandler.handshakeFuture();
494518
pipeline.addBefore(INFLATER_HANDLER, SSL_HANDLER, sslHandler);
495519
pipeline.addAfter(SSL_HANDLER, HTTP_CLIENT_CODEC, newHttpClientCodec());
@@ -527,9 +551,9 @@ public Future<Channel> updatePipelineForHttpsTunneling(ChannelPipeline pipeline,
527551
// The proxy SSL handler should remain as it provides the tunnel transport
528552
// We need to add target SSL handler that will negotiate with the target through the tunnel
529553

530-
SslHandler sslHandler = createSslHandler(requestUri.getHost(), requestUri.getExplicitPort());
554+
SslHandler sslHandler = createSslHandler(requestUri.getHost(), requestUri.getExplicitPort(), !requestUri.isWebSocket());
531555
whenHandshaked = sslHandler.handshakeFuture();
532-
556+
533557
// For HTTPS proxy tunnel, add target SSL handler after the existing proxy SSL handler
534558
// This creates a nested SSL setup: Target SSL -> Proxy SSL -> Network
535559
if (isSslHandlerConfigured(pipeline)) {
@@ -580,7 +604,8 @@ public SslHandler addSslHandler(ChannelPipeline pipeline, Uri uri, String virtua
580604
peerPort = uri.getExplicitPort();
581605
}
582606

583-
SslHandler sslHandler = createSslHandler(peerHost, peerPort);
607+
// A WebSocket connection must not negotiate h2 (no RFC 8441 support), so advertise only http/1.1 in ALPN.
608+
SslHandler sslHandler = createSslHandler(peerHost, peerPort, !uri.isWebSocket());
584609
// Check if SOCKS handler actually exists in the pipeline before trying to add after it
585610
if (hasSocksProxyHandler && pipeline.get(SOCKS_HANDLER) != null) {
586611
pipeline.addAfter(SOCKS_HANDLER, SSL_HANDLER, sslHandler);
@@ -710,7 +735,11 @@ public void upgradePipelineToHttp2(ChannelPipeline pipeline) {
710735
.initialWindowSize(config.getHttp2InitialWindowSize())
711736
.maxFrameSize(config.getHttp2MaxFrameSize())
712737
.headerTableSize(config.getHttp2HeaderTableSize())
713-
.maxHeaderListSize(config.getHttp2MaxHeaderListSize());
738+
.maxHeaderListSize(config.getHttp2MaxHeaderListSize())
739+
// RFC 9113 §8.4: AsyncHttpClient never consumes server push, so advertise ENABLE_PUSH=0.
740+
// A conformant server then never opens push streams; without this the client relies on
741+
// Netty's default and a pushing server could trip a connection-level PROTOCOL_ERROR.
742+
.pushEnabled(false);
714743

715744
Http2FrameCodec frameCodec = Http2FrameCodecBuilder.forClient()
716745
.initialSettings(settings)
@@ -734,7 +763,9 @@ protected void initChannel(Channel ch) {
734763
Http2ConnectionState state = new Http2ConnectionState();
735764
int configMaxStreams = config.getHttp2MaxConcurrentStreams();
736765
if (configMaxStreams > 0) {
737-
state.updateMaxConcurrentStreams(configMaxStreams);
766+
// Client's own cap; the server-advertised value (applied by the http2-settings-listener below)
767+
// can only lower the effective limit, never raise it above this.
768+
state.setClientMaxConcurrentStreams(configMaxStreams);
738769
}
739770
pipeline.channel().attr(Http2ConnectionState.HTTP2_STATE_KEY).set(state);
740771

@@ -772,6 +803,13 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception
772803
if (pk != null) {
773804
removeHttp2Connection(pk, ctx.channel());
774805
}
806+
// Fail requests still queued for a stream slot: a draining connection accepts no
807+
// new streams, so they can never be opened here and would otherwise wait until the
808+
// connection finally closes. Fail them now so they retry on a fresh connection (the
809+
// registry no longer offers this one). Already-open streams below lastStreamId are
810+
// untouched and complete normally. #12
811+
connState.failPendingOpeners(orphan -> failOrphanedH2Opener(orphan,
812+
"HTTP/2 connection received GOAWAY; request must retry on a new connection"));
775813
}
776814
LOGGER.debug("HTTP/2 GOAWAY received on {}, lastStreamId={}, errorCode={}",
777815
ctx.channel(), lastStreamId, goAwayFrame.errorCode());

0 commit comments

Comments
 (0)