Skip to content

Preserve accepted TLS plaintext on output cancellation - #3727

Merged
He-Pin merged 1 commit into
apache:mainfrom
He-Pin:test/tls-tcp-closing-policy
Oct 29, 2026
Merged

He-Pin merged 1 commit into
apache:mainfrom
He-Pin:test/tls-tcp-closing-policy

Conversation

@He-Pin

@He-Pin He-Pin commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Motivation

The GraphStage TLS engine could close outbound before encrypting plaintext it had already accepted into its deferred batch.

The original four-argument TcpSpec echo failure is a separate cancellation-versus-delivery race, tracked in #3729. This PR does not modify or restore that test and does not claim to eliminate its intermittent failure.

Modification

  • Drain accepted application data for the established session before closing, stopping new input and respecting backpressure.
  • Defer transport-input half-close until the accepted batch drains without spinning while transport demand is absent.
  • Terminate when transport output closes under every closing policy, including IgnoreCancel and IgnoreBoth.
  • Preserve upstream failure during delayed cancellation while ignoring completion that would otherwise abort the drain.
  • Do not start queued renegotiation or wait for a new handshake.
  • Add deterministic cancellation/termination regressions; keep the subscriber boundary adjustment specific to custom cancellation attributes.
  • Update Scala/Java Scaladocs and a dedicated closing-policy guide section with the engine configuration key, default engine, drain bound, and possible termination delay.

Result

Already-accepted plaintext is retained when plaintext-output cancellation is observed before the peer's transport input completes, subject to the documented drain limits. When peer transport-input completion is observed first, the existing inbound-close contract applies and accepted plaintext is discarded, matching the legacy actor engine. Legacy TLS behavior remains unchanged. Data not yet accepted when cancellation is observed is outside the drain guarantee, including data delivered afterwards.

Tests

  • Previously reported: all five new regressions failed on the original PR head before the review fixes.
  • Independent recheck on 2026-10-29: OpenJDK 25.0.2, macOS arm64, Scala 2.13.18, original TLS 1.3 TcpSpec case; fresh JVM per repetition using sbt-exported test classpath/options, with forked-JVM engine selection confirmed.
  • Without diagnostics, 20 runs per engine per revision: main a55a008fdf had 0 legacy failures and 8 GraphStage failures (40%); PR 205155160a had 0 legacy failures and 13 GraphStage failures (65%). All failures returned hell instead of hello.
  • Separate diagnostic batches, also 20 per engine per revision: 0 legacy failures on either revision; 11 GraphStage failures on main (55%) and 12 on the PR (60%). Every failing server trace was h/e/l/l -> cancellation -> o; successes accepted hello before cancellation. Thus only hell had been accepted when cancellation was observed, although a late onPush(o) occurred afterwards. Legacy receipt/chop logs contained hello. All diagnostic changes were removed.
  • Workstation load varied and diagnostics affect scheduling; these are sample observations, not evidence that either revision has a higher failure probability. The original TcpSpec assertion and closing policy are retained; follow-up: TcpSpec TLS echo can lose the final byte when cancellation precedes TLS input delivery #3729.
  • Current follow-up checks on JDK 25: TlsGraphStageDeferredCloseSpec passed all 15 tests; sbt docs/paradox, headerCreateAll, +headerCheckAll, and git diff --check passed. Paradox reported duplicate anchors in unchanged release-note pages. An additional sbt invocation of the original GraphStage TcpSpec still failed with hell.
  • Auditable Qoder stdout review of this documentation/correction follow-up: No must-fix findings.
  • Setup attempts outside the measurement matrix: one invalid sbt set expression failed before tests; one preliminary suite aborted because a concurrent build changed version resources. Both were resolved before the clean serial batches.
  • Full PR validation and binary compatibility remain remote CI checks; they were not rerun locally for this documentation-only follow-up.

References

Refs #2860. TcpSpec cancellation/delivery follow-up: #3729. Buffer allocation and session-size growth are addressed separately in #3728.

@He-Pin He-Pin added this to the 2.0.0-M5 milestone Oct 28, 2026
@He-Pin
He-Pin marked this pull request as ready for review October 28, 2026 22:21

@pjfanning pjfanning 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.

This changes the test to avoid what looks like a GraphStage TLS engine bug, rather than fixing the engine.

Pending plaintext is dropped when the user output is cancelled

  • A small SendBytes is not encrypted straight away. UserInputLogic.onPush queues it and schedules UserInputBatchTimer.
  • If the plaintext output is cancelled before that timer fires, doInbound calls engine.closeOutbound() and moves to FlushingOutboundPhase. The queued bytes are never encrypted, so the client receives hell.
  • The legacy engine (TLSActor.doInbound) has the same close-on-cancel logic. It passes because it encrypts each element as soon as it arrives, so nothing is left waiting when the cancel comes in.

This is not documented IgnoreComplete behaviour

The TLSClosing scaladoc only says which termination signal is ignored. It says nothing about discarding plaintext the handler has already emitted. A handler that writes its last bytes and stops reading at about the same moment is a normal server pattern: takeWhile, take, request/response. On the default bindAndHandleWithTls overload, such servers would lose trailing response bytes once graph-stage is enabled or becomes the default.

Suggestion

Fix this in TlsGraphStage. Before calling closeOutbound on user-output cancellation, encrypt and flush any queued user input, for example by cancelling the batch timer and draining userInput first. Keep this test on the 4-argument overload so it guards the fix.

@He-Pin
He-Pin force-pushed the test/tls-tcp-closing-policy branch from ccdef5a to ac631bb Compare October 28, 2026 23:30
@He-Pin He-Pin changed the title Use the server closing policy in the TCP TLS spec Preserve accepted TLS plaintext on output cancellation Oct 28, 2026
@He-Pin

He-Pin commented Oct 28, 2026 •

Copy link
Copy Markdown
Member Author

Agreed: this was a batching regression. 31f6681 fixes the engine and restores the original four-argument test. Accepted current-session data is drained without starting queued renegotiation or waiting for a new handshake. Both public DSL docs now describe this behavior and its limits.

The 257 TLS tests and 10 focused cases on JDK 17/21/25 pass; all four inline threads are resolved.

@He-Pin
He-Pin force-pushed the test/tls-tcp-closing-policy branch from ac631bb to 31f6681 Compare October 28, 2026 23:41
@He-Pin
He-Pin requested review from pjfanning and a balanced review from Copilot October 29, 2026 02:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@He-Pin He-Pin left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Deep review, comparing against TLSActor.

The premise checks out. TLSActor wraps synchronously inside the same pump turn (TLSActor.scala:336-345), whereas TlsGraphStage defers small SendBytes to a zero-delay batch timer (TlsGraphStage.scala:719-726 and 739-750). In TcpSpec's echo case the server's takeWhile(_ != '\n') (TcpSpec.scala:923) cancels the TLS plainOut while 'o' is still sitting in the pending queue, so the base implementation drops it and the client sees hell. Draining already-accepted data before closeOutbound() is the right shape of fix, and reusing InboundClosedPhase instead of adding a new phase is a good call. Truncating at the first non-SendBytes in finishAcceptedInput() correctly implements "do not start a queued renegotiation", and the tests around it are well constructed - the handshakesStarted shouldBe 1 assertion in particular pins the intent down precisely.

Findings: one blocker-level functional gap and three suggestions inline, plus three nits.

Two notes that are not tied to a single line:

  1. The PR description says "Restore the original four-argument bindAndHandleWithTls test", but the diff touches five files and none of them is TcpSpec.scala - the four-argument test at TcpSpec.scala:914-946 is unchanged on main. Please drop or correct that bullet.

  2. Conflict with #3728. The two branches are independent (neither contains the other), both are based on a55a008, and both edit the same lines of the same three files:

    • docs/src/main/paradox/stream/stream-io.md - both insert a different paragraph at the same position.
    • TlsGraphStage.scala - this PR rewrites the doInbound cancel branch, inboundClosedCompleted and phaseExecutable; #3728 rewrites allocateBuffers/doWrap/doUnwrap and the buffer helpers.
    • TlsGraphStageIsolatedSpec.scala - both change ScriptedSSLEngine's constructor and newHarness's signature incompatibly (this PR adds requestHandshakeAfterApplication/closing/extraAttributes; #3728 adds requiredOutputSpace/negotiatedPacketSize/negotiatedApplicationSize), and both append cases to the same "TlsGraphStage deferred close handling" must { block.

    Worth agreeing an order and rebasing the second one.

Verification I ran locally with sbt "stream-tests/Test/testOnly org.apache.pekko.stream.io.TlsGraphStageDeferredCloseSpec":

  • this PR's head, unmodified: all 10 tests pass (7 new + 3 pre-existing);
  • this PR's head plus the 2 probe tests quoted inline: succeeded 10, failed 2;
  • main plus the IgnoreCancel probe: succeeded 3, failed 1, confirming that one is pre-existing.

if (!isOutboundClosed && closing.ignoreCancel) {
nextPhase(InboundClosedPhase)
true
} else if (!isOutboundClosed && canDrainAcceptedUserInput &&

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The drain can be preempted by the transportInput.isDepleted && transportInput.isEmpty branch at the top of doInbound (line 314), which is evaluated before this branch and before the inboundHalfClosedMode && cancelledHandshakeReady branch below it.

Once we are in InboundClosedPhase, if the peer's transport input completes while the accepted batch is still waiting for cipherOut demand, that first branch fires: engine.closeInbound() -> completeOrFlush() -> FlushingOutboundPhase/CompletedPhase, and the retained SendBytes are then dropped by pumpFinished() -> userInput.cancel() -> clearPending().

Reproduced against this PR's head with a probe added to TlsGraphStageDeferredCloseSpec:

val harness = newHarness()
finishInitialHandshake(harness)
harness.plainIn.sendNext(SendBytes(ByteString("a")))
harness.plainIn.expectRequest()
harness.plainOut.cancel()
harness.plainIn.expectCancellation()
harness.cipherIn.sendComplete()   // peer half-closes during the drain
harness.cipherOut.request(1)
harness.cipherOut.expectNext(ApplicationCiphertext)
[info] - must PROBE keep draining accepted plaintext when the transport input completes *** FAILED *** (82 milliseconds)
[info]   java.lang.AssertionError: assertion failed: expected OnNext(ByteString(90)), found OnComplete

This is not a regression (main drops the data too, and so does TLSActor), but it defeats the stated goal of the PR in a realistic ordering: a client that sends a request and then half-closes its write side, or a server whose cipherIn FIN is processed just before the handler cancels plainOut. TcpSpec's echo case only passes because Source.maybe keeps the client's plaintext input open forever.

Suggested direction: while canDrainAcceptedUserInput and user data is still queued, do the inbound close after the outbound drain - e.g. record "transport depleted" and stay in InboundClosedPhase so doOutbound can wrap the retained batch first, instead of jumping straight to FlushingOutboundPhase/CompletedPhase. Please add a regression test for this ordering either way.


private def inboundClosedCompleted: Boolean =
outboundCompleted && inboundHalfClosedCompleted
private def inboundClosedCompleted: Boolean = (!closing.ignoreCancel && transportOutput.isCancelled) ||

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This new escape hatch is guarded by !closing.ignoreCancel, so it only fixes non-termination for EagerClose/IgnoreComplete. The identical hang still exists for IgnoreCancel and IgnoreBoth.

With closing = IgnoreCancel, cancelling plainOut takes the first branch of doInbound -> InboundClosedPhase. If cipherOut is then cancelled while cipherIn stays open and silent: outboundReady is false (transportOutput.isDemandAvailable is false once cancelled), inboundHalfClosedReady is false (no transport data), and cancelledHandshakeReady is false because closing.ignoreCancel is true - so the phase is not executable. And inboundClosedCompleted stays false because inboundHalfClosedCompleted needs transportInput.isCompleted || engine.isInboundDone. pumpFinished() is never reached and the stage lives forever.

Reproduced on both main and this PR's head, so it is pre-existing rather than introduced here:

val harness = newHarness(closing = IgnoreCancel)
finishInitialHandshake(harness)
harness.plainOut.cancel()
harness.cipherOut.cancel()
harness.cipherIn.expectCancellation()   // never arrives
main   : [info] Tests: succeeded 3, failed 1
pr3727 : [info] Tests: succeeded 10, failed 2
[info] - must PROBE terminate when the transport output is cancelled under IgnoreCancel *** FAILED *** (3 seconds, 190 milliseconds)
[info]   java.lang.AssertionError: assertion failed: timeout (3 seconds) during fishForSpecificMessage, hint: Expecting cancellation

Since this line is already being touched, consider dropping the guard and widening to isClosed (which also covers the errored/completed flags):

private def inboundClosedCompleted: Boolean = transportOutput.isClosed ||
  (outboundCompleted && inboundHalfClosedCompleted)

If cipherOut is gone there is nowhere left to write, so terminating is correct regardless of the closing policy. Happy for this to be a separate PR if you would rather keep this one scoped.

cancelPendingBatchTimer()
if (!logic.isClosed(inlet)) logic.cancel(inlet)
// AfterDelay cancellation leaves the inlet open temporarily; completion must not abort the drain.
setHandler(inlet, ignoreTerminateInput)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

ignoreTerminateInput ignores onPush, onUpstreamFinish and onUpstreamFailure.

Ignoring completion is the point of the change, but ignoring failure is a behaviour change: today UserInputSlot.onUpstreamFailure routes to failTls(ex), which fails cipherOut and aborts the connection. With CancellationStrategy.AfterDelay the inlet is genuinely still open here - GraphStageLogic.cancel installs EagerTerminateInput precisely so that termination signals are not ignored (see the do not ignore termination signals comment at GraphStage.scala:777-781), and this line replaces it. The net effect is that an upstream failure arriving during the drain is silently converted into a clean close_notify.

Suggest a small private handler that ignores onPush/onUpstreamFinish but still calls failTls on onUpstreamFailure.

* secure connection without regard to whether bytes are remaining to be sent or
* received, respectively. Especially for a client the common idiom of attaching
* secure connection, subject to the closing policy described below. Pending bytes
* may be discarded. When cancellation closes the connection, the `legacy-actor`

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Two problems with this wording (the same text is added to javadsl/TLS.scala):

  1. Pending bytes may be discarded. is immediately contradicted by the following sentences, which say the graph-stage engine does send already-accepted data. As written, a reader cannot tell what actually happens.
  2. The drain bound is not stated. finishAcceptedInput() stops new input but drains everything already accepted, which is up to UserInputQueueSize = 64 queued messages (TlsGraphStage.scala:1081) plus whatever is in userInBuffer, each of arbitrary size. The paragraph further down - "In order to terminate the connection the client will then need to cancel the plaintext output as soon as all expected bytes have been received" - now implies termination can be delayed by that drain, which is worth saying explicitly.

Also, graph-stage is not the default (reference.conf: pekko.stream.materializer.tls.engine = "legacy-actor"), and nothing here tells the reader which engine they are on or where to look. Consider naming the config key, the way the companion paragraph added in #3728 does.

Suggested shape: state the guarantee once ("bytes that have not been accepted by the operator may be discarded"), then the engine-specific drain behaviour and its bound, then point at pekko.stream.materializer.tls.engine.

}
}

private def cancelledHandshakeReady: Boolean =

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Nit: cancelledHandshakeReady reads as "a cancelled handshake is ready", but it actually means "cancellation must close outbound now because draining is not possible". Something like mustCloseOutboundOnCancel would make both call sites (phaseExecutable and doInbound) self-explanatory.

cipherIn ~> tls.in2
tls.out2 ~> plainOut
// Keep the subscriber boundary's cancellation independent of the TLS stage's attributes.
tls.out2 ~> Flow[SslTlsInbound].map(identity) ~> plainOut

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This map(identity) is only needed for the AfterDelay case, so that the TestSink's own cancellation is not subject to the TLS stage's attributes - but it is now inserted into every harness, which changes the topology of the three pre-existing tests as well. Consider making it conditional on extraAttributes, or extending the comment to say that it is deliberate for all cases.


The `SSLEngine` instance can then be used with the binding or outgoing connection factory methods.

When the closing policy makes plaintext-output cancellation close the connection, the GraphStage TLS

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Two notes:

  • This section is about constructing the SSLEngine; closing-policy semantics would sit better next to the TLSClosing / TLS() documentation, and the paragraph never mentions pekko.stream.materializer.tls.engine, so a reader cannot tell whether it applies to them.
  • Fix TLS GraphStage buffer allocation and growth #3728 inserts a different paragraph at this exact position, so whichever PR merges second will conflict here and both paragraphs need to be kept.

He-Pin added a commit to He-Pin/incubator-pekko that referenced this pull request Oct 29, 2026
Motivation:
Transport half-close could preempt draining accepted plaintext, output
cancellation could hang with IgnoreCancel/IgnoreBoth, and delayed input
cancellation could swallow upstream failure.

Modification:
Defer inbound close until the accepted batch drains, suppress pump readiness
while it awaits transport demand, and terminate when the transport output
closes for any policy. Preserve upstream failures during delayed cancellation.
Rename the cancellation predicate, limit the harness topology change to
custom attributes, and align Scala/Java docs with the drain bound and engine key.

Result:
Accepted plaintext survives peer transport completion, cancelled outputs
terminate, and delayed upstream failure still fails the transport.

Tests:
- Five new regression cases failed against the original PR implementation.
- JDK 25: all 15 focused close/cancellation tests passed.
- Native scalafmt and git diff --check passed.
- Qoder stdout review: No must-fix findings after resolving TCP control concern.
- Existing TcpSpec pre-acceptance race reproduces with origin/main TLS.
- Full validation, docs, style, and binary compatibility run in remote CI.

References:
Refs apache#3727, Refs apache#2860.

@pjfanning pjfanning 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

He-Pin added a commit to He-Pin/incubator-pekko that referenced this pull request Oct 29, 2026
Motivation:
The TLS closing-policy guide needs to distinguish cancellation observed before
peer transport completion from the existing inbound-close contract.

Modification:
Add the ordering qualification to the TLS closing policies section.
Keep production behavior and existing test assertions unchanged.

Result:
The guide explains that the GraphStage drain covers cancellation observed first,
while transport completion observed first retains the legacy discard contract.

Tests:
- sbt docs/paradox: passed; duplicate-anchor warnings in unchanged release notes.
- TlsGraphStageDeferredCloseSpec: 15 passed on OpenJDK 25.0.2.
- headerCreateAll and +headerCheckAll: passed.
- git diff --check: passed.
- Original graph-stage TcpSpec still intermittently returns hell; investigated
  separately with 20 baseline and 20 diagnostic repetitions per engine/revision.
- Auditable Qoder stdout review: No must-fix findings.
- validatePullRequest and MiMa: Not run - docs only; full validation is reserved
  for remote CI as requested.

References:
Refs apache#2860 and apache#3727.
@He-Pin

He-Pin commented Oct 29, 2026

Copy link
Copy Markdown
Member Author

Motivation

This corrects the 2026-10-28 23:35:00Z comment, including its reference to 31f6681c00.

Modification

The rechecked PR code revision is 205155160a, superseding 31f6681c00. The PR does not modify or restore TcpSpec.scala; its original four-argument call, closing policy, and hello assertion remain unchanged.

Repeated tests also show that the echo failure is not evidence that the final byte was already accepted when cancellation was observed. Every failing diagnostic run on both main and this head logged h/e/l/l, then plaintext-output cancellation, then a late onPush(o). The PR description's claim that only h/e/l/l was accepted must therefore be limited to before cancellation.

Result

The accepted-plaintext drain fix remains valuable, but this PR does not fix the intermittent original TcpSpec failure. Track that cancellation-versus-delivery race separately in #3729; the precise scheduling boundary responsible for the ordering remains unlocalized.

The peer-completion claim is also limited to cancellation being observed first. Peer transport-input completion observed first retains the existing discard contract, matching the legacy actor engine.

Tests

  • OpenJDK 25.0.2, macOS arm64, Scala 2.13.18, original TLS 1.3 case, fresh JVM per repetition using sbt-exported classpath/options; forked-JVM engine selection was confirmed.
  • Without diagnostic logging: main a55a008fdf had 0/20 legacy failures and 8/20 GraphStage failures; PR 205155160a had 0/20 legacy failures and 13/20 GraphStage failures. All failures returned hell instead of hello.
  • With local-only diagnostics: main had 0/20 legacy failures and 11/20 GraphStage failures; PR had 0/20 legacy failures and 12/20 GraphStage failures. Every failing GraphStage trace had hell before cancellation and o afterwards; legacy receipt/chop traces were hello.
  • Counts are observations under variable workstation load, not a comparison of failure probabilities. Diagnostics were removed.

References

Refs #2860 and #3729. This correction concerns #3727; #3728 remains the separate buffer change.

He-Pin added a commit to He-Pin/incubator-pekko that referenced this pull request Oct 29, 2026
Motivation:
Sender-side boundary batching can send plaintext-output cancellation before
plaintext that the TLS handler has already delivered to its output boundary.

Modification:
Opt TLS input boundaries out of sender-side batching using an internal
attribute, retaining asynchronous isolation and existing input-buffer sizes.
Add deterministic TLS 1.2 and TLS 1.3 regressions and document the scope.

Result:
Delivered plaintext reaches the TLS boundary before cancellation. Preserve
the original TCP assertion, closing policy, and TLS engine close semantics.

Tests:
- Regression: 0/2 before, 2/2 after; related TLS 252/252 and fusing 37/37.
- Original TCP TLS test: 20/20 per engine, plus one sbt run per engine.
- Integration with apache#3727/apache#3728: deferred close 15/15, TLS 252/252,
  another 20/20 TCP tests per engine.
- headerCreateAll, +headerCheckAll, docs/paradox, native scalafmt check,
  and git diff --check passed.
- Full validatePullRequest and cross-project MiMa reserved for remote CI
  per user instruction; CI not followed.

References:
Fixes apache#3729
He-Pin added a commit to He-Pin/incubator-pekko that referenced this pull request Oct 29, 2026
Motivation:
Sender-side boundary batching could deliver plaintext-output cancellation
before plaintext already delivered by the same handler. TLS could also close
outbound before encrypting plaintext accepted into its deferred batch.

Modification:
- Opt TLS input boundaries out of sender-side batching while retaining
  asynchronous isolation and the existing input-buffer sizes.
- Stop accepting input and drain the established session's accepted data with
  backpressure. Preserve transport-close ordering, failures during delayed
  cancellation, and prompt termination when a new handshake would be required.
- Add deterministic ordering and deferred-close regressions, document drain
  limits in both DSLs and the guide, and correct the IgnoreCancel API reference.

Result:
Boundary messages sent by a handler precede its subsequent cancellation.
Accepted plaintext drains when cancellation is observed before peer transport
completion, subject to the documented limits. Preserve the peer-completes-first
discard contract, original TCP hello assertion, and default closing policy.

Tests:
- Unchanged production/test tree from bae8200: deferred-close and
  cancellation suites 17/17; original GraphStage TCP TLS case 1/1.
- Ordering regression on main: 0/2 before the fix, 2/2 afterwards; related TLS
  suites 252/252, fusing/interpreter suites 37/37, TCP TLS 20/20 per engine.
- Temporary apache#3727/apache#3728 integration: all 15 deferred-close cases and related
  TLS suites passed; an additional 20/20 TCP TLS runs per engine passed.
- sbt docs/paradox and git diff --check passed after the documentation fix.
- Existing headerCreateAll, +headerCheckAll, and native scalafmt checks passed;
  no Scala code, test, imports, or headers changed during squash/review.
- Auditable Qoder stdout review and independent review: no must-fix findings.
- Full validatePullRequest and cross-project MiMa left to remote validation
  per user instruction; CI results were not followed or claimed to pass.

References:
Fixes apache#3729. Refs apache#2860.
@He-Pin
He-Pin force-pushed the test/tls-tcp-closing-policy branch from bae8200 to 5c42a2d Compare October 29, 2026 12:21
Motivation:
Sender-side boundary batching could deliver plaintext-output cancellation
before plaintext already delivered by the same handler. TLS could also close
outbound before encrypting plaintext accepted into its deferred batch.

Modification:
- Opt TLS input boundaries out of sender-side batching while retaining
  asynchronous isolation and the existing input-buffer sizes.
- Stop accepting input and drain the established session's accepted data with
  backpressure. Preserve transport-close ordering, failures during delayed
  cancellation, and prompt termination when a new handshake would be required.
- Add deterministic ordering, deferred-close, and partially sent large-message
  regressions. Document drain limits in both DSLs and the guide, including the
  correct IgnoreCancel API reference.

Result:
Boundary messages sent by a handler precede its subsequent cancellation.
Accepted plaintext drains when cancellation is observed before peer transport
completion, subject to the documented limits. Preserve the peer-completes-first
discard contract, original TCP hello assertion, and default closing policy.

Tests:
- Large-message cancellation regression for IgnoreComplete and EagerClose:
  0/2 passed with the original main TlsGraphStage implementation, 2/2 with
  the current implementation. Deferred-close and cancellation suites: 19/19.
- Previously recorded ordering regression on main: 0/2 before, 2/2 after;
  related TLS suites 252/252, fusing/interpreter suites 37/37, TCP TLS 20/20
  per engine. The combined PR's original GraphStage TCP TLS case passed 1/1.
- Temporary apache#3727/apache#3728 integration: all 15 original deferred-close cases
  and related TLS suites passed; another 20/20 TCP TLS runs per engine passed.
- Native scalafmt and git diff --check passed for the new tests. Production
  code is unchanged by this test-only follow-up.
- Existing docs/paradox, headerCreateAll and +headerCheckAll checks passed;
  documentation, imports, source-file headers, and public API are unchanged.
- Auditable Qoder stdout review and independent review: no remaining must-fix
  findings after checking the test against the implementation and API definitions.
- Full validatePullRequest and cross-project MiMa left to remote validation
  per user instruction; CI results were not followed or claimed to pass.

References:
Fixes apache#3729. Refs apache#2860.
@He-Pin
He-Pin force-pushed the test/tls-tcp-closing-policy branch from 5c42a2d to 5a887c7 Compare October 29, 2026 12:42
@He-Pin
He-Pin requested a balanced review from Copilot October 29, 2026 13:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@He-Pin
He-Pin merged commit 5302e4b into apache:main Oct 29, 2026
13 of 16 checks passed
@He-Pin
He-Pin deleted the test/tls-tcp-closing-policy branch October 29, 2026 14:16
He-Pin added a commit to He-Pin/incubator-pekko that referenced this pull request Oct 29, 2026
Motivation:
Small TCP pool buffers prevent TLS initialization and cannot satisfy output
growth. Deferred ciphertext, session changes and heap allocation also need
safe capacity and ownership handling.

Modification:
Use stage-owned heap buffers when the TCP pool is too small, sharing one
initial allocation between three disjoint transport slices. Flush before
growing, refresh session requirements and batching, and shrink drained output
after a handshake lowers its capacity requirement. Validate pooled capacity
and recycling. Rebase on main while preserving cancellation/draining coverage,
add review and integration regressions, and document allocation tradeoffs.

Result:
TLS progresses with small TCP buffers without losing ciphertext or polluting
the shared pool. Transient session capacity can be reclaimed without repeated
growth, and initialization uses one backing heap allocation instead of three.

Tests:
- Two new review regressions failed before the fix and passed afterwards.
- JDK 21: 310 related TLS/TCP tests passed, including 28 focused cases.
- JDK 17 and 25: 28 focused cases passed on each runtime via ScalaTest.
- Native scalafmt, headerCreateAll, +headerCheckAll and git diff --check passed.
- Qoder stdout review and independent review: No must-fix findings.
- Full local MiMa stopped at user request; full validation, docs, import/style
  and binary compatibility checks are delegated to remote CI.

References:
Refs apache#2860, Refs apache#3727, Refs apache#3728.
He-Pin added a commit to He-Pin/incubator-pekko that referenced this pull request Oct 29, 2026
Motivation:
TLS GraphStage cannot initialize when the TCP pool is smaller than the
SSLEngine packet requirement, and fixed-size pooled buffers cannot satisfy
growth. The partial-record regression test also races handshake processing.

Modification:
Use stage-owned heap buffers when needed, sharing the initial allocation
between three disjoint transport slices. Flush before growing, preserve
pending data, refresh session requirements and batching, and shrink drained
output after renegotiation. Keep foreign buffers out of the TCP pool.
Add buffer and cancellation regressions and document allocation behavior.
Synchronize the test harness with session verification and check query
counts after cancellation acknowledges completion of unwrap processing.

Result:
Small TCP buffers no longer prevent TLS progress. Buffer growth and recycling
preserve data and ownership, and the partial-record test checks session
query behavior without racing the stage.

Tests:
- JDK 17: 312 TLS/TCP tests passed, including 28 focused buffer/close cases.
- Negative control: unconditional underflow session queries fail with 3 != 2;
  the temporary mutation was restored.
- Native scalafmt, headerCreateAll, checkCodeStyle and git diff --check passed.
- +mimaReportBinaryIssues passed on Scala 2.13.18 and 3.3.9.
- sortImports encountered a Scalafix/Scalameta NoSuchMethodError; its unrelated
  edits were reverted and the native formatter check passed.
- docs/paradox stopped at user request; validatePullRequest and the remaining
  Scala test matrix are delegated to remote CI.

References:
Refs apache#2860, Refs apache#3727, Refs apache#3728.
He-Pin added a commit that referenced this pull request Oct 29, 2026
Motivation:
TLS GraphStage cannot initialize when the TCP pool is smaller than the
SSLEngine packet requirement, and fixed-size pooled buffers cannot satisfy
growth. The partial-record regression test also races handshake processing.

Modification:
Use stage-owned heap buffers when needed, sharing the initial allocation
between three disjoint transport slices. Flush before growing, preserve
pending data, refresh session requirements and batching, and shrink drained
output after renegotiation. Keep foreign buffers out of the TCP pool.
Add buffer and cancellation regressions and document allocation behavior.
Synchronize the test harness with session verification and check query
counts after cancellation acknowledges completion of unwrap processing.

Result:
Small TCP buffers no longer prevent TLS progress. Buffer growth and recycling
preserve data and ownership, and the partial-record test checks session
query behavior without racing the stage.

Tests:
- JDK 17: 312 TLS/TCP tests passed, including 28 focused buffer/close cases.
- Negative control: unconditional underflow session queries fail with 3 != 2;
  the temporary mutation was restored.
- Native scalafmt, headerCreateAll, checkCodeStyle and git diff --check passed.
- +mimaReportBinaryIssues passed on Scala 2.13.18 and 3.3.9.
- sortImports encountered a Scalafix/Scalameta NoSuchMethodError; its unrelated
  edits were reverted and the native formatter check passed.
- docs/paradox stopped at user request; validatePullRequest and the remaining
  Scala test matrix are delegated to remote CI.

References:
Refs #2860, Refs #3727, Refs #3728.
He-Pin pushed a commit that referenced this pull request Oct 30, 2026
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 #3727
He-Pin pushed a commit that referenced this pull request Oct 30, 2026
* Keep draining TLS plaintext after a TLS 1.3 KeyUpdate

Motivation:
On plaintext-output cancellation the GraphStage TLS engine drains plaintext it
has already accepted, but stops if a new handshake is required. It treated any
NEED_WRAP as a new handshake, so a TLS 1.3 KeyUpdate that requests a response,
received before or during the drain, closed outbound and discarded the
accepted plaintext.

Modification:
- Allow the drain to continue when an established TLS 1.3 session needs to
  wrap. TLS 1.3 has no renegotiation, so this is a post-handshake message such
  as a KeyUpdate response; the response is wrapped before the plaintext.
- TLS 1.2 keeps stopping the drain, because NEED_WRAP may start a
  renegotiation.
- Document the KeyUpdate case in the stream IO guide and both TLS DSLs.

Result:
A TLS 1.3 KeyUpdate no longer discards plaintext accepted before
plaintext-output cancellation.

Tests:
- stream-tests/testOnly TlsGraphStageDeferredCloseSpec: 25 passed; the two new
  TLS 1.3 KeyUpdate tests fail without the fix (ByteString() was not equal to
  ByteString(92, 90)), the TLS 1.2 guard passes both ways
- stream-tests/testOnly org.apache.pekko.stream.io.Tls*: 288 passed
- scalafmt --mode diff-ref=upstream/main: clean

References:
Refs #3727

* Match SunJSSE's FINISHED status for scripted KeyUpdate wraps

Motivation:
Review noted the KeyUpdate tests rely on a scripted engine. A real
SunJSSE TLS 1.3 KeyUpdate (forced with a low jdk.tls.keyLimits) reports
NEED_WRAP after unwrap, and the response wrap returns FINISHED, not
NOT_HANDSHAKING as scripted. FINISHED re-runs session verification.

Modification:
Return FINISHED from the scripted KeyUpdate response wrap, leaving the
engine in NOT_HANDSHAKING afterwards, like SunJSSE.

Result:
The drain tests now cover the FINISHED path a real KeyUpdate takes.

Tests:
- stream-tests/testOnly org.apache.pekko.stream.io.TlsGraphStage*:
  173 passed.
- With the fix disabled, both TLS 1.3 KeyUpdate tests fail.
- Standalone JDK 17 SunJSSE engine pair with low jdk.tls.keyLimits
  confirmed the NEED_WRAP then FINISHED sequence on TLSv1.3.

References:
Review of #3738
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.

3 participants