Skip to content

TcpSpec TLS echo can lose the final byte when cancellation precedes TLS input delivery #3729

Closed

Description

@He-Pin

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.

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