apache / apache/pekko-http

HTTP/2 client ALPN fallback needs a handshake-complete signal that the TLS stage does not emit

Open
#1,249 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Scala
Stars
196
Forks
55
Avg merge
4d 52m
Merged PRs (30d)
74

Description

Split out from #483 (client-side HTTP/1.1 vs HTTP/2 protocol negotiation), which needs this solved first.

Code anchors below are against `main` @ 85d7243e7.

## The blocker

The server already negotiates via ALPN and switches stacks: `Http2Ext.httpsWithAlpn` (`Http2.scala:241`) parks the chosen protocol in a mutable cell from `setHandshakeApplicationProtocolSelector`, and `ProtocolSwitch` (`ProtocolSwitch.scala`) installs either the HTTP/1.1 or HTTP/2 stack.

`ProtocolSwitch` makes that decision **on the first inbound `SessionBytes`**. That is sound server-side, because in HTTP the client always speaks first — there is always an inbound byte to trigger on.

Mirroring it on the client deadlocks. If negotiation lands on `http/1.1`, the server sends nothing until it receives a request, and the switch is sitting on that request waiting to learn the protocol. Neither side moves.

So the client needs a different trigger: a "handshake complete, ALPN result known" signal, delivered without depending on inbound application bytes.

## Why the obvious signals don't work

- Pekko's `TLS` stage emits no handshake-complete event. `SslTlsInbound` is only ever `SessionBytes` or `SessionTruncated`, and `SessionBytes` only arrives when there is application data.
- `SSLSession` (carried by `SessionBytes`) does not expose the negotiated ALPN protocol. Only `SSLEngine.getApplicationProtocol` does.
- `SSLEngine.setHandshakeApplicationProtocolSelector` is only meaningful for the peer that *selects* the protocol, i.e. the server.
- `HandshakeCompletedListener` is `SSLSocket`-only; `SSLEngine` has no equivalent.

## Proposed fix

Wrap the engine returned by `HttpsConnectionContext.engineCreator` in a delegating `SSLEngine` that, on the first `wrap`/`unwrap` after which `getApplicationProtocol` goes non-null, completes a `Promise[String]`. The client-side switch stage installs the chosen layer from an `AsyncCallback` hung off that promise.

Notes on shape:

- Per-materialization state is required — `PersistentConnection.managedConnection` re-materializes the connection flow on every reconnect, so the `httpsWithAlpn` approach of a closed-over `var` plus an "is not reusable" guard is not viable here. Building the stack inside `Flow.fromMaterializer` gives a fresh promise and engine per materialization.
- The switch stage must buffer inbound elements that arrive before the async callback is delivered (with h2, the server's SETTINGS frame can land first) and replay them on install.
- Types line up favourably: `Http.ClientLayer` (`Http.scala:794`) is `BidiFlow[HttpRequest, SslTlsOutbound, SslTlsInbound, HttpResponse, NotUsed]`, and `Http2Blueprint.clientStack` (`Http2Blueprint.scala:146`) `.atop(unwrapTls)` (`:327`) has that same shape. Both carry `NotUsed`, so the client switch needs none of the `ServerTerminator` plumbing `ProtocolSwitch` carries — but it is a 4-port `BidiFlow` stage rather than the server's 2-port `Flow`.

The delegation is ~30 mechanical methods, which is the main cost of this approach.

## Alternative considered

A `TimerGraphStageLogic` polling `engine.getApplicationProtocol` is a few lines instead of a delegating engine, but puts a poll loop in the connection setup path. Recorded here as the fallback if the wrapper turns out to interact badly with the TLS stage.

## Prerequisite bug

`Http2JDKAlpnSupport.clientSetApplicationProtocols` (`Http2AlpnSupport.scala:74`) ignores its `protocols` parameter and hardcodes `Array("h2")`:

```scala
def clientSetApplicationProtocols(engine: SSLEngine, protocols: Array[String]): Unit = {
val params = engine.getSSLParameters
params.setApplicationProtocols(Array("h2")) // should be `protocols`
engine.setSSLParameters(params)
}
```

Harmless today because the only caller (`Http2.scala:275`) passes `Array("h2")`, but this is exactly the seam negotiation needs — offering `["h2", "http/1.1"]` is step one.

Contributor guide

Open the contributing guide

Research direction

Start with Http2AlpnSupport.scala:74 and Http2.scala:241,275, then trace ProtocolSwitch.scala and Http2Blueprint.scala:146,327. Implement the per-materialization engine signal and client-side 4-port switch described in the issue, including buffering early inbound elements. Done means offering the supplied protocols and selecting the negotiated HTTP/1.1 or HTTP/2 stack without deadlock.

Written by the indexing model from the issue text.

Assessment

Tech stack
java, scala
Domain
backend-api-design, networking
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.