Skip to content

TLS endpoints lack a recurrent close_notify shutdown path #176

Description

@kentbull

Problem

TLS endpoints currently inherit a close path that reaches the wrapped socket's raw shutdown() and then closes it. That can discard queued plaintext and bypass the authenticated TLS shutdown operation exposed by SSLSocket.unwrap().

On a nonblocking socket, unwrap() may require repeated service through WANT_READ or WANT_WRITE. While waiting, OpenSSL may already hold decrypted plaintext, and the peer may send final application data before its close_notify. HIO therefore needs a recurrent TLS close operation rather than a blocking call or immediate socket disposal.

This issue owns the common shutdown mechanism. The protocol meaning of received close_notify differs between TLS 1.2 and TLS 1.3 and is defined by #177.

Required sequence

  • Drain accepted output before starting local TLS shutdown.
  • Latch closing state and reject later application writes.
  • Preserve plaintext already reported by SSLSocket.pending().
  • Call SSLSocket.unwrap() to drive OpenSSL shutdown.
  • Recur through WANT_WRITE without changing terminal state.
  • On WANT_READ, service TLS receives and preserve final plaintext.
  • Do not retry unwrap() after a no-progress receive cycle; wait until receive processing records authenticated peer closure.
  • Dispose of the raw socket returned by successful unwrap() exactly once.
  • Retain and surface terminal TLS or transport failures.
  • Make repeated service calls after completion harmless.

This is an explicit HIO graceful-close policy built on OpenSSL's shutdown state. It must not imply that TLS 1.2 and TLS 1.3 impose the same reciprocal-close protocol rule.

Reproduction

With an established nonblocking TLS client or server endpoint:

  1. Queue output or leave decrypted plaintext pending.
  2. Request shutdown.
  3. Observe that current main reaches raw socket shutdown rather than recurrent unwrap().
  4. Inject WANT_WRITE followed by WANT_READ and verify that progress requires later service cycles.
  5. Deliver final plaintext before peer close_notify; verify that it remains in rxbs.
  6. Complete unwrap() and verify that its returned raw socket is closed once.

Close paths

A target implementation should make each branch explicit:

try:
    raw = self.cs.unwrap()
except ssl.SSLWantReadError:
    self._closeWantRead = True
    self._serviceCloseReceives()
    return False
except ssl.SSLWantWriteError:
    self._closeWantRead = False
    return False
except ssl.SSLZeroReturnError:
    self._receiveClosed()       # applies #177 version policy
    self._closeWantRead = True
    return False
except (ssl.SSLEOFError, ssl.SSLSyscallError, ssl.SSLError) as ex:
    self.cutoff = self.txCutoff = True
    self.error = ex
    self.close()                # force-close; unwrap cannot continue
    raise
except OSError as ex:
    if ex.errno in (errno.EAGAIN, errno.EWOULDBLOCK):
        return False
    self.cutoff = self.txCutoff = True
    self.error = ex
    self.close()
    raise

After successful unwrap(), clear the wrapped-socket reference, mark both endpoint directions terminal, reset close-progress state, and close raw. If closing raw fails, retain and surface that exception without exposing the unwrapped socket for application I/O.

Before negotiation completes, or after a retained fatal failure, no authenticated shutdown can be completed. Those states should take the explicit force-close path.

State invariants

Once local graceful close begins, application transmit is terminal even while OpenSSL still emits shutdown records. Authenticated peer closure is recorded through the normal receive policy rather than inferred from WANT_READ. A fatal failure bypasses all later unwrap() work, while retryable WANT and blocked-socket outcomes leave close progress intact.

Ownership

The endpoint performs one nonblocking close step per call. Its owner remains responsible for recurring the operation, maintaining normal send/receive service, imposing a deadline, and choosing force-close when the deadline expires.

shutdown(), shutdownSend(), and shutdownReceive() should all enter this TLS close operation while a TLS session is active; none should perform raw TCP half-close underneath TLS.

Related work

Acceptance criteria

  • ClientTls and RemoterTls use recurrent unwrap() for graceful shutdown.
  • Every WANT, authenticated-close, retryable socket, fatal-error, and success path above is covered.
  • Queued output and final received plaintext are preserved.
  • No-progress WANT_READ cycles do not spin on unwrap().
  • Successful completion closes the returned raw socket exactly once.
  • Directional shutdown aliases use the TLS path.
  • Terminal causes remain observable and repeated completion calls are harmless.

Activity

  1. kentbull commented on Aug 28, 2026

    @kentbull
    ContributorAuthor

    Downstream implementation and coverage:

    These changes must be adapted to current upstream main; they are not clean cherry-picks.

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