Skip to content

Keep draining TLS plaintext after a TLS 1.3 peer close_notify - #3731

Merged
He-Pin merged 1 commit into
apache:mainfrom
pjfanning:tls-drain-after-peer-close-notify
Oct 30, 2026
Merged

He-Pin merged 1 commit into
apache:mainfrom
pjfanning:tls-drain-after-peer-close-notify

Conversation

@pjfanning

Copy link
Copy Markdown
Member

Motivation

#3727 made the GraphStage TLS engine (pekko.stream.materializer.tls.engine = graph-stage) drain plaintext it has already accepted when the plaintext output is cancelled. If a TLS 1.3 peer sends close_notify during that drain, unwrap returns CLOSED, completeOrFlush completes the stage and pumpFinished cancels the user input. The accepted plaintext is discarded, and our own close_notify is never sent. In TLS 1.3, close_notify only closes the peer's sending direction, so the response could still have been delivered.

Modification

  • In doUnwrap, when unwrap returns CLOSED while the stage is in the inbound-closed drain, accepted plaintext remains and the engine's outbound side is still open, stay in the drain instead of completing. The drain then finishes through the existing path: closeOutbound, send close_notify, complete.
  • Document in the stream IO guide and both TLS DSL scaladocs that a TLS 1.3 close_notify does not end the drain, while a TLS 1.2 close_notify (which closes both SSLEngine directions) does.
  • Add tests in TlsGraphStageCancellationSpec that drive a real client SSLEngine against the operator. The server accepts a response larger than its remaining ciphertext demand, its plaintext output is cancelled, and the client then sends close_notify. These run for IgnoreComplete and EagerClose, on TLS 1.3 (full response expected) and TLS 1.2 (prompt termination with a partial prefix expected).

Result

A TLS 1.3 peer that half-closes receives the accepted response followed by the server's close_notify. TLS 1.2 behaviour is unchanged and still terminates promptly. The legacy actor engine is unaffected.

Tests

  • sbt "stream-tests / Test / testOnly org.apache.pekko.stream.io.TlsGraphStageCancellationSpec": the two TLS 1.3 tests failed before the fix (partial response) and pass after it; 6/6 pass.
  • sbt "stream-tests / Test / testOnly org.apache.pekko.stream.io.Tls*": 281/281 passed.
  • Native scalafmt and git diff --check passed.

References

Refs #3727

Motivation:
When the plaintext output is cancelled, the GraphStage TLS engine drains
plaintext it has already accepted. If a TLS 1.3 peer sent close_notify
during that drain, unwrap returned CLOSED and the stage completed,
discarding the accepted plaintext without sending it or its own
close_notify, although TLS 1.3 close_notify only closes the peer's
sending direction.

Modification:
- Stay in the inbound-closed drain when unwrap returns CLOSED while
  accepted plaintext is still being drained and the engine's outbound
  side is open. The drain then finishes and closes outbound as before.
- Document that a TLS 1.3 close_notify does not end the drain, while a
  TLS 1.2 close_notify (which closes both SSLEngine directions) does.
- Add real-SSLEngine tests that send close_notify during the drain for
  IgnoreComplete and EagerClose, on TLS 1.3 and TLS 1.2.

Result:
A TLS 1.3 peer that half-closes receives the accepted response followed
by close_notify. TLS 1.2 behaviour is unchanged and terminates promptly.

Tests:
- sbt "stream-tests / Test / testOnly org.apache.pekko.stream.io.TlsGraphStageCancellationSpec":
  the two TLS 1.3 tests failed before the fix (partial response) and
  pass after it; 6/6 pass.
- sbt "stream-tests / Test / testOnly org.apache.pekko.stream.io.Tls*": 281/281 passed.
- Native scalafmt and git diff --check passed.

References:
Refs apache#3727

@He-Pin He-Pin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Traced the state machine: in InboundClosedPhase the peer close_notify makes transportInput non-empty, so drainBeforeInboundClose goes false and doInbound reaches doUnwrap. Skipping completeOrFlush() there is safe because engine.isInboundDone then forces inboundHalfClosedReady/inboundHalfClosedCompleted, so the pump exits and the drain finishes via doOutbound -> closeOutbound -> FlushingOutboundPhase. No spin: phaseExecutable is false without transport demand, and OutputSlot.onPull re-runs the pump. The phase.toInt != InboundClosedPhase guard keeps AwaitingClosePhase and the bidirectional/outbound-closed paths unchanged. Extracting drainingAcceptedUserInput is the right call since drainBeforeInboundClose cannot express this state. Docs and both DSL scaladocs match the tested TLS 1.2 vs 1.3 behaviour.

@He-Pin
He-Pin merged commit eeffdef into apache:main Oct 30, 2026
10 checks passed
@pjfanning
pjfanning deleted the tls-drain-after-peer-close-notify branch October 30, 2026 07:28
pjfanning added a commit to pjfanning/incubator-pekko that referenced this pull request Oct 30, 2026
Add apache#3727, apache#3728, apache#3731, apache#3734, apache#3738 (TLS), apache#3730 (fanout publisher
performance) and apache#3733 (shard lease release logging).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pjfanning added a commit to pjfanning/incubator-pekko that referenced this pull request Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants