Skip to content

TLS send handling conflates retryable and terminal failures #174

Description

@kentbull

Problem

Established ClientTls and RemoterTls sessions classify send failures through a broad OSError branch that mixes integer error values with exception classes:

except OSError as ex:
    if ex.args[0] in (
        ssl.SSL_ERROR_WANT_READ,
        ssl.SSL_ERROR_WANT_WRITE,
    ):
        result = 0
    elif ex.args[0] in (..., ssl.SSLEOFError):
        self.cutoff = True
        result = 0

This obscures whether an operation should be retried, only transmission has failed, or the complete TLS transport is unusable. It also leaves callers without the original failure, permits later writes to enter a dead queue, and can strand accepted plaintext without an observable cause.

This issue concerns established-session data-plane I/O. Authenticated peer closure and abrupt receive EOF are tracked by #173.

Required outcome model

Send outcome Retry Receive Transmit Queued plaintext Cause
SSLWantReadError / SSLWantWriteError Yes Open Open Preserve None
EAGAIN / EWOULDBLOCK Yes Open Open Preserve None
BrokenPipeError No Remains available Closed Preserve Retain
SSLEOFError / SSLSyscallError No Closed Closed Preserve Retain and raise
Connection-wide socket failure No Closed Closed Preserve Retain and raise
Other terminal send failure No Available unless known otherwise Closed Preserve Retain and raise

A TLS write may require the underlying socket to become readable, and a TLS read may require it to become writable. WANT_READ and WANT_WRITE are therefore recurrence signals, not terminal outcomes. The same plaintext operation must be retried later without deleting or duplicating application data.

A broken write is directional: it prevents further transmission but does not prove that final peer input is unavailable. In contrast, TLS protocol failure, connection reset, network reset, timeout, and equivalent connection-wide failures make both directions unusable.

Reproduction

  • Create an established ClientTls or RemoterTls with queued output.
  • Make the wrapped socket raise BrokenPipeError from send().
  • Service sends.
  • Observe that current main raises without durable transmit state or a retained cause.

Repeat with typed WANT exceptions, EAGAIN, fatal SSL exceptions, and a representative connection-wide socket error. Each result should match the table above for both endpoint roles.

Proposed direction

Classify typed TLS exceptions before generic socket failures:

try:
    count = self.cs.send(data)
except (ssl.SSLWantReadError, ssl.SSLWantWriteError):
    count = 0
except BrokenPipeError as ex:
    self.txCutoff = True
    self.error = ex
    count = 0
except (ssl.SSLEOFError, ssl.SSLSyscallError) as ex:
    self.cutoff = self.txCutoff = True
    self.error = ex
    self.close()
    raise
except OSError as ex:
    if ex.errno in (errno.EAGAIN, errno.EWOULDBLOCK):
        count = 0
    elif is_connection_wide(ex):
        self.cutoff = self.txCutoff = True
        self.error = ex
        self.close()
        raise
    else:
        self.txCutoff = True
        self.error = ex
        raise

Send service must delete only bytes accepted by the TLS socket. WANT and terminal failures therefore leave every unsent byte in txbs. Later tx() calls after transmit closure must follow #172's observable rejection contract.

State lifecycle

Terminal state and its retained cause belong to one socket generation. Opening a newly allocated connection must clear them; retrying the same failed socket must not. The endpoint must also preserve the first terminal cause rather than replacing it with a later late-enqueue error, because that first exception explains why transmission became impossible.

Related work

Acceptance criteria

  • Both TLS endpoint roles use typed exception handling for send and receive WANT outcomes.
  • WANT and blocked socket operations remain retryable without state or queue mutation.
  • Broken pipe closes only transmit and preserves receive service.
  • Fatal TLS and connection-wide failures close both directions and force-close.
  • Every terminal outcome retains its original cause and every unsent byte.
  • Later output is rejected after transmit closure.
  • Deterministic coverage exercises the complete outcome table for both endpoint roles.

Activity

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

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions