Skip to content

Mark an HTTP/2 connection instead of looking it up - #2320

Merged
hyperxpro merged 3 commits into
AsyncHttpClient:mainfrom
maygemdev:perf/http2-check-without-pipeline-lookup
Sep 18, 2026
Merged

hyperxpro merged 3 commits into
AsyncHttpClient:mainfrom
maygemdev:perf/http2-check-without-pipeline-lookup

Conversation

@pavel-ptashyts

Copy link
Copy Markdown
Contributor

Problem

ChannelManager.isHttp2(channel) asks the pipeline for the multiplex handler by name:

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:

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:

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

isHttp2 asked the pipeline for the multiplex handler by name, and the
write path asks isHttp2 of every request: once to decide whether to
store the per-request future on the channel, once to route the write. A
pipeline lookup walks the handlers comparing names, and an HTTP/1.1
connection, having no such handler, is walked to the end to say no,
which is the common case for anyone not using HTTP/2. In one profile of
a client with HTTP/2 disabled, DefaultChannelPipeline.context0 took 83
CPU samples on that account alone.

The multiplex handler is installed in exactly one place, so a channel
attribute is set beside it and isHttp2 reads that: 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(), which would add an entry
to the attribute map of every HTTP/1.1 channel just to find none.

Not a config check. Reading isHttp2Enabled first would be cheaper still,
but the two can disagree: NettyConnectListener upgrades on the ALPN
result alone, and a caller who supplies an SslContext or an
SslEngineFactory of their own controls what ALPN advertises whatever the
config says - which the WebSocket guard beside it already accounts for.
A request would then be written as HTTP/1.1 onto an HTTP/2 pipeline. The
attribute costs nothing extra and cannot disagree, since it is set where
the handler is.

The attribute cannot go stale either: nothing removes the multiplex
handler from a pipeline, so there is no downgrade for the two to differ
across.

ChannelManagerHttp2MarkerTest pins them together, so a later change to
the upgrade cannot set one without the other. Removing the attribute
fails that test and 46 of the 52 in BasicHttp2Test, the write path
having routed HTTP/2 connections down the HTTP/1.1 branch.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java Outdated
Comment thread client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java Outdated
Comment thread client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java Outdated
Review feedback. The previous commit added an attribute of its own to
stand for an HTTP/2 connection, but the connection already carries one:
upgradePipelineToHttp2 attaches an Http2ConnectionState ten lines below
the multiplex handler, and writeHttp2Request reads it back as its first
statement. Routing on that drops the second marker, and with it the
question of keeping two in sync.

writeRequest reads the state once and hands it to writeHttp2Request,
which no longer looks it up again, and the null check that guarded that
lookup goes too: its only caller now routes on the state being there.

Neither the state nor the multiplex handler is ever taken away by this
client, so the two agree for as long as a connection lives. They part
after Netty's own teardown removes the handlers on close, where isHttp2
keeps saying HTTP/2 and a pipeline lookup would have stopped: both
callers are behind an active-channel check, so nothing asks by then.

The test builds one ChannelManager for the class rather than one per
test - an SslContext and an event loop group each time, for state that
lives on the channel - and closes it, which it was not doing: the fork
gained file descriptors it never gave back. The stream case opened a
bare EmbeddedChannel, which has no parent and so asserted what the
first case already did; it opens a real stream child now, which pins
the part that is actually new, a stream not inheriting the connection
state of its parent.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pavel-ptashyts

Copy link
Copy Markdown
Contributor Author

Round 1 addressed. The HTTP2_STATE_KEY suggestion was the right call and it took the other two main-code comments with it: there is no new attribute any more, writeRequest reads the state once and hands it to writeHttp2Request, which loses its own lookup and the null guard around it. So the change is now one lookup where there were two, rather than a cheaper lookup plus a second marker.

The one difference from the old pipeline lookup that survives is recorded inline: Netty removes handlers during teardown while the attribute stays, so isHttp2 keeps answering true after a close where the lookup would have stopped. Both callers are behind an active-channel check.

Tests: one ChannelManager per class instead of per test, close() added - it was missing, and your fd count was right - and the stream case opens a real Http2StreamChannel rather than a bare EmbeddedChannel that was re-testing the first case.

./mvnw clean verify green. JDK 17 locally, no 11 on this machine, so the JDK 11 legs of CI are the real gate.

Comment thread client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java Outdated
Comment thread client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java Outdated
Review feedback.

The state was attached after the frame codec and the multiplex handler,
which left an instant in which the pipeline spoke HTTP/2 and the
connection did not yet say so. That gap was harmless while a null state
meant "carry on anyway", and this branch made it load bearing: a write
landing there used to take the HTTP/2 path with an unaccounted stream
slot, and would now take the HTTP/1.1 path onto an HTTP/2 pipeline and
die in UnsupportedMessageTypeException. Nothing can reach a channel that
early today - all three callers are on its event loop with the channel
in neither the pool nor the registry - but that is a fact about the
callers rather than about this method. Attaching first makes it a
property of the upgrade, and inverts what a racing write would do: it
would open a stream on a pipeline that has no multiplex handler yet,
which fails out of Http2StreamChannelBootstrap and is retried, rather
than corrupting a connection.

The javadoc on isHttp2 claimed neither marker is ever taken away. That
is not true of the handler: Netty strips the pipeline on close, so a
closed connection answers no by lookup and yes by attribute - the
divergence this branch records elsewhere, contradicted here. It also
said the write path asks this of every request, which this branch is
what stopped being true: writeRequest reads the state itself now and
never calls isHttp2. It says what holds instead, that the two agree
while the connection is live and only the attribute survives its close.

Two javadoc blocks had ended up stacked on writeHttp2Request, so the
description of the stream child model was dropped from the published
javadoc and only the @PARAM survived. And the state != null guards
inside openHttp2Stream can no longer be false, the only caller having
routed on that state being non-null, so they go the way of the one at
the top.

Continue100Interceptor explained itself in terms of writeRequest looking
the multiplex handler up in the pipeline, which it no longer does. The
reason stands - a stream child carries neither the handler nor the state
- so only the wording changed.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pavel-ptashyts

Copy link
Copy Markdown
Contributor Author

Round 2 addressed.

The ordering one was the substantive change: the state is attached before the frame codec and the multiplex handler now, so the upgrade itself keeps the invariant rather than the callers keeping it by accident. As you say, that also inverts the failure - a write racing into the window opens a stream on a pipeline without a multiplex handler, which fails out of the bootstrap and retries, instead of an HTTP/1.1 write onto an HTTP/2 pipeline. I had a second look at what the reorder could disturb: nothing between the two points reads the attribute, adding either handler does not replay channelActive or start an inbound read, and the SETTINGS listener is still added after both with the state already in place either way.

The isHttp2 javadoc is rewritten - both claims in it were wrong, and the first one contradicted what I had written on the HTTP2_STATE_KEY thread, which is the part I should have caught. The stacked javadoc blocks on writeHttp2Request are one block, and the two dead state != null guards in openHttp2Stream are gone.

One thing beyond the review, because this commit is what made it stale: Continue100Interceptor explained itself in terms of writeRequest looking the multiplex handler up in the pipeline, which it no longer does. The reason it gives still holds, so only the wording changed.

./mvnw clean verify green. JDK 17 locally, no 11 on this machine.

@hyperxpro
hyperxpro merged commit c6750e2 into AsyncHttpClient:main Sep 18, 2026
13 checks passed
@hyperxpro

Copy link
Copy Markdown
Member

Thanks a lot!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants