HTTP/2 client ALPN fallback needs a handshake-complete signal that the TLS stage does not emit
- 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
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