Skip to content

socket-action API: connections in graceful TLS shutdown are never progressed since 8.21.0 (fd + memory leak) #22282

Description

@pszemus

I did this

We upgraded libcurl from 8.20.0 to 8.21.0 in a production image-processing service and its RSS memory started growing constantly (hundreds of MB per hour under load) until the process had to be restarted. Reverting to 8.20.0 — or applying the one-line patch suggested below — makes RSS flat again. Nothing else changed between deployments.

Production RSS chart, before/after the upgrade:

Image

The service drives one curl_multi with the event/socket-action API (CURLMOPT_SOCKETFUNCTION/CURLMOPT_TIMERFUNCTION on a libuv loop, curl_multi_socket_action()), downloading many HTTPS URLs with a fresh easy handle per transfer (curl_multi_add_handle, and curl_multi_remove_handle + curl_easy_cleanup on CURLMSG_DONE).

What leaks

Every HTTPS connection whose graceful TLS shutdown does not complete on the first attempt (SSL_shutdown() wants to read the peer's close_notify — with real network latency that is nearly every connection) is parked in multi->cshutdn and never touched again until curl_multi_cleanup():

  • one socket fd per connection (dead socket in TCP CLOSED state — visible in /proc/<pid>/fd, invisible to ss), eventually leading to fd exhaustion, and
  • ~33 KB of heap per connection (TLS buffers, connection struct).

Because everything is still reachable from the shutdown list, valgrind reports no "definitely lost" bytes — RSS just grows.

Local reproduction (20,000 downloads of a 100 KB file from a local TLS server that closes the connection after each response, 8 transfers in flight, socket-action API):

libcurl RSS growth per download open fds after 15 s
8.20.0 4.4 MB, plateaus 0.2 kB 26 (stable)
8.21.0 16.4 MB, keeps growing 0.8 kB 86 and climbing
master (6841d59af6, 8.21.1-DEV) 18.9 MB, keeps growing 0.9 kB 106 and climbing
8.21.0 + patch below 4.3 MB, plateaus 0.2 kB 26 (stable)

Cause

Bisecting the 8.20.0→8.21.0 diff points at commit 5e4e629 ("cfilters: fix busy loop on blocked transfers", #21671 / #21675). It added this guard to Curl_conn_adjust_pollset():

  if(ps->n || !Curl_conn_is_connected(conn, FIRSTSOCKET) ||
     (conn->cfilter[SECONDARYSOCKET] &&
      !Curl_conn_is_connected(conn, SECONDARYSOCKET))) {
    for(i = 0; (i < 2) && !result && conn; ++i) {
      result = Curl_conn_cf_adjust_pollset(conn->cfilter[i], data, ps);
    }
  }

A connection in graceful shutdown hits neither branch: the (admin) transfer wants no I/O (ps->n == 0) and the connection is still "connected". So when Curl_cshutdn_add()cshutdn_update_ev()Curl_multi_ev_assess_conn() asks which sockets to watch, the filters are never consulted, the SSL filter's POLLIN interest is lost, and the shutdown socket is never registered with the application's socket callback. No event can ever fire for it, so cshutdn_perform() never runs again for that connection.

CURL_DEBUG=multi,ssl,tcp traces (DEBUGBUILD) show it directly. 8.20.0, right after a first shutdown attempt returns done=0 — the socket gets registered and the shutdown completes on the next event:

* [SSL] SSL shutdown sent, want receive
* [MULTI] [SHUTDOWN] shutdown, done=0
* [SSL] adjust_pollset, POLLIN fd=14
* [MULTI] ev entry fd=14, added connection #88, total=1/1 (xfer/conn)
* [MULTI] ev update fd=14, action '' -> 'IN' (2/0 r/w)
* [MULTI] [SHUTDOWN] added #88 to shutdowns, now 1 conns in shutdown
...
* [MULTI] [SHUTDOWN] perform on 1 connections
* [SSL] shut down successfully

8.21.0, same situation — no pollset adjustment, no event registration, and the connection is still in the shutdown list when the multi is destroyed:

* [SSL] SSL shutdown sent, want receive
* [MULTI] [SHUTDOWN] shutdown, done=0
* [MULTI] [SHUTDOWN] added #12 to shutdowns, now 1 conns in shutdown
    (nothing ever again for #12 ...)
* [MULTI] [SHUTDOWN] destroy, 1 connections, timeout=0ms

There is also no timer fallback: in cshutdn_perform(), next_expire_ms is initialized to 0 and only updated on ms < next_expire_ms, which is never true for a positive ms — so Curl_expire_ex(data, next_expire_ms, EXPIRE_SHUTDOWN) is unreachable. That looks like a separate latent bug (present in 8.20.0 too); before 5e4e629 the socket events masked it.

Suggested fix

Consult the filters also when shutdown has started on the connection (verified to restore 8.20.0 behavior in the reproduction and in production — the paused-transfer busy loop of #21671 does not come back because paused transfers never have shutdown.start set):

--- a/lib/cfilters.c
+++ b/lib/cfilters.c
@@ -760,6 +760,7 @@ CURLcode Curl_conn_adjust_pollset(struct Curl_easy *data,
    * will not change state and POLLIN/POLLOUT events will trigger forever,
    * making us busy loop. See #21671 */
   if(ps->n || !Curl_conn_is_connected(conn, FIRSTSOCKET) ||
+     Curl_shutdown_started(data, FIRSTSOCKET) ||
      (conn->cfilter[SECONDARYSOCKET] &&
       !Curl_conn_is_connected(conn, SECONDARYSOCKET))) {
     for(i = 0; (i < 2) && !result && conn; ++i) {

(data is attached to the connection when cshutdn_update_ev() calls Curl_multi_ev_assess_conn(), and Curl_cshutdn_run_once() calls Curl_shutdown_start() before the first attempt, so the predicate holds exactly for connections in the shutdown list.)

Happy to turn this into a PR if that helps.

I expected the following

Connections entering graceful shutdown under the event-based API keep being driven (as in 8.20.0) and are closed, releasing their socket and memory.

curl/libcurl version

curl 8.21.0 (also reproduced on current master, 6841d59af6 / 8.21.1-DEV). Built via CMake, static: OpenSSL 3.6.3, c-ares 1.34.6, nghttp2 1.69.0, HTTP_ONLY=ON, CURL_USE_LIBPSL=OFF.

operating system

Linux x86_64 (AlmaLinux 10 in production; reproduced on Fedora 43, kernel 7.1.3-100.fc43).

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