Skip to content

GraphStage TLS fails with "Stuck in unwrap loop" when peer data arrives while the engine needs to wrap and transport output has no demand #3732

Closed

Description

@pjfanning

Summary

With pekko.stream.materializer.tls.engine = graph-stage, TlsGraphStage fails the stream with IllegalStateException: Stuck in unwrap loop when ciphertext from the peer arrives while the SSLEngine needs to wrap (NEED_WRAP) and the ciphertext output has no downstream demand.

How it was hit

I found this while writing the real-SSLEngine tests for #3731. A test drove a plain client SSLEngine against a server TlsGraphStage (TLS 1.3, TestSource/TestSink on the ciphertext ports), and requested server ciphertext one element at a time, only when the client needed to unwrap.

  1. The client sends ClientHello. The server has to send its whole flight (ServerHello … Finished), but the test only requested one element.
  2. The client unwraps ServerHello and then sends its 6-byte middlebox-compatibility ChangeCipherSpec.
  3. That CCS reaches the server while the server engine is still NEED_WRAP and the ciphertext output has no demand.
java.lang.IllegalStateException: Stuck in unwrap loop, bailing out, last handshake status [NEED_WRAP], remaining=6, out=0, (https://github.com/apache/pekko/issues/442)
	at org.apache.pekko.stream.impl.io.TlsGraphStage$$anon$1.doUnwrap(TlsGraphStage.scala:487)
	at org.apache.pekko.stream.impl.io.TlsGraphStage$$anon$1.doInbound(TlsGraphStage.scala:361)
	at org.apache.pekko.stream.impl.io.TlsGraphStage$$anon$1.runPhase(TlsGraphStage.scala:225)
	at org.apache.pekko.stream.impl.io.TlsGraphStage$$anon$1.pump(TlsGraphStage.scala:188)
	at org.apache.pekko.stream.impl.io.TlsGraphStage$$anon$1.org$apache$pekko$stream$impl$io$TlsGraphStage$$anon$$runPump(TlsGraphStage.scala:178)
	at org.apache.pekko.stream.impl.io.TlsGraphStage$$anon$1$TransportInputSlot.onPush(TlsGraphStage.scala:1021)

(Line numbers are from 8f3d71b.) The test in #3731 avoids this by requesting some demand up front, so it is not part of that PR.

Analysis

In doUnwrap, unwrap returns OK with NEED_WRAP without consuming the record, and the stage puts the bytes back (putBackUnreadBuffer). Nothing can be wrapped without transport demand, but the inbound side still reports ready (transport data present and plaintext demand available). So the pump keeps re-running doInbound → doUnwrap until inlineHandshakeReentries exceeds MaxInlineEngineProgressAttempts and the stage fails. Back-pressure on the ciphertext output is legitimate, so this should wait for demand rather than fail.

Over real TCP the ciphertext output usually has demand, so this needs the TCP write side to be back-pressured at the moment the peer sends mid-flight. It should be rare, but it is plausible with slow networks or large handshake flights.

Suggested direction

When the last handshake status is NEED_WRAP and the transport output has no demand, treat the inbound side as not ready (or stop the inbound loop) until onPull on the ciphertext output re-runs the pump, instead of re-unwrapping the same bytes.

Legacy engine

TLSActor has the same guard with a comment asking for a reproducer (#442). I have not checked whether the legacy engine fails with the same sequence; if it does, this may be a reproducer for #442.

References

Refs #442, #3731

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