TLS socket without a clean shutdown

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.

The idea

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.

See it work

Pick a mode, then step through the teardown.

mode: clean close phase: idle leaked sockets: 0 step: 0 / 0

How it works

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.

Signals

What you observeWhat it tells you
ss -tan state close-wait count climbingThe 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 / EMFILEFile 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 responseA strict peer treats a missing close_notify as a possible truncation attack and discards trailing data.
RSS / heap creeping up under steady loadTLS session objects and per-connection buffers aren't freed because the connections never reach a closed state.
Sockets stuck in FIN_WAIT_2You sent a FIN but never drained the peer's FIN, so the 4-way teardown can't complete.

Watch out for

Worked example

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.

Check yourself

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?