Skip to content

TLS stage silently discards received inbound data when the transport output is cancelled #3739

Closed as not planned

Description

@He-Pin

Motivation

Recent work hardened cancellation behaviour on the plaintext-output side of the TLS operators (#3727, #3731, #3738). While auditing cancellation symmetry across the TLS stage's four teardown paths, I found a gap in the opposite direction: when the transport (ciphertext) output is cancelled while inbound records have already been received but not yet decrypted, both TLS engines discard that data silently.

The other three teardown paths all have an observable contract:

Only "transport output cancelled while inbound data awaits plaintext demand" ends in a clean onComplete with the received data dropped and no signal at all.

Scenario

Observed on main (2.0.0-M4+ snapshot), both pekko.stream.materializer.tls.engine = legacy-actor and graph-stage.

  1. A TLS client stage with an established session. The peer sends an application data record (e.g. a final response); the client's transport input receives the ciphertext.
  2. The client's plaintext output has no outstanding demand at that moment (the consumer has not requested yet).
  3. The transport output is then cancelled while the read side stays healthy (e.g. a kill switch on the send path, or an application that deliberately stops sending but still expects to read the response).

Observed

  • The stage completes immediately; the plaintext output receives onComplete with no SessionBytes.
  • The already-received record is dropped undecrypted, and no SessionTruncated is emitted, so the application cannot tell that data received at the record layer was thrown away.
  • Identical behaviour with both engines.
  • If plaintext demand existed before the cancellation, the data is delivered normally, so the loss is specific to "cancellation while inbound data awaits demand".

Internal notes

  • Graph stage: inbound decryption is gated on plaintext-output demand (inboundReady requires userOutput.isDemandAvailable in TlsGraphStage). A cancelled transport output makes outboundCompleted (and thus bidirectionalCompleted) true immediately, so the pump skips every phase and reaches pumpFinished, which cancels the transport input queue — the inbound close path never runs, hence no closeInbound, no SessionTruncated.
  • Legacy actor: same demand gating in the inbound transfer state (demandAvailableFor(UserOut)); a cancelled transport output jumps straight to the completed phase (TLSActor.doOutbound).

Expected

Either of the following, plus a sentence in the stream IO guide (the current text only covers the plaintext-output-cancellation/drain direction):

  1. Deliver already-received inbound records to the plaintext output as demand arrives before completing — the read side is independent of the cancelled write side; or
  2. At minimum, run the inbound close path so SessionTruncated is emitted before completion, making the loss observable.

Option 2 is a small, low-risk change (signal only, no completion-timing change). Option 1 mirrors the outbound drain philosophy but needs a decision on how long the stage may wait for plaintext demand, similar to the trade-off already accepted and documented for the outbound drain.

References

Refs #3727, #3731, #3738

Activity

  1. added this to the 2.0.0-M5 milestone on Oct 30, 2026
  2. assigned and unassigned on Oct 30, 2026
  3. modified the milestones: 2.0.0-M5, Unplanned on Oct 30, 2026
  4. He-Pin commented on Oct 30, 2026

    @He-Pin
    MemberAuthor

    I re-checked this on main (f785e2f) with probe-level tests against both engines. The scenario as described does not reproduce, so I am closing this. The reasoning is below so it stays auditable, and the related defect I did find on the same teardown path is fixed in #3740.

    What I measured

    Harness: the operator under test with all four ports on test probes, driven by a plain SSLEngine peer. The session is established first, then the peer sends an application record while the plaintext output has no outstanding demand, then the transport (ciphertext) output is cancelled.

    engine protocol closing completes before plaintext demand? record delivered once demand arrives?
    graph-stage TLSv1.3 IgnoreComplete no yes, SessionBytes("response")
    graph-stage TLSv1.3 EagerClose no yes
    legacy-actor TLSv1.3 IgnoreComplete no yes
    legacy-actor TLSv1.3 EagerClose no yes
    graph-stage TLSv1.2 IgnoreComplete no yes
    legacy-actor TLSv1.2 IgnoreComplete no yes

    All six then complete normally and cancel the transport input.

    Where the internal note goes wrong

    A cancelled transport output makes outboundCompleted (and thus bidirectionalCompleted) true immediately, so the pump skips every phase and reaches pumpFinished, which cancels the transport input queue

    bidirectionalCompleted is outboundCompleted && inboundCompleted. A cancelled transport output does make outboundCompleted true, but inboundCompleted additionally requires (transportInput.isCompleted || userOutput.isClosed) && (engine.isInboundDone || userOutput.isErrored). With the transport input still open and the engine's inbound side not done, that is false, so the bidirectional phase is neither executable nor completed: the pump idles instead of reaching pumpFinished, and the transport input queue is not cancelled. When plaintext demand arrives, inboundReady becomes true, the record is unwrapped and pushed, and only then does doOutbound observe the cancelled transport output and move to CompletedPhase.

    The legacy actor behaves the same way: inbound's isCompleted needs userOutCancelled.isCompleted, i.e. engine.isInboundDone || outputBunch.isErrored(UserOut), so bidirectional does not complete on a transport output cancellation either.

    Steps 1 and 2 of the scenario also cannot both hold here. Inbound decryption is gated on plaintext output demand (inboundReady requires userOutput.isDemandAvailable; the actor requires demandAvailableFor(UserOut)), so a session can only be established if the plaintext output had demand at some point. With a plaintext output that never requested anything, the handshake itself never progresses — I hit exactly that while building the harness.

    The related defect that is real

    Auditing the same teardown path did turn up a genuine loss, narrower than the one described: when two or more inbound transport elements are buffered while the plaintext output has no demand, cancelling the transport output delivers only the first and discards the rest with a clean onComplete and no SessionTruncated. Reproduced on both engines and both protocols. The control, with the transport output left open, delivers both records.

    Cause: doOutbound moves straight to the completed phase on a cancelled transport output, so the pump performs at most one inbound round before pumpFinished() cancels the transport input.

    #3740 fixes that by reporting SessionTruncated on this path in both engines, with a directional spec where 14 of 18 cases fail without the production change.

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

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions