Repository navigation
TcpSpec TLS echo can lose the final byte when cancellation precedes TLS input delivery #3729
Description
Motivation
The unchanged TcpSpec case "TLS client and server convenience methods with SSLEngine setup should allow for TLS" can return hell instead of hello with graph-stage selected. Its server echoes characters through takeWhile(_ != '\n') and the four-argument bindAndHandleWithTls overload uses the default closing policy.
Modification
Investigate the ordering between handler cancellation and delivery of the last echoed byte to TLS. Preserve the complete-response assertion and establish the intended test contract with a focused, deterministic regression. The existing tests and policies were not changed during this investigation.
Reproduce on OpenJDK 25.0.2:
rtk sbt 'show stream-tests/Test/fork'
rtk sbt \
'set (LocalProject("stream-tests") / Test / javaOptions) += "-Dpekko.stream.materializer.tls.engine=graph-stage"' \
'stream-tests/Test/testOnly org.apache.pekko.stream.io.TcpSpec -- -z "allow for TLS"'Repeat because the failure is intermittent. For the default engine, omit the set command.
Result
Expected by the existing assertion: hello. Observed on failure: hell (TcpSpec.scala:946).
Temporary server-side logging after UserInputSlot.onPush enqueues an element, and at plaintext-output cancellation, showed this order in every diagnosed failure on both revisions:
accept h
accept e
accept l
accept l
cancel plaintext output
accept o
client result: hell
Thus o had not been accepted when cancellation was observed. It subsequently reached onPush; the trace does not identify the exact upstream buffer involved. This is separate from #3727's guarantee for plaintext already accepted at cancellation, and reproducing it on #3727 does not invalidate that fix.
Tests
2026-10-29, macOS arm64 (12 CPUs), OpenJDK 25.0.2, Scala 2.13.18, TLS 1.3:
| Revision | Engine | Runs without diagnostics | Failures | Diagnostic runs | Diagnostic failures |
|---|---|---|---|---|---|
a55a008fdf |
legacy-actor | 20 | 0 (0%) | 20 | 0 (0%) |
a55a008fdf |
graph-stage | 20 | 8 (40%) | 20 | 11 (55%) |
205155160a |
legacy-actor | 20 | 0 (0%) | 20 | 0 (0%) |
205155160a |
graph-stage | 20 | 13 (65%) | 20 | 12 (60%) |
Repeated measurements used the unchanged ScalaTest case in fresh JVMs with sbt-exported classpath and test JVM options, after confirming fork = true. GraphStage was selected explicitly with -Dpekko.stream.materializer.tls.engine=graph-stage. A direct sbt run on main also reproduced hell.
All diagnosed GraphStage successes accepted hello before cancellation. Legacy receipt and ChoppingBlock.chopInto logs contained hello. The diagnostic modifications were removed and are not proposed for commit.
The workstation was shared, with no synthetic stress workload introduced. One-minute load ranges were 5.76–6.72 for the main baseline, 19.40–34.44 for the PR baseline, 6.88–8.38 for main diagnostics, and 7.85–9.26 for PR diagnostics. The counts do not establish relative failure probabilities or results on other JDKs.
References
Refs #2860 and #3727. This follow-up concerns the TcpSpec cancellation/delivery contract, not #3728's buffer allocation/growth work.
Activity
Preserve TLS boundary input ordering on cancellation apache#3729
Preserve TLS plaintext ordering and drain on cancellation apache#3729
Preserve TLS plaintext ordering and drain on cancellation apache#3729
Preserve TLS plaintext ordering and drain on cancellation apache#3729 (…