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:
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).
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:
The service drives one
curl_multiwith the event/socket-action API (CURLMOPT_SOCKETFUNCTION/CURLMOPT_TIMERFUNCTIONon a libuv loop,curl_multi_socket_action()), downloading many HTTPS URLs with a fresh easy handle per transfer (curl_multi_add_handle, andcurl_multi_remove_handle+curl_easy_cleanuponCURLMSG_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 inmulti->cshutdnand never touched again untilcurl_multi_cleanup():/proc/<pid>/fd, invisible toss), eventually leading to fd exhaustion, andBecause 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):
6841d59af6, 8.21.1-DEV)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():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 whenCurl_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, socshutdn_perform()never runs again for that connection.CURL_DEBUG=multi,ssl,tcptraces (DEBUGBUILD) show it directly. 8.20.0, right after a first shutdown attempt returnsdone=0— the socket gets registered and the shutdown completes on the next event: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:
There is also no timer fallback: in
cshutdn_perform(),next_expire_msis initialized to0and only updated onms < next_expire_ms, which is never true for a positivems— soCurl_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.startset):(
datais attached to the connection whencshutdn_update_ev()callsCurl_multi_ev_assess_conn(), andCurl_cshutdn_run_once()callsCurl_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).