diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java
index f305fb3f3..954f7ec9f 100755
--- a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java
+++ b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java
@@ -1027,10 +1027,17 @@ protected void initChannel(Channel channel) throws Exception {
}
/**
- * Checks whether the given channel is an HTTP/2 connection (i.e. has the HTTP/2 multiplex handler installed).
+ * Checks whether the given channel is an HTTP/2 connection: the parent that multiplexes streams, not one of
+ * its stream children, which carry neither the multiplex handler nor connection state of their own.
+ *
+ * Answered from the {@link Http2ConnectionState} attached to the connection rather than by looking
+ * {@link #HTTP2_MULTIPLEX} up in the pipeline. The two are attached together, in
+ * {@link #upgradePipelineToHttp2}, and neither is ever taken away, so they say the same thing; but a
+ * pipeline lookup compares handler names down the chain, and an HTTP/1.1 connection, which has no such
+ * handler, is walked to the end to say no. The write path asks this of every request.
*/
public static boolean isHttp2(Channel channel) {
- return channel.pipeline().get(HTTP2_MULTIPLEX) != null;
+ return channel.attr(Http2ConnectionState.HTTP2_STATE_KEY).get() != null;
}
/**
@@ -1097,7 +1104,8 @@ protected void initChannel(Channel ch) {
pipeline.addLast(HTTP2_FRAME_CODEC, frameCodec);
pipeline.addLast(HTTP2_MULTIPLEX, multiplexHandler);
- // Attach HTTP/2 connection state for MAX_CONCURRENT_STREAMS tracking and GOAWAY draining
+ // Attach HTTP/2 connection state for MAX_CONCURRENT_STREAMS tracking and GOAWAY draining. Its
+ // presence is also what marks the connection as HTTP/2; see isHttp2.
Http2ConnectionState state = new Http2ConnectionState();
int configMaxStreams = config.getHttp2MaxConcurrentStreams();
if (configMaxStreams > 0) {
diff --git a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java
index c142bc62a..a9beb025a 100755
--- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java
+++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java
@@ -703,9 +703,12 @@ public void writeRequest(NettyResponseFuture future, Channel channel) {
return;
}
- // Route to HTTP/2 path if the parent channel has the HTTP/2 multiplex handler installed
- if (ChannelManager.isHttp2(channel)) {
- writeHttp2Request(future, channel);
+ // Route to HTTP/2 when the connection carries HTTP/2 state, which is attached where the multiplex
+ // handler is. Read here rather than asked of ChannelManager.isHttp2, because the HTTP/2 path needs the
+ // state itself and would otherwise look up what this line has already found.
+ Http2ConnectionState http2State = channel.attr(Http2ConnectionState.HTTP2_STATE_KEY).get();
+ if (http2State != null) {
+ writeHttp2Request(future, channel, http2State);
return;
}
@@ -772,10 +775,12 @@ public void writeRequest(NettyResponseFuture future, Channel channel) {
* The stream child channel has the {@link org.asynchttpclient.netty.handler.Http2Handler} installed
* and the {@link NettyResponseFuture} attached to it, mirroring the HTTP/1.1 channel model.
*/
- private void writeHttp2Request(NettyResponseFuture future, Channel parentChannel) {
- Http2ConnectionState state = parentChannel.attr(Http2ConnectionState.HTTP2_STATE_KEY).get();
-
- if (state != null && !state.tryAcquireStream()) {
+ /**
+ * @param state the connection's HTTP/2 state, which is what identified it as an HTTP/2 connection in the
+ * first place, so the caller has it in hand
+ */
+ private void writeHttp2Request(NettyResponseFuture future, Channel parentChannel, Http2ConnectionState state) {
+ if (!state.tryAcquireStream()) {
if (state.isDraining()) {
// Connection is draining from GOAWAY — fail the future so it retries on a new connection.
// Don't close the parent channel since it may still have active streams. sendHttp2Frames
diff --git a/client/src/test/java/org/asynchttpclient/netty/channel/ChannelManagerHttp2MarkerTest.java b/client/src/test/java/org/asynchttpclient/netty/channel/ChannelManagerHttp2MarkerTest.java
new file mode 100644
index 000000000..16e8fcb28
--- /dev/null
+++ b/client/src/test/java/org/asynchttpclient/netty/channel/ChannelManagerHttp2MarkerTest.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * 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 org.asynchttpclient.netty.channel;
+
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.channel.embedded.EmbeddedChannel;
+import io.netty.handler.codec.http2.Http2StreamChannelBootstrap;
+import io.netty.util.HashedWheelTimer;
+import io.netty.util.Timer;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.asynchttpclient.Dsl.config;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * {@link ChannelManager#isHttp2(Channel)} answers from the {@link Http2ConnectionState} attached to a
+ * connection, while the thing it stands for is the multiplex handler in the pipeline. These pin the two
+ * together: either both say HTTP/2 or neither does, whichever way a later change to the upgrade attaches them.
+ */
+class ChannelManagerHttp2MarkerTest {
+
+ // One per class: the upgrade is what is under test and it needs a ChannelManager only to be called. Building
+ // one per test costs an SslContext and an event loop group each time, for state that lives on the channel.
+ private static ChannelManager channelManager;
+ private static Timer timer;
+
+ private EmbeddedChannel channel;
+
+ @BeforeAll
+ static void startManager() {
+ timer = new HashedWheelTimer();
+ channelManager = new ChannelManager(config().build(), timer);
+ }
+
+ @AfterAll
+ static void stopManager() {
+ if (channelManager != null) {
+ channelManager.close();
+ }
+ if (timer != null) {
+ timer.stop();
+ }
+ }
+
+ @BeforeEach
+ void setUp() {
+ channel = new EmbeddedChannel();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (channel != null) {
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void aConnectionThatWasNeverUpgradedIsNotHttp2() {
+ assertNull(channel.pipeline().get(ChannelManager.HTTP2_MULTIPLEX),
+ "an untouched pipeline should not carry the multiplex handler");
+ assertFalse(ChannelManager.isHttp2(channel), "and should not be reported as HTTP/2");
+ }
+
+ @Test
+ void upgradingAConnectionBothInstallsTheHandlerAndReportsHttp2() {
+ channelManager.upgradePipelineToHttp2(channel.pipeline());
+
+ assertNotNull(channel.pipeline().get(ChannelManager.HTTP2_MULTIPLEX),
+ "the upgrade should install the multiplex handler");
+ assertTrue(ChannelManager.isHttp2(channel), "and should report the connection as HTTP/2");
+ }
+
+ @Test
+ void aStreamOfAnHttp2ConnectionIsNotTheConnection() {
+ // A real stream child rather than a bare channel: what is worth pinning is that a stream does not
+ // inherit the connection state its parent carries, since that is now what identifies an HTTP/2
+ // connection. The stream is where a request is written, so mistaking it for its parent would loop.
+ channelManager.upgradePipelineToHttp2(channel.pipeline());
+ channel.runPendingTasks();
+
+ Channel stream = new Http2StreamChannelBootstrap(channel)
+ .handler(new ChannelInboundHandlerAdapter())
+ .open().syncUninterruptibly().getNow();
+ try {
+ assertNull(stream.pipeline().get(ChannelManager.HTTP2_MULTIPLEX),
+ "a stream child carries no multiplex handler of its own");
+ assertFalse(ChannelManager.isHttp2(stream), "and is not the connection that multiplexes it");
+ } finally {
+ stream.close().syncUninterruptibly();
+ }
+ }
+}