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:
- Queue output or leave decrypted plaintext pending.
- Request shutdown.
- Observe that current
main reaches raw socket shutdown rather than recurrent unwrap().
- Inject WANT_WRITE followed by WANT_READ and verify that progress requires later service cycles.
- Deliver final plaintext before peer
close_notify; verify that it remains in rxbs.
- 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.
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 bySSLSocket.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 itsclose_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_notifydiffers between TLS 1.2 and TLS 1.3 and is defined by #177.Required sequence
SSLSocket.pending().SSLSocket.unwrap()to drive OpenSSL shutdown.unwrap()after a no-progress receive cycle; wait until receive processing records authenticated peer closure.unwrap()exactly once.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:
mainreaches raw socket shutdown rather than recurrentunwrap().close_notify; verify that it remains inrxbs.unwrap()and verify that its returned raw socket is closed once.Close paths
A target implementation should make each branch explicit:
After successful
unwrap(), clear the wrapped-socket reference, mark both endpoint directions terminal, reset close-progress state, and closeraw. If closingrawfails, 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(), andshutdownReceive()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
unwrap()for graceful shutdown.unwrap().