Skip to content

Socket closed without shutdown() causes TIME_WAIT accumulation and data loss risk #488

Closed

Description

@jyberg

Socket closed without shutdown() causes TIME_WAIT accumulation and data loss risk

Summary

The clickhouse-cpp client calls close() / closesocket() directly on the TCP socket without first calling shutdown(). This is an improper socket teardown that causes two problems:

  1. TIME_WAIT socket accumulation on the client side when the client initiates the close, which can exhaust ephemeral ports under high connection churn.
  2. Potential data loss — close() on a socket with unread data in the receive buffer causes the OS to send a RST instead of a graceful FIN, which can discard in-flight data and confuse the peer.

The issue affects both plain TCP and SSL connections (no SSL_shutdown() either).

Root Cause

Socket::Close() in socket.cpp:

void Socket::Close() {
    CloseSocket(handle_);
    handle_ = INVALID_SOCKET;
}

Where CloseSocket is:

void CloseSocket(SOCKET socket) {
    if (socket == INVALID_SOCKET)
        return;
#if defined(_win_)
    closesocket(socket);
#else
    close(socket);
#endif
}

There is no shutdown(fd, SHUT_RDWR) or shutdown(fd, SHUT_WR) call before close() anywhere in the library. A search of the entire clickhouse/ source tree confirms zero uses of shutdown().

For SSL sockets, SSLSocket inherits from Socket and its destructor chain also never calls SSL_shutdown() before closing the underlying file descriptor.

Affected Code Paths

Every socket close goes through Socket::Close() → CloseSocket():

  • Client::~Client() → ~Impl() → ~socket_ → Socket::~Socket() → Close()
  • Client::Impl::ResetConnection() → InitializeStreams() → old socket swapped out → ~Socket()
  • SocketRAIIWrapper::~SocketRAIIWrapper() in SocketConnect() (on connection failure)

Consequences

1. TIME_WAIT accumulation

When the client closes first without shutdown(), the TCP stack enters TIME_WAIT on the client side. Under high connection churn (many short-lived client instances), this can exhaust the available ephemeral port range (~28,000 ports on Linux default), causing connect() failures with EADDRNOTAVAIL.

2. RST instead of graceful FIN

Per POSIX/RFC 793, calling close() on a socket that has unread data in the kernel receive buffer causes the OS to send a TCP RST to the peer instead of a graceful FIN. This can happen when:

  • The server sent data the client never read (e.g., client destroys Client object mid-query)
  • The server sent an exception or log packet the client didn't consume

The RST causes the peer to see ECONNRESET, and any data the client sent that is still in-flight may be discarded by the peer's TCP stack.

3. SSL session not terminated properly

For SSL connections, SSL_shutdown() is never called. This means the TLS close_notify alert is never sent, which:

  • Prevents SSL session resumption/caching
  • May cause the server to log TLS errors
  • Violates the TLS specification (RFC 5246 §7.2.1)

Reproduction

void tcp_server(std::atomic<bool>& running, int port) {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    // ... bind, listen ...
    while (running) {
        int client_fd = accept(server_fd, nullptr, nullptr);
        if (client_fd >= 0) {
            // Read Hello, then close properly from server side
            recv(client_fd, buffer, sizeof(buffer), 0);
            shutdown(client_fd, SHUT_RDWR);
            close(client_fd);
        }
    }
}

// Client side — creates and destroys many connections
for (int i = 0; i < 15000; ++i) {
    try {
        clickhouse::Client client(opts);
    } catch (...) {}
}

// Check TIME_WAIT count — will be very high
// Linux: grep "06 " /proc/net/tcp | wc -l

Suggested Fix

Add shutdown() before close() in Socket::Close():

void Socket::Close() {
    if (handle_ == INVALID_SOCKET)
        return;

#if defined(_win_)
    shutdown(handle_, SD_BOTH);
    closesocket(handle_);
#else
    shutdown(handle_, SHUT_RDWR);
    close(handle_);
#endif
    handle_ = INVALID_SOCKET;
}

For SSL sockets, add SSL_shutdown() in the SSLSocket destructor before the base Socket::Close() runs:

SSLSocket::~SSLSocket() {
    if (ssl_) {
        SSL_shutdown(ssl_.get());
    }
}

Note: shutdown() may fail if the socket is already in an error state — the return value can be safely ignored here since we are closing regardless.

Environment

  • Library: clickhouse-cpp (all current versions)
  • Affected platforms: All (Linux, macOS, Windows)
  • Affected files: clickhouse/base/socket.cpp, clickhouse/base/sslsocket.cpp

Activity

  1. ansh0014 commented on May 4, 2026

    @ansh0014

    @jyberg
    I understand the issue: Socket::Close() calls close()/closesocket() directly, and SSL sockets never call SSL_shutdown(). This can cause TIME_WAIT buildup and ungraceful teardown (RST) if unread data exists.

    Suggested fix: call shutdown(SHUT_RDWR/SD_BOTH) before close in Socket::Close(), and call SSL_shutdown() for TLS sockets before the FD is closed.

  2. added theissue type on Jun 2, 2026
  3. jyberg commented on Aug 25, 2026

    @jyberg
    Author

    Hi, It looks like there was already correction done, but why it was not merged to the main? Any information when this small change can be done?

  4. slabko commented on Aug 26, 2026

    @slabko
    Contributor

    Hi @jyberg

    Sorry I didn't reply to your issue report in time.

    Some aspects of the network stack are being reworked in the library, and I am still thinking about a way to solve this problem, if it really needs solving at all. Let me elaborate. And please, if I miss anything, let me know. This is a complicated topic with many parts, and I can absolutely miss some of them.

    It is indeed required that the closing peer:

    1. Sends a FIN segment
    2. Waits for an ACK and FIN from the other side
    3. Sends the final ACK
    4. Waits for the TIME_WAIT period to allow any inflight segments to reach the peer. (I am simplifying the reason for TIME_WAIT a bit, as it is not important for the problem at hand)

    From an OS API perspective, you can either let the system handle this flow and just close the socket (unless specific options are applied to prevent that, which is not the case here), or the OS can let you control the flow yourself. If you want to control it yourself, you call shutdown(fd, SHUT_WR) or shutdown(fd, SHUT_RDWR) and then call recv(...) to get the final empty response of size 0. This allows the application to send the FIN segment and wait for the FIN segment from the peer. Doing so, however, does not prevent the OS from sending an RST if the peer sends any data after your call to shutdown(...). It doesn't change anything about TIME_WAIT either - TIME_WAIT is always respected on the side that initiated the connection closure, regardless of whether you call shutdown(...) or not.

    Now let me address your concerns one by one:

    TIME_WAIT socket accumulation on the client side when the client initiates the close, which can exhaust ephemeral ports under high connection churn.

    TIME_WAIT is going to happen regardless of whether you call shutdown(...) before close(...). This is part of the closing handshake, the OS must do it to follow the protocol.

    This can exhaust the available ephemeral port range (~28,000 ports on Linux default), causing connect() failures with EADDRNOTAVAIL

    As I said, this can happen even if you call shutdown(...), and even if you follow the more advanced flow described earlier (i.e. shutdown(...), recv(...), and only then close(...)).

    However, if you are concerned about ephemeral port exhaustion, you are probably dealing with a much larger problem in general. In order to reach this point, you need to open and close 28K ports per TIME_WAIT period, where TIME_WAIT is usually 60 seconds. In other words, to reach that point, you need to open and close more than two sockets per millisecond.

    Per POSIX/RFC 793, calling close() on a socket that has unread data in the kernel receive buffer causes the OS to send a TCP RST to the peer instead of a graceful FIN.

    The exact same thing happens when calling shutdown(fd, SHUT_RDWR).

    Potential data loss — close() on a socket with unread data in the receive buffer causes the OS to send a RST instead of a graceful FIN, which can discard in-flight data and confuse the peer.

    That happens when the user has already deleted their socket, so they are not interested in that data in the first place. And again, the exact same thing happens if you call shutdown(fd, SHUT_RDWR).

    This can also happen when sending data. For example, the data has been sent and the peer closes the connection. If there is any data in both the read and write buffers, the OS will reset that socket, discarding data in the write buffer.

    However, the ClickHouse protocol is a confirmation- and turn-based protocol, meaning that:

    1. For each attempt to send data (Insert(...), EndInsert(...), Execute(...), etc.), the call waits for server confirmation before returning control, so the client and its socket cannot be deleted at that point.
    2. The server is not going to send any data that would cause the OS to react to unwanted data after close(), because the server only replies after it receives data.

    From all of this, however, I think calling SSL_shutdown(...) followed by close(...) is a good idea, but the lack of it should not cause any problems. Just realized that SSL_shutdown can also block under some circumstances, which is a no-no in the destructor.

    @jyberg, all that said, I understand that you created the issue for a reason, and I believe you are facing a specific problem in your environment. I was hoping you could share it here so that we can address that problem instead of trying to solve the abstract issues initially listed in the ticket.

  5. removed theissue type on Aug 26, 2026
  6. slabko commented on Sep 16, 2026

    @slabko
    Contributor

    Closing stale ticket

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

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