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 @ 85d7243.
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"):
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.
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@ 85d7243.The blocker
The server already negotiates via ALPN and switches stacks:
Http2Ext.httpsWithAlpn(Http2.scala:241) parks the chosen protocol in a mutable cell fromsetHandshakeApplicationProtocolSelector, andProtocolSwitch(ProtocolSwitch.scala) installs either the HTTP/1.1 or HTTP/2 stack.ProtocolSwitchmakes that decision on the first inboundSessionBytes. 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
TLSstage emits no handshake-complete event.SslTlsInboundis only everSessionBytesorSessionTruncated, andSessionBytesonly arrives when there is application data.SSLSession(carried bySessionBytes) does not expose the negotiated ALPN protocol. OnlySSLEngine.getApplicationProtocoldoes.SSLEngine.setHandshakeApplicationProtocolSelectoris only meaningful for the peer that selects the protocol, i.e. the server.HandshakeCompletedListenerisSSLSocket-only;SSLEnginehas no equivalent.Proposed fix
Wrap the engine returned by
HttpsConnectionContext.engineCreatorin a delegatingSSLEnginethat, on the firstwrap/unwrapafter whichgetApplicationProtocolgoes non-null, completes aPromise[String]. The client-side switch stage installs the chosen layer from anAsyncCallbackhung off that promise.Notes on shape:
PersistentConnection.managedConnectionre-materializes the connection flow on every reconnect, so thehttpsWithAlpnapproach of a closed-overvarplus an "is not reusable" guard is not viable here. Building the stack insideFlow.fromMaterializergives a fresh promise and engine per materialization.Http.ClientLayer(Http.scala:794) isBidiFlow[HttpRequest, SslTlsOutbound, SslTlsInbound, HttpResponse, NotUsed], andHttp2Blueprint.clientStack(Http2Blueprint.scala:146).atop(unwrapTls)(:327) has that same shape. Both carryNotUsed, so the client switch needs none of theServerTerminatorplumbingProtocolSwitchcarries — but it is a 4-portBidiFlowstage rather than the server's 2-portFlow.The delegation is ~30 mechanical methods, which is the main cost of this approach.
Alternative considered
A
TimerGraphStageLogicpollingengine.getApplicationProtocolis 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 itsprotocolsparameter and hardcodesArray("h2"):Harmless today because the only caller (
Http2.scala:275) passesArray("h2"), but this is exactly the seam negotiation needs — offering["h2", "http/1.1"]is step one.