Skip to content

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

Description

@pjfanning

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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions