Hanging up by yanking the cable instead of saying goodbye — the other side can't tell whether you finished talking or got cut off, so it keeps the line open just in case.
A TLS connection rides on top of a TCP socket. When you're done, you owe the peer two goodbyes: a TLS close_notify alert (the encrypted "I'm finished, that was the end of the data"), and then a clean TCP teardown (the FIN/ACK handshake).
If you skip the goodbyes and just close() the file descriptor, the peer never hears the close_notify. It can't tell a normal end-of-stream from a truncation attack where someone chopped your bytes off early. So it either logs a scary warning or holds the socket open waiting for more — and that leaks.
Over thousands of connections, those half-closed sockets pile up in CLOSE_WAIT / FIN_WAIT, file descriptors run out, and TLS session state lingers in memory.
Pick a mode, then step through the teardown.
The clean shutdown is a short ritual. Send the TLS close, half-close the TCP write side, drain whatever the peer still sends, wait for its FIN, then release the fd.
# Clean shutdown of a TLS-over-TCP connection (client side)
def clean_close(tls_conn, raw_sock):
# 1. Send the encrypted TLS close_notify alert.
# This tells the peer "data stream ended normally" —
# NOT a truncation attack.
tls_conn.shutdown() # OpenSSL: SSL_shutdown()
# 2. Half-close the TCP write side: send a FIN, but keep
# reading. We've said all we'll say.
raw_sock.shutdown(SHUT_WR) # sends TCP FIN
# 3. Drain: read until the peer's close_notify + EOF.
# Reaching b'' means the peer sent its own FIN.
while raw_sock.recv(4096):
pass # discard trailing bytes
# 4. Now both sides have FIN'd. Release the fd.
raw_sock.close() # frees the file descriptor
Step 3 is the part people drop. If you close() right after sending your FIN, you never collect the peer's FIN, and the kernel parks your side in FIN_WAIT_2 while the peer sits in CLOSE_WAIT.
| What you observe | What it tells you |
|---|---|
ss -tan state close-wait count climbing | The peer got your FIN but your app never called close() on its side (or the reverse). Half-open sockets are accumulating. |
Too many open files / EMFILE | File descriptors leaked. Each lingering socket holds an fd; the process hits its ulimit -n ceiling. |
Peer logs SSL_ERROR_SYSCALL or "unexpected EOF" / "connection reset" | You closed the TCP socket without sending close_notify. The peer can't prove the stream ended cleanly. |
| Peer warns "TLS truncation" or drops the response | A strict peer treats a missing close_notify as a possible truncation attack and discards trailing data. |
| RSS / heap creeping up under steady load | TLS session objects and per-connection buffers aren't freed because the connections never reach a closed state. |
Sockets stuck in FIN_WAIT_2 | You sent a FIN but never drained the peer's FIN, so the 4-way teardown can't complete. |
sock.close() directly on a TLS socket — it drops the fd but never emits close_notify. Close the TLS layer first, then the socket.SSL_shutdown() as one-shot. It often returns "in progress" on the first call; call it again (or read) until the peer's close_notify arrives, or accept a one-way close deliberately.FIN without draining. The peer's FIN never gets read, leaving you in FIN_WAIT_2 and the peer in CLOSE_WAIT.SO_LINGER with a hard timeout to "fix" leaks — that sends an abortive RST, which is the opposite of a clean close and corrupts in-flight data.FIN can hang your shutdown forever; bound step 3 with a deadline, then force-close.A payments API handles 2,000 short-lived HTTPS requests a second behind a TLS-terminating proxy. After each response the handler calls os.close(fd) on the raw socket directly, skipping the TLS layer entirely. Each response goes out fine, so nothing looks broken in tests.
In production, the proxy never receives close_notify, so every connection it holds lands in CLOSE_WAIT waiting for an orderly end that never comes. Within twenty minutes ss -tan shows tens of thousands of CLOSE_WAIT sockets, the process hits EMFILE, new accept() calls fail, and the service starts dropping requests. The fix is one ordered ritual: SSL_shutdown() to emit close_notify, shutdown(SHUT_WR) to send the FIN, drain until EOF, then close(). CLOSE_WAIT drains to near zero and the fd count flattens.
Your server's CLOSE_WAIT count climbs all day until it runs out of file descriptors. What does that point to?
Why send a TLS close_notify before tearing down the TCP socket?