https://bugs.gentoo.org/980279
https://github.com/rakshasa/rtorrent/issues/1208
https://github.com/curl/curl/commit/820c014578b56b9fa4d2c472e90b5060198dd404

From 820c014578b56b9fa4d2c472e90b5060198dd404 Mon Sep 17 00:00:00 2001
From: Graham Campbell <hello@gjcampbell.co.uk>
Date: Sun, 12 Jul 2026 17:52:16 +0100
Subject: [PATCH] cfilters: fix event-based connection shutdown

- Adjust pollset during connection shutdown.

- Separate the FIRSTSOCKET and SECONDSOCKET check so that one being in
  connect or shutdown no longer adds poll events for the other.

- Fix shutdown state evaluation (as detailed below).

- Add a unit test for Curl_conn_adjust_pollset.

- Add a client for event testing.

- Test that shutdown sockets stay with the socket callback until done.

The pollset predicate now reads the connection's own shutdown state
rather than going through data->conn, and the EXPIRE_SHUTDOWN arming in
cshutdn_perform() works again: next_expire_ms started at 0 and could
never be set, so a peer that never sends a close_notify would still park
its connection forever, timeout or not.

Reported-by: pszemus@users.noreply.github.com

Fixes https://github.com/curl/curl/issues/22282
Closes https://github.com/curl/curl/pull/22304
---
 lib/cfilters.c                  |  24 ++-
 lib/connect.c                   |  11 +-
 lib/connect.h                   |   2 +-
 lib/cshutdn.c                   |   4 +-
 lib/transfer.c                  |   2 +-
 tests/data/Makefile.am          |   2 +-
 tests/data/test2606             |  18 ++
 tests/http/test_19_shutdown.py  |  34 ++++
 tests/libtest/Makefile.inc      |   1 +
 tests/libtest/cli_ev_download.c | 313 ++++++++++++++++++++++++++++++++
 tests/unit/Makefile.inc         |   1 +
 tests/unit/unit2606.c           | 221 ++++++++++++++++++++++
 12 files changed, 612 insertions(+), 21 deletions(-)
 create mode 100644 tests/data/test2606
 create mode 100644 tests/libtest/cli_ev_download.c
 create mode 100644 tests/unit/unit2606.c

diff --git a/lib/cfilters.c b/lib/cfilters.c
index fb1914d133e8..c5ee51a1b42f 100644
--- a/lib/cfilters.c
+++ b/lib/cfilters.c
@@ -206,7 +206,7 @@ CURLcode Curl_conn_shutdown(struct Curl_easy *data, int sockindex, bool *done)
   }
 
   *done = FALSE;
-  if(!Curl_shutdown_started(data, sockindex)) {
+  if(!Curl_shutdown_started(data->conn, sockindex)) {
     Curl_shutdown_start(data, sockindex, 0);
   }
   else {
@@ -748,23 +748,29 @@ CURLcode Curl_conn_adjust_pollset(struct Curl_easy *data,
                                   struct easy_pollset *ps)
 {
   CURLcode result = CURLE_OK;
+  bool want_io = !!ps->n;
   int i;
 
   DEBUGASSERT(data);
   DEBUGASSERT(conn);
   /* During connect time, connection filters may add sockets to the pollset
    * even when the transfer neither wants to send nor receive. And those
-   * sockets, when having events, are served.
-   * Once connected however, a transfer that neither wants to send nor receive
+   * sockets, when having events, are served. The same applies to a
+   * filter chain whose shutdown has started.
+   * Once a filter chain is connected however and before its shutdown
+   * starts, a transfer that neither wants to send nor receive
    * will never call the connection filters. Any sockets added by the filters
    * 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) ||
-     (conn->cfilter[SECONDARYSOCKET] &&
-      !Curl_conn_is_connected(conn, SECONDARYSOCKET))) {
-    for(i = 0; (i < 2) && !result && conn; ++i) {
+   * making us busy loop. See #21671.
+   * Gate each filter chain on its own state, so that one chain being in
+   * connect or shutdown does not add poll events for the other. Check
+   * against the transfer's own interest, before any chain added sockets
+   * of its own. */
+  for(i = 0; (i < 2) && !result; ++i) {
+    if(conn->cfilter[i] &&
+       (want_io || !Curl_conn_is_connected(conn, i) ||
+        Curl_shutdown_started(conn, i)))
       result = Curl_conn_cf_adjust_pollset(conn->cfilter[i], data, ps);
-    }
   }
   return result;
 }
diff --git a/lib/connect.c b/lib/connect.c
index 6d6ddd2e91a1..86e97b955a01 100644
--- a/lib/connect.c
+++ b/lib/connect.c
@@ -75,7 +75,7 @@ UNITTEST timediff_t timeleft_now_ms(struct Curl_easy *data,
   timediff_t timeleft_ms = 0;
   timediff_t ctimeleft_ms = 0;
 
-  if(Curl_shutdown_started(data, FIRSTSOCKET))
+  if(data->conn && Curl_shutdown_started(data->conn, FIRSTSOCKET))
     return Curl_shutdown_timeleft(data, data->conn, FIRSTSOCKET);
   else if(Curl_is_connecting(data)) {
     timediff_t ctimeout_ms = (data->set.connecttimeout > 0) ?
@@ -164,13 +164,10 @@ void Curl_shutdown_clear(struct Curl_easy *data, int sockindex)
   memset(pt, 0, sizeof(*pt));
 }
 
-bool Curl_shutdown_started(struct Curl_easy *data, int sockindex)
+bool Curl_shutdown_started(struct connectdata *conn, int sockindex)
 {
-  if(data->conn) {
-    struct curltime *pt = &data->conn->shutdown.start[sockindex];
-    return (pt->tv_sec > 0) || (pt->tv_usec > 0);
-  }
-  return FALSE;
+  const struct curltime *pt = &conn->shutdown.start[sockindex];
+  return (pt->tv_sec > 0) || (pt->tv_usec > 0);
 }
 
 /*
diff --git a/lib/connect.h b/lib/connect.h
index a5b7042d6e59..20eedaf10e1d 100644
--- a/lib/connect.h
+++ b/lib/connect.h
@@ -58,7 +58,7 @@ timediff_t Curl_conn_shutdown_timeleft(struct Curl_easy *data,
 void Curl_shutdown_clear(struct Curl_easy *data, int sockindex);
 
 /* TRUE iff shutdown has been started */
-bool Curl_shutdown_started(struct Curl_easy *data, int sockindex);
+bool Curl_shutdown_started(struct connectdata *conn, int sockindex);
 
 /*
  * Used to extract socket and connectdata struct for the most recent
diff --git a/lib/cshutdn.c b/lib/cshutdn.c
index cc77e401fd32..30cbaa552acb 100644
--- a/lib/cshutdn.c
+++ b/lib/cshutdn.c
@@ -76,7 +76,7 @@ static void cshutdn_run_once(struct Curl_easy *data,
   /* We expect to be attached when called */
   DEBUGASSERT(data->conn == conn);
 
-  if(!Curl_shutdown_started(data, FIRSTSOCKET)) {
+  if(!Curl_shutdown_started(conn, FIRSTSOCKET)) {
     Curl_shutdown_start(data, FIRSTSOCKET, 0);
   }
 
@@ -253,7 +253,7 @@ static void cshutdn_perform(struct cshutdn *cshutdn,
       /* idata has one timer list, but maybe more than one connection.
        * Set EXPIRE_SHUTDOWN to the smallest time left for all. */
       ms = Curl_conn_shutdown_timeleft(data, conn);
-      if(ms && ms < next_expire_ms)
+      if(ms && (!next_expire_ms || (ms < next_expire_ms)))
         next_expire_ms = ms;
     }
     e = enext;
diff --git a/lib/transfer.c b/lib/transfer.c
index 2eab7cabad4c..b7902de9a172 100644
--- a/lib/transfer.c
+++ b/lib/transfer.c
@@ -155,7 +155,7 @@ static bool xfer_recv_shutdown_started(struct Curl_easy *data)
 {
   if(!data || !data->conn)
     return FALSE;
-  return Curl_shutdown_started(data, data->conn->recv_idx);
+  return Curl_shutdown_started(data->conn, data->conn->recv_idx);
 }
 
 CURLcode Curl_xfer_send_shutdown(struct Curl_easy *data, bool *done)
diff --git a/tests/data/Makefile.am b/tests/data/Makefile.am
index 40e7c7da5cd7..e88472728b23 100644
--- a/tests/data/Makefile.am
+++ b/tests/data/Makefile.am
@@ -267,7 +267,7 @@ test2408 test2409 test2410 test2411 test2412 test2413 \
 \
 test2500 test2501 test2502 test2503 test2504 test2505 test2506 \
 \
-test2600 test2601 test2602 test2603 test2604 test2605 \
+test2600 test2601 test2602 test2603 test2604 test2605 test2606 \
 \
 test2700 test2701 test2702 test2703 test2704 test2705 test2706 test2707 \
 test2708 test2709 test2710 test2711 test2712 test2713 test2714 test2715 \
diff --git a/tests/data/test2606 b/tests/data/test2606
new file mode 100644
index 000000000000..1ce80013a717
--- /dev/null
+++ b/tests/data/test2606
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="US-ASCII"?>
+<testcase>
+<info>
+<keywords>
+unittest
+</keywords>
+</info>
+
+# Client-side
+<client>
+<features>
+unittest
+</features>
+<name>
+Curl_conn_adjust_pollset unit test
+</name>
+</client>
+</testcase>
diff --git a/tests/http/test_19_shutdown.py b/tests/http/test_19_shutdown.py
index f8a948acd661..4ac447d75837 100644
--- a/tests/http/test_19_shutdown.py
+++ b/tests/http/test_19_shutdown.py
@@ -211,3 +211,37 @@ def test_19_07_shutdown_by_curl(self, env: Env, httpd, proto, share_connect):
                      if re.match(r'.*SHUTDOWN] shutdown, done=1', line)]
         # we see less clean shutdowns as total limit forces early closes
         assert len(shutdowns) < count, f'{shutdowns}'
+
+    # Strictly event-based transfers, no connection reuse. Connections
+    # whose graceful shutdown cannot finish right away must have their
+    # socket stay registered with the application's socket callback,
+    # or they never make progress and leak until multi cleanup.
+    @pytest.mark.parametrize("proto", ['http/1.1'])
+    def test_19_08_event_shutdown_watched(self, env: Env, httpd, proto):
+        if not env.curl_is_debug():
+            pytest.skip('only works for curl debug builds')
+        count = 5
+        docname = 'data.json'
+        url = f'https://localhost:{env.https_port}/{docname}'
+        client = LocalClient(name='cli_ev_download', env=env, run_env={
+            # make socket receives block often, so the TLS shutdown
+            # cannot finish on its first attempt
+            'CURL_DBG_SOCK_RBLOCK': '90',
+            'CURL_DEBUG': 'ssl,multi'
+        })
+        if not client.exists():
+            pytest.skip(f'example client not built: {client.name}')
+        r = client.run(args=[
+            '-n', f'{count}', '-C', env.ca.cert_file, url
+        ])
+        r.check_exit_code(0)
+        m = None
+        for line in r.stderr.splitlines():
+            m = re.match(r'.*\[ev] final: watched=(\d+) socks_left=(\d+)', line)
+            if m:
+                break
+        assert m, f'no client event summary found: {r.stderr}'
+        # shutdown sockets were watched after transfers finished and
+        # all shutdowns finished within the event loop
+        assert int(m.group(1)) > 0, f'{r.stderr}'
+        assert int(m.group(2)) == 0, f'{r.stderr}'
diff --git a/tests/libtest/Makefile.inc b/tests/libtest/Makefile.inc
index 6fd8469dfa14..07d78b51925f 100644
--- a/tests/libtest/Makefile.inc
+++ b/tests/libtest/Makefile.inc
@@ -54,6 +54,7 @@ TOOLX_C = \
 
 # All libtest programs
 TESTS_C = \
+  cli_ev_download.c \
   cli_ftp_upload.c \
   cli_h2_pausing.c \
   cli_h2_serverpush.c \
diff --git a/tests/libtest/cli_ev_download.c b/tests/libtest/cli_ev_download.c
new file mode 100644
index 000000000000..e3ca6ad406a7
--- /dev/null
+++ b/tests/libtest/cli_ev_download.c
@@ -0,0 +1,313 @@
+/***************************************************************************
+ *                                  _   _ ____  _
+ *  Project                     ___| | | |  _ \| |
+ *                             / __| | | | |_) | |
+ *                            | (__| |_| |  _ <| |___
+ *                             \___|\___/|_| \_\_____|
+ *
+ * Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
+ *
+ * This software is licensed as described in the file COPYING, which
+ * you should have received as part of this distribution. The terms
+ * are also available at https://curl.se/docs/copyright.html.
+ *
+ * You may opt to use, copy, modify, merge, publish, distribute and/or sell
+ * copies of the Software, and permit persons to whom the Software is
+ * furnished to do so, under the terms of the COPYING file.
+ *
+ * This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
+ * KIND, either express or implied.
+ *
+ * SPDX-License-Identifier: curl
+ *
+ ***************************************************************************/
+#include "first.h"
+
+/* An event-based download client, driving transfers purely via
+ * curl_multi_socket_action() with its own socket/timer callbacks, the
+ * way an event-based application does. It runs its transfers serially
+ * with CURLOPT_FORBID_REUSE, so every transfer's connection enters
+ * shutdown when done, and reports whether the connection sockets
+ * remain under event supervision until their shutdown finished. */
+
+#define EV_DL_MAX_SOCKS 8
+
+struct ev_dl_ctx {
+  CURLM *multi;
+  struct curltime timer_started;
+  curl_socket_t fds[EV_DL_MAX_SOCKS];
+  int actions[EV_DL_MAX_SOCKS];
+  size_t nsocks;
+  long timer_ms; /* last timeout set, -1 for none */
+};
+
+static int ev_dl_socket_cb(CURL *easy, curl_socket_t fd, int what,
+                           void *clientp, void *socketp)
+{
+  struct ev_dl_ctx *ctx = clientp;
+  size_t i;
+
+  (void)easy;
+  (void)socketp;
+  for(i = 0; i < ctx->nsocks; ++i) {
+    if(ctx->fds[i] == fd)
+      break;
+  }
+  if(what == CURL_POLL_REMOVE) {
+    if(i < ctx->nsocks) {
+      ctx->nsocks--;
+      ctx->fds[i] = ctx->fds[ctx->nsocks];
+      ctx->actions[i] = ctx->actions[ctx->nsocks];
+    }
+  }
+  else {
+    if(i == ctx->nsocks) {
+      if(ctx->nsocks >= EV_DL_MAX_SOCKS) {
+        curl_mfprintf(stderr, "[ev] too many sockets to track\n");
+        return -1;
+      }
+      ctx->nsocks++;
+    }
+    ctx->fds[i] = fd;
+    ctx->actions[i] = what;
+  }
+  curl_mfprintf(stderr, "[ev] socket fd=%ld what=%d, tracking %zu\n",
+                (long)fd, what, ctx->nsocks);
+  return 0;
+}
+
+static int ev_dl_timer_cb(CURLM *multi, long timeout_ms, void *clientp)
+{
+  struct ev_dl_ctx *ctx = clientp;
+
+  (void)multi;
+  ctx->timer_ms = timeout_ms;
+  if(timeout_ms >= 0)
+    ctx->timer_started = curlx_now();
+  return 0;
+}
+
+static size_t ev_dl_discard_cb(char *ptr, size_t size, size_t nmemb,
+                               void *userdata)
+{
+  (void)ptr;
+  (void)userdata;
+  return size * nmemb;
+}
+
+/* wait for socket events or the timer, then feed libcurl. */
+static CURLMcode ev_dl_roundtrip(struct ev_dl_ctx *ctx)
+{
+  curl_socket_t fds[EV_DL_MAX_SOCKS];
+  int actions[EV_DL_MAX_SOCKS];
+  struct timeval tv;
+  fd_set rd, wr;
+  size_t i, nsocks;
+  long wait_ms = 100;
+  int maxfd = -1;
+  int running = 0;
+  CURLMcode mres = CURLM_OK;
+
+  FD_ZERO(&rd);
+  FD_ZERO(&wr);
+  for(i = 0; i < ctx->nsocks; ++i) {
+    if(ctx->actions[i] & CURL_POLL_IN)
+      FD_SET(ctx->fds[i], &rd);
+    if(ctx->actions[i] & CURL_POLL_OUT)
+      FD_SET(ctx->fds[i], &wr);
+    if((int)ctx->fds[i] > maxfd)
+      maxfd = (int)ctx->fds[i];
+  }
+  if(ctx->timer_ms >= 0) {
+    long left = ctx->timer_ms -
+      (long)curlx_timediff_ms(curlx_now(), ctx->timer_started);
+    if(left < 0)
+      left = 0;
+    if(left < wait_ms)
+      wait_ms = left;
+  }
+  tv.tv_sec = wait_ms / 1000;
+  tv.tv_usec = (int)(wait_ms % 1000) * 1000;
+  select_wrapper(maxfd + 1, &rd, &wr, NULL, &tv);
+
+  /* the callbacks change our socket table while we dispatch */
+  nsocks = ctx->nsocks;
+  memcpy(fds, ctx->fds, sizeof(fds));
+  memcpy(actions, ctx->actions, sizeof(actions));
+  for(i = 0; i < nsocks; ++i) {
+    int ev_bitmask = 0;
+    if((actions[i] & CURL_POLL_IN) && FD_ISSET(fds[i], &rd))
+      ev_bitmask |= CURL_CSELECT_IN;
+    if((actions[i] & CURL_POLL_OUT) && FD_ISSET(fds[i], &wr))
+      ev_bitmask |= CURL_CSELECT_OUT;
+    if(ev_bitmask) {
+      mres = curl_multi_socket_action(ctx->multi, fds[i], ev_bitmask,
+                                      &running);
+      if(mres)
+        return mres;
+    }
+  }
+
+  if((ctx->timer_ms >= 0) &&
+     (curlx_timediff_ms(curlx_now(), ctx->timer_started) >= ctx->timer_ms)) {
+    ctx->timer_ms = -1;
+    mres = curl_multi_socket_action(ctx->multi, CURL_SOCKET_TIMEOUT, 0,
+                                    &running);
+  }
+  return mres;
+}
+
+static void usage_ev_download(const char *msg)
+{
+  if(msg)
+    curl_mfprintf(stderr, "%s\n", msg);
+  curl_mfprintf(stderr,
+    "usage: [options] url\n"
+    "  event-based downloads with connection reuse forbidden\n"
+    "  -n number      of transfers to do serially (default: 5)\n"
+    "  -C certfile    for CA verification\n"
+  );
+}
+
+static CURLcode test_cli_ev_download(const char *URL)
+{
+  struct ev_dl_ctx ctx;
+  CURLM *multi = NULL;
+  char *cafile = NULL;
+  const char *url;
+  size_t transfer_count = 5;
+  size_t i;
+  int watched = 0;
+  struct curltime started;
+  CURLcode result = CURLE_OK;
+  int ch;
+
+  (void)URL;
+  memset(&ctx, 0, sizeof(ctx));
+  ctx.timer_ms = -1;
+
+  while((ch = cgetopt(test_argc, test_argv, "hn:C:")) != -1) {
+    switch(ch) {
+    case 'h':
+      usage_ev_download(NULL);
+      result = (CURLcode)2;
+      goto optcleanup;
+    case 'n': {
+      const char *opt = coptarg;
+      curl_off_t num;
+      if(!curlx_str_number(&opt, &num, LONG_MAX))
+        transfer_count = (size_t)num;
+      break;
+    }
+    case 'C':
+      curlx_free(cafile);
+      cafile = curlx_strdup(coptarg);
+      break;
+    default:
+      usage_ev_download("invalid option");
+      result = (CURLcode)1;
+      goto optcleanup;
+    }
+  }
+  test_argc -= coptind;
+  test_argv += coptind;
+
+  if(test_argc != 1) {
+    usage_ev_download("not enough arguments");
+    result = (CURLcode)2;
+    goto optcleanup;
+  }
+  url = test_argv[0];
+
+  if(curl_global_init(CURL_GLOBAL_ALL) != CURLE_OK) {
+    curl_mfprintf(stderr, "curl_global_init() failed\n");
+    result = (CURLcode)3;
+    goto optcleanup;
+  }
+  curl_global_trace("ids,time");
+
+  multi = curl_multi_init();
+  if(!multi) {
+    curl_mfprintf(stderr, "curl_multi_init() failed\n");
+    result = (CURLcode)3;
+    goto cleanup;
+  }
+  ctx.multi = multi;
+  curl_multi_setopt(multi, CURLMOPT_SOCKETFUNCTION, ev_dl_socket_cb);
+  curl_multi_setopt(multi, CURLMOPT_SOCKETDATA, &ctx);
+  curl_multi_setopt(multi, CURLMOPT_TIMERFUNCTION, ev_dl_timer_cb);
+  curl_multi_setopt(multi, CURLMOPT_TIMERDATA, &ctx);
+
+  for(i = 0; i < transfer_count && !result; ++i) {
+    CURL *easy = curl_easy_init();
+    int done = 0;
+    int running = 0;
+
+    if(!easy) {
+      curl_mfprintf(stderr, "[t-%zu] FAILED setup\n", i);
+      result = (CURLcode)1;
+      goto cleanup;
+    }
+    curl_easy_setopt(easy, CURLOPT_URL, url);
+    curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, ev_dl_discard_cb);
+    curl_easy_setopt(easy, CURLOPT_FORBID_REUSE, 1L);
+    curl_easy_setopt(easy, CURLOPT_VERBOSE, 1L);
+    if(cafile)
+      curl_easy_setopt(easy, CURLOPT_CAINFO, cafile);
+
+    curl_multi_add_handle(multi, easy);
+    curl_mfprintf(stderr, "[t-%zu] STARTED\n", i);
+    curl_multi_socket_action(multi, CURL_SOCKET_TIMEOUT, 0, &running);
+
+    started = curlx_now();
+    while(!done) {
+      struct CURLMsg *m;
+      int msgq = 0;
+
+      if(ev_dl_roundtrip(&ctx)) {
+        curl_mfprintf(stderr, "[t-%zu] multi failure\n", i);
+        result = (CURLcode)1;
+        goto cleanup;
+      }
+      m = curl_multi_info_read(multi, &msgq);
+      if(m && (m->msg == CURLMSG_DONE)) {
+        done = 1;
+        result = m->data.result;
+        curl_mfprintf(stderr, "[t-%zu] FINISHED with result %d\n",
+                      i, (int)result);
+      }
+      else if(curlx_timediff_ms(curlx_now(), started) > (timediff_t)30000) {
+        curl_mfprintf(stderr, "[t-%zu] transfer timed out\n", i);
+        result = (CURLcode)1;
+        goto cleanup;
+      }
+    }
+    curl_multi_remove_handle(multi, easy);
+    curl_easy_cleanup(easy);
+
+    /* The transfer is over and its connection may not be reused. A
+     * graceful shutdown that could not finish right away needs its
+     * socket watched by us, or it can never make progress. */
+    curl_mfprintf(stderr, "[t-%zu] sockets tracked after done: %zu\n",
+                  i, ctx.nsocks);
+    if(ctx.nsocks)
+      watched++;
+  }
+
+  /* drive the remaining shutdowns via socket events */
+  started = curlx_now();
+  while(ctx.nsocks &&
+        (curlx_timediff_ms(curlx_now(), started) < (timediff_t)5000)) {
+    if(ev_dl_roundtrip(&ctx))
+      break;
+  }
+  curl_mfprintf(stderr, "[ev] final: watched=%d socks_left=%zu\n",
+                watched, ctx.nsocks);
+
+cleanup:
+  curl_multi_cleanup(multi);
+  curl_global_cleanup();
+optcleanup:
+  curlx_free(cafile);
+  return result;
+}
diff --git a/tests/unit/Makefile.inc b/tests/unit/Makefile.inc
index c292e7a1985c..4fe2dc645b50 100644
--- a/tests/unit/Makefile.inc
+++ b/tests/unit/Makefile.inc
@@ -46,6 +46,7 @@ TESTS_C = \
   unit1961.c unit1979.c unit1980.c \
   unit2413.c \
   unit2600.c unit2601.c unit2602.c unit2603.c unit2604.c unit2605.c \
+  unit2606.c \
   unit3200.c                                             unit3205.c \
   unit3211.c unit3212.c unit3213.c unit3214.c            unit3216.c unit3219.c \
   unit3227.c \
diff --git a/tests/unit/unit2606.c b/tests/unit/unit2606.c
new file mode 100644
index 000000000000..ebe8b275fd89
--- /dev/null
+++ b/tests/unit/unit2606.c
@@ -0,0 +1,221 @@
+/***************************************************************************
+ *                                  _   _ ____  _
+ *  Project                     ___| | | |  _ \| |
+ *                             / __| | | | |_) | |
+ *                            | (__| |_| |  _ <| |___
+ *                             \___|\___/|_| \_\_____|
+ *
+ * Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
+ *
+ * This software is licensed as described in the file COPYING, which
+ * you should have received as part of this distribution. The terms
+ * are also available at https://curl.se/docs/copyright.html.
+ *
+ * You may opt to use, copy, modify, merge, publish, distribute and/or sell
+ * copies of the Software, and permit persons to whom the Software is
+ * furnished to do so, under the terms of the COPYING file.
+ *
+ * This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
+ * KIND, either express or implied.
+ *
+ * SPDX-License-Identifier: curl
+ *
+ ***************************************************************************/
+#include "unitcheck.h"
+
+#include "urldata.h"
+#include "cfilters.h"
+#include "curl_trc.h"
+#include "select.h"
+
+/* Test the gating in Curl_conn_adjust_pollset(): each filter chain
+ * of a connection must only get to adjust the pollset when the transfer
+ * wants to send or receive, or when that chain itself is connecting or
+ * shutting down. One chain being in connect or shutdown must not add
+ * poll events for the other, see #21671. */
+
+struct t2606_ctx {
+  int calls;
+  bool add_socket;
+  curl_socket_t sock;
+};
+
+static void t2606_cf_destroy(struct Curl_cfilter *cf,
+                             struct Curl_easy *data)
+{
+  (void)data;
+  /* ctx is owned by the test case, nothing to free */
+  cf->ctx = NULL;
+}
+
+static CURLcode t2606_cf_connect(struct Curl_cfilter *cf,
+                                 struct Curl_easy *data,
+                                 bool *done)
+{
+  (void)cf;
+  (void)data;
+  *done = TRUE;
+  return CURLE_OK;
+}
+
+static CURLcode t2606_cf_adjust_pollset(struct Curl_cfilter *cf,
+                                        struct Curl_easy *data,
+                                        struct easy_pollset *ps)
+{
+  struct t2606_ctx *ctx = cf->ctx;
+  ctx->calls++;
+  if(ctx->add_socket)
+    return Curl_pollset_add_in(data, ps, ctx->sock);
+  return CURLE_OK;
+}
+
+static const struct Curl_cftype t2606_cft = {
+  "TEST-PS",
+  CF_TYPE_IP_CONNECT,
+  CURL_LOG_LVL_NONE,
+  t2606_cf_destroy,
+  t2606_cf_connect,
+  Curl_cf_def_shutdown,
+  t2606_cf_adjust_pollset,
+  Curl_cf_def_data_pending,
+  Curl_cf_def_send,
+  Curl_cf_def_recv,
+  Curl_cf_def_cntrl,
+  Curl_cf_def_conn_is_alive,
+  Curl_cf_def_conn_keep_alive,
+  Curl_cf_def_query,
+};
+
+struct t2606_case {
+  int id;
+  bool has_c1;        /* a SECONDARYSOCKET chain exists */
+  bool c0_connected;
+  bool c1_connected;
+  bool c0_shutdown;   /* shutdown started on the chain */
+  bool c1_shutdown;
+  bool xfer_io;       /* the pollset holds a transfer socket already */
+  bool c0_adds;       /* chain 0 adds a socket when called */
+  int exp_c0_calls;
+  int exp_c1_calls;
+};
+
+static CURLcode t2606_setup(CURL **easy)
+{
+  CURLcode result = CURLE_OK;
+
+  global_init(CURL_GLOBAL_ALL);
+  *easy = curl_easy_init();
+  if(!*easy) {
+    curl_global_cleanup();
+    return CURLE_OUT_OF_MEMORY;
+  }
+  return result;
+}
+
+static void t2606_stop(CURL *easy)
+{
+  curl_easy_cleanup(easy);
+  curl_global_cleanup();
+}
+
+static void t2606_run(struct Curl_easy *data, const struct t2606_case *tc)
+{
+  struct connectdata *conn;
+  struct Curl_cfilter *cf0 = NULL, *cf1 = NULL;
+  struct t2606_ctx ctx0, ctx1;
+  struct easy_pollset ps;
+  CURLcode result;
+  char msg[128];
+
+  conn = curlx_calloc(1, sizeof(*conn));
+  abort_if(!conn, "could not allocate a connection");
+
+  memset(&ctx0, 0, sizeof(ctx0));
+  memset(&ctx1, 0, sizeof(ctx1));
+  ctx0.sock = 42;
+  ctx0.add_socket = tc->c0_adds;
+  ctx1.sock = 43;
+
+  result = Curl_cf_create(&cf0, &t2606_cft, &ctx0);
+  fail_unless(!result, "creating filter chain 0 failed");
+  if(!result) {
+    Curl_conn_cf_add(data, conn, FIRSTSOCKET, cf0);
+    cf0->connected = tc->c0_connected;
+    if(tc->c0_shutdown)
+      conn->shutdown.start[FIRSTSOCKET] = curlx_now();
+  }
+
+  if(tc->has_c1) {
+    result = Curl_cf_create(&cf1, &t2606_cft, &ctx1);
+    fail_unless(!result, "creating filter chain 1 failed");
+    if(!result) {
+      Curl_conn_cf_add(data, conn, SECONDARYSOCKET, cf1);
+      cf1->connected = tc->c1_connected;
+      if(tc->c1_shutdown)
+        conn->shutdown.start[SECONDARYSOCKET] = curlx_now();
+    }
+  }
+
+  Curl_pollset_init(&ps);
+  if(tc->xfer_io) {
+    result = Curl_pollset_add_in(data, &ps, 7);
+    fail_unless(!result, "preloading the pollset failed");
+  }
+
+  result = Curl_conn_adjust_pollset(data, conn, &ps);
+
+  curl_msprintf(msg, "case %d: adjust_pollset failed", tc->id);
+  fail_unless(!result, msg);
+  curl_msprintf(msg, "case %d: chain 0 called %d times, expected %d",
+                tc->id, ctx0.calls, tc->exp_c0_calls);
+  fail_unless(ctx0.calls == tc->exp_c0_calls, msg);
+  curl_msprintf(msg, "case %d: chain 1 called %d times, expected %d",
+                tc->id, ctx1.calls, tc->exp_c1_calls);
+  fail_unless(ctx1.calls == tc->exp_c1_calls, msg);
+  if(tc->c0_adds && tc->exp_c0_calls) {
+    curl_msprintf(msg, "case %d: chain 0 did not add its socket", tc->id);
+    fail_unless(ps.n > 0, msg);
+  }
+
+  Curl_pollset_cleanup(&ps);
+  Curl_conn_cf_discard_all(data, conn, SECONDARYSOCKET);
+  Curl_conn_cf_discard_all(data, conn, FIRSTSOCKET);
+unit_test_abort:
+  curlx_free(conn);
+}
+
+static CURLcode test_unit2606(const char *arg)
+{
+  CURL *easy;
+
+  UNITTEST_BEGIN(t2606_setup(&easy))
+
+  static const struct t2606_case TEST_CASES[] = {
+    /* both chains connected and quiet, transfer idle: nothing runs */
+    { 1, TRUE,  TRUE,  TRUE,  FALSE, FALSE, FALSE, FALSE, 0, 0 },
+    /* transfer wants IO: both chains run */
+    { 2, TRUE,  TRUE,  TRUE,  FALSE, FALSE, TRUE,  FALSE, 1, 1 },
+    /* chain 0 connecting, chain 1 connected and quiet: only chain 0 */
+    { 3, TRUE,  FALSE, TRUE,  FALSE, FALSE, FALSE, FALSE, 1, 0 },
+    /* chain 1 connecting, chain 0 connected and quiet: only chain 1 */
+    { 4, TRUE,  TRUE,  FALSE, FALSE, FALSE, FALSE, FALSE, 0, 1 },
+    /* chain 0 shutting down, chain 1 connected and quiet: only chain 0 */
+    { 5, TRUE,  TRUE,  TRUE,  TRUE,  FALSE, FALSE, FALSE, 1, 0 },
+    /* chain 1 shutting down, chain 0 connected and quiet: only chain 1 */
+    { 6, TRUE,  TRUE,  TRUE,  FALSE, TRUE,  FALSE, FALSE, 0, 1 },
+    /* single connected quiet chain, transfer idle: nothing runs */
+    { 7, FALSE, TRUE,  FALSE, FALSE, FALSE, FALSE, FALSE, 0, 0 },
+    /* single chain shutting down: runs */
+    { 8, FALSE, TRUE,  FALSE, TRUE,  FALSE, FALSE, FALSE, 1, 0 },
+    /* a socket added by connecting chain 0 must not run quiet chain 1 */
+    { 9, TRUE,  FALSE, TRUE,  FALSE, FALSE, FALSE, TRUE,  1, 0 },
+  };
+
+  size_t i;
+
+  for(i = 0; i < CURL_ARRAYSIZE(TEST_CASES); ++i) {
+    t2606_run(easy, &TEST_CASES[i]);
+  }
+
+  UNITTEST_END(t2606_stop(easy))
+}

