Skip to content

Commit c6750e2

Browse files
Mark an HTTP/2 connection instead of looking it up (#2320)
## Problem `ChannelManager.isHttp2(channel)` asks the pipeline for the multiplex handler by name: ```java return channel.pipeline().get(HTTP2_MULTIPLEX) != null; ``` The write path asks it of every request, twice: once in `sendRequestWithOpenChannel` to decide whether to store the per-request future on the channel, once in `writeRequest` to route the write. `DefaultChannelPipeline.get(String)` walks the handler chain comparing names, and an HTTP/1.1 connection - which has no such handler - is walked to the end to answer no. That is the common case for anyone who has not turned HTTP/2 on. In a profile of a client running with `setHttp2Enabled(false)`, `DefaultChannelPipeline.context0` accounted for 83 CPU samples on this alone. ## Change The multiplex handler is installed in exactly one place, `upgradePipelineToHttp2`, so a channel attribute is set beside it and `isHttp2` reads that instead: ```java public static boolean isHttp2(Channel channel) { return channel.hasAttr(HTTP2_CONNECTION_ATTRIBUTE); } ``` A binary search over integer keys in a small array, rather than a walk with a string compare per handler. `hasAttr` rather than `attr(...).get()`: the latter would add an entry to the attribute map of every HTTP/1.1 channel just to find nothing in it. Behaviour is unchanged in every configuration, including the ones a config check would get wrong - see below. ## Why not check the config instead Reading `config.isHttp2Enabled()` first would be cheaper still, and it was the first thing tried. It is not safe: the two can disagree. `NettyConnectListener` upgrades the pipeline on the ALPN result alone, not on the config: ```java boolean http2Negotiated = ApplicationProtocolNames.HTTP_2.equals(alpnProtocol); if (http2Negotiated && !uri.isWebSocket()) { channelManager.upgradePipelineToHttp2(channel.pipeline()); ``` And ALPN can select `h2` with the flag off. `DefaultSslEngineFactory` advertises `h2` only when `isHttp2Enabled()`, but it leaves a caller-supplied `SslContext` alone (`config.getSslContext() != null || !config.isHttp2Enabled()`), and a caller-supplied `SslEngineFactory` is free to advertise whatever it likes - which the WebSocket guard beside the upgrade already accounts for in as many words: *"this guard is the backstop for a custom SslEngineFactory that still advertises h2"*. `upgradePipelineToHttp2AfterProxyConnect` is gated on ALPN the same way. With `http2Enabled(false)` and such a context, a config check would route a genuine HTTP/2 connection down the HTTP/1.1 branch and write an HTTP/1.1 request onto an HTTP/2 pipeline. The attribute costs nothing more than the config read would have saved, and cannot disagree with the handler, being set where the handler is. If HTTP/2 negotiated against `http2Enabled(false)` is considered unsupported, a config short-circuit could be layered on top of this - but that is a behaviour decision rather than a micro-optimisation, so it is not made here. ## Can the attribute go stale No. Nothing removes `HTTP2_MULTIPLEX` from a pipeline - the only reference to it besides the lookup is the `addLast` in the upgrade - so there is no downgrade for the two to diverge across. Stream child channels carry neither the handler nor the attribute, so `isHttp2` answers no for them exactly as it did before. ## Tests `ChannelManagerHttp2MarkerTest` pins the attribute to the handler: either both say HTTP/2 or neither does, so a later change to the upgrade cannot set one and forget the other. Three cases - a connection never upgraded, one upgraded, and a stream channel. Checked by mutation rather than assumption: dropping the attribute assignment fails that test and 46 of the 52 in `BasicHttp2Test`, the write path having routed HTTP/2 connections down the HTTP/1.1 branch. ## Verification `mvnw clean verify` - BUILD SUCCESS, 1488 tests, 0 failures, 0 errors, 26 skipped. Error Prone, NullAway and Revapi all clean, with no revapi entries: `isHttp2` keeps its signature and the attribute key is private. Caveat on the testing gate: `AGENTS.md` requires the build to run on JDK 11 and no JDK 11 is installed on this machine, so it was run on **JDK 17** (also in the CI matrix). The JDK 11 legs of CI on this PR are the real gate. Claude Code on behalf of @pavel-ptashyts 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent d1bdd7e commit c6750e2

4 files changed

Lines changed: 162 additions & 33 deletions

File tree

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

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,10 +1029,21 @@ protected void initChannel(Channel channel) throws Exception {
10291029
}
10301030

10311031
/**
1032-
* Checks whether the given channel is an HTTP/2 connection (i.e. has the HTTP/2 multiplex handler installed).
1032+
* Checks whether the given channel is an HTTP/2 connection: the parent that multiplexes streams, not one of
1033+
* its stream children, which carry neither the multiplex handler nor connection state of their own.
1034+
* <p>
1035+
* Answered from the {@link Http2ConnectionState} attached to the connection rather than by looking
1036+
* {@link #HTTP2_MULTIPLEX} up in the pipeline. The two are attached together, in
1037+
* {@link #upgradePipelineToHttp2}, and agree for as long as the connection is live; a pipeline lookup,
1038+
* though, compares handler names down the chain, and an HTTP/1.1 connection, which has no such handler, is
1039+
* walked to the end to say no.
1040+
* <p>
1041+
* They part on close, where Netty's own teardown strips the pipeline and leaves the attribute: a closed
1042+
* connection answers yes here and would have answered no to a lookup. Callers reach this while deciding
1043+
* what to do with a channel they have just taken from the pool, having checked it is active.
10331044
*/
10341045
public static boolean isHttp2(Channel channel) {
1035-
return channel.pipeline().get(HTTP2_MULTIPLEX) != null;
1046+
return channel.attr(Http2ConnectionState.HTTP2_STATE_KEY).get() != null;
10361047
}
10371048

10381049
/**
@@ -1070,6 +1081,21 @@ public void upgradePipelineToHttp2(ChannelPipeline pipeline) {
10701081
pipeline.remove(AHC_HTTP_HANDLER);
10711082
}
10721083

1084+
// Attach HTTP/2 connection state for MAX_CONCURRENT_STREAMS tracking and GOAWAY draining. Its
1085+
// presence is also what marks the connection as HTTP/2; see isHttp2. Attached before the handlers
1086+
// rather than after them, so that there is no instant at which the pipeline speaks HTTP/2 and the
1087+
// connection does not yet say so -- a write landing there would take the HTTP/1.1 branch onto an
1088+
// HTTP/2 pipeline. Nothing can reach this channel that early today; the ordering is what keeps that
1089+
// from being something each new caller has to know.
1090+
Http2ConnectionState state = new Http2ConnectionState();
1091+
int configMaxStreams = config.getHttp2MaxConcurrentStreams();
1092+
if (configMaxStreams > 0) {
1093+
// Client's own cap; the server-advertised value (applied by the http2-settings-listener below)
1094+
// can only lower the effective limit, never raise it above this.
1095+
state.setClientMaxConcurrentStreams(configMaxStreams);
1096+
}
1097+
pipeline.channel().attr(Http2ConnectionState.HTTP2_STATE_KEY).set(state);
1098+
10731099
// Add HTTP/2 frame codec (handles connection preface, SETTINGS, PING, flow control, etc.)
10741100
Http2Settings settings = new Http2Settings()
10751101
.initialWindowSize(config.getHttp2InitialWindowSize())
@@ -1100,16 +1126,6 @@ protected void initChannel(Channel ch) {
11001126
pipeline.addLast(HTTP2_FRAME_CODEC, frameCodec);
11011127
pipeline.addLast(HTTP2_MULTIPLEX, multiplexHandler);
11021128

1103-
// Attach HTTP/2 connection state for MAX_CONCURRENT_STREAMS tracking and GOAWAY draining
1104-
Http2ConnectionState state = new Http2ConnectionState();
1105-
int configMaxStreams = config.getHttp2MaxConcurrentStreams();
1106-
if (configMaxStreams > 0) {
1107-
// Client's own cap; the server-advertised value (applied by the http2-settings-listener below)
1108-
// can only lower the effective limit, never raise it above this.
1109-
state.setClientMaxConcurrentStreams(configMaxStreams);
1110-
}
1111-
pipeline.channel().attr(Http2ConnectionState.HTTP2_STATE_KEY).set(state);
1112-
11131129
// Install SETTINGS listener to update MAX_CONCURRENT_STREAMS from server
11141130
pipeline.addLast("http2-settings-listener", new ChannelInboundHandlerAdapter() {
11151131
@Override

‎client/src/main/java/org/asynchttpclient/netty/handler/intercept/Continue100Interceptor.java‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ public boolean exitAfterHandling100(final Channel channel, final NettyResponseFu
4141

4242
if (channel instanceof Http2StreamChannel) {
4343
// HTTP/2: the HEADERS frame was already sent with endStream=false; now send the deferred body
44-
// as DATA frame(s). writeRequest() can't be reused here — its isHttp2() check looks for the
45-
// parent connection's multiplex handler, which a stream child channel doesn't have, so it would
46-
// mis-route to the HTTP/1.1 writer (UnsupportedMessageTypeException + use-after-free).
44+
// as DATA frame(s). writeRequest() can't be reused here — it routes on the parent connection's
45+
// HTTP/2 state, which a stream child channel doesn't carry, so it would mis-route to the
46+
// HTTP/1.1 writer (UnsupportedMessageTypeException + use-after-free).
4747
//
4848
// Only resume when the body was genuinely deferred. For a request WITHOUT Expect: 100-continue
4949
// the body was already written with endStream=true, so writing DATA now would be a frame after

‎client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java‎

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -704,9 +704,12 @@ public <T> void writeRequest(NettyResponseFuture<T> future, Channel channel) {
704704
return;
705705
}
706706

707-
// Route to HTTP/2 path if the parent channel has the HTTP/2 multiplex handler installed
708-
if (ChannelManager.isHttp2(channel)) {
709-
writeHttp2Request(future, channel);
707+
// Route to HTTP/2 when the connection carries HTTP/2 state, which is attached where the multiplex
708+
// handler is. Read here rather than asked of ChannelManager.isHttp2, because the HTTP/2 path needs the
709+
// state itself and would otherwise look up what this line has already found.
710+
Http2ConnectionState http2State = channel.attr(Http2ConnectionState.HTTP2_STATE_KEY).get();
711+
if (http2State != null) {
712+
writeHttp2Request(future, channel, http2State);
710713
return;
711714
}
712715

@@ -772,11 +775,12 @@ public <T> void writeRequest(NettyResponseFuture<T> future, Channel channel) {
772775
* as HTTP/2 frames ({@link DefaultHttp2HeadersFrame} + optional {@link DefaultHttp2DataFrame}).
773776
* The stream child channel has the {@link org.asynchttpclient.netty.handler.Http2Handler} installed
774777
* and the {@link NettyResponseFuture} attached to it, mirroring the HTTP/1.1 channel model.
778+
*
779+
* @param state the connection's HTTP/2 state, which is what identified it as an HTTP/2 connection in the
780+
* first place, so the caller has it in hand
775781
*/
776-
private <T> void writeHttp2Request(NettyResponseFuture<T> future, Channel parentChannel) {
777-
Http2ConnectionState state = parentChannel.attr(Http2ConnectionState.HTTP2_STATE_KEY).get();
778-
779-
if (state != null && !state.tryAcquireStream()) {
782+
private <T> void writeHttp2Request(NettyResponseFuture<T> future, Channel parentChannel, Http2ConnectionState state) {
783+
if (!state.tryAcquireStream()) {
780784
if (state.isDraining()) {
781785
// Connection is draining from GOAWAY — fail the future so it retries on a new connection.
782786
// Don't close the parent channel since it may still have active streams. sendHttp2Frames
@@ -851,14 +855,12 @@ protected void initChannel(Http2StreamChannel streamCh) {
851855
if (openedRequest != null) {
852856
openedRequest.release();
853857
}
854-
if (state != null) {
855-
state.releaseStream();
856-
// Close the parent once it has no active streams AND it is either draining
857-
// (GOAWAY) or a redundant duplicate (#10 thundering-herd loser) — neither
858-
// will serve further requests, so it must not linger open.
859-
if ((state.isDraining() || state.isRedundant()) && state.getActiveStreams() <= 0) {
860-
channelManager.closeChannel(parentChannel);
861-
}
858+
state.releaseStream();
859+
// Close the parent once it has no active streams AND it is either draining
860+
// (GOAWAY) or a redundant duplicate (#10 thundering-herd loser) — neither
861+
// will serve further requests, so it must not linger open.
862+
if ((state.isDraining() || state.isRedundant()) && state.getActiveStreams() <= 0) {
863+
channelManager.closeChannel(parentChannel);
862864
}
863865
});
864866

@@ -897,9 +899,7 @@ protected void initChannel(Http2StreamChannel streamCh) {
897899
} else {
898900
// Stream channel was never opened — no closeFuture will fire, so release the
899901
// acquired slot and the unsent request body inline.
900-
if (state != null) {
901-
state.releaseStream();
902-
}
902+
state.releaseStream();
903903
releaseHttp2Request(future);
904904
// Fail ONLY this future (future.abort, not abort(parentChannel, ...)): opening one stream
905905
// can fail for a stream-local reason (e.g. Netty rejecting it as the outbound max-streams
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Copyright (c) 2026 AsyncHttpClient Project. 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+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.asynchttpclient.netty.channel;
17+
18+
import io.netty.channel.Channel;
19+
import io.netty.channel.ChannelInboundHandlerAdapter;
20+
import io.netty.channel.embedded.EmbeddedChannel;
21+
import io.netty.handler.codec.http2.Http2StreamChannelBootstrap;
22+
import io.netty.util.HashedWheelTimer;
23+
import io.netty.util.Timer;
24+
import org.junit.jupiter.api.AfterAll;
25+
import org.junit.jupiter.api.AfterEach;
26+
import org.junit.jupiter.api.BeforeAll;
27+
import org.junit.jupiter.api.BeforeEach;
28+
import org.junit.jupiter.api.Test;
29+
30+
import static org.asynchttpclient.Dsl.config;
31+
import static org.junit.jupiter.api.Assertions.assertFalse;
32+
import static org.junit.jupiter.api.Assertions.assertNotNull;
33+
import static org.junit.jupiter.api.Assertions.assertNull;
34+
import static org.junit.jupiter.api.Assertions.assertTrue;
35+
36+
/**
37+
* {@link ChannelManager#isHttp2(Channel)} answers from the {@link Http2ConnectionState} attached to a
38+
* connection, while the thing it stands for is the multiplex handler in the pipeline. These pin the two
39+
* together: either both say HTTP/2 or neither does, whichever way a later change to the upgrade attaches them.
40+
*/
41+
class ChannelManagerHttp2MarkerTest {
42+
43+
// One per class: the upgrade is what is under test and it needs a ChannelManager only to be called. Building
44+
// one per test costs an SslContext and an event loop group each time, for state that lives on the channel.
45+
private static ChannelManager channelManager;
46+
private static Timer timer;
47+
48+
private EmbeddedChannel channel;
49+
50+
@BeforeAll
51+
static void startManager() {
52+
timer = new HashedWheelTimer();
53+
channelManager = new ChannelManager(config().build(), timer);
54+
}
55+
56+
@AfterAll
57+
static void stopManager() {
58+
if (channelManager != null) {
59+
channelManager.close();
60+
}
61+
if (timer != null) {
62+
timer.stop();
63+
}
64+
}
65+
66+
@BeforeEach
67+
void setUp() {
68+
channel = new EmbeddedChannel();
69+
}
70+
71+
@AfterEach
72+
void tearDown() {
73+
if (channel != null) {
74+
channel.finishAndReleaseAll();
75+
}
76+
}
77+
78+
@Test
79+
void aConnectionThatWasNeverUpgradedIsNotHttp2() {
80+
assertNull(channel.pipeline().get(ChannelManager.HTTP2_MULTIPLEX),
81+
"an untouched pipeline should not carry the multiplex handler");
82+
assertFalse(ChannelManager.isHttp2(channel), "and should not be reported as HTTP/2");
83+
}
84+
85+
@Test
86+
void upgradingAConnectionBothInstallsTheHandlerAndReportsHttp2() {
87+
channelManager.upgradePipelineToHttp2(channel.pipeline());
88+
89+
assertNotNull(channel.pipeline().get(ChannelManager.HTTP2_MULTIPLEX),
90+
"the upgrade should install the multiplex handler");
91+
assertTrue(ChannelManager.isHttp2(channel), "and should report the connection as HTTP/2");
92+
}
93+
94+
@Test
95+
void aStreamOfAnHttp2ConnectionIsNotTheConnection() {
96+
// A real stream child rather than a bare channel: what is worth pinning is that a stream does not
97+
// inherit the connection state its parent carries, since that is now what identifies an HTTP/2
98+
// connection. The stream is where a request is written, so mistaking it for its parent would loop.
99+
channelManager.upgradePipelineToHttp2(channel.pipeline());
100+
channel.runPendingTasks();
101+
102+
Channel stream = new Http2StreamChannelBootstrap(channel)
103+
.handler(new ChannelInboundHandlerAdapter())
104+
.open().syncUninterruptibly().getNow();
105+
try {
106+
assertNull(stream.pipeline().get(ChannelManager.HTTP2_MULTIPLEX),
107+
"a stream child carries no multiplex handler of its own");
108+
assertFalse(ChannelManager.isHttp2(stream), "and is not the connection that multiplexes it");
109+
} finally {
110+
stream.close().syncUninterruptibly();
111+
}
112+
}
113+
}

0 commit comments

Comments
 (0)