https://curl.se/mail/lib-2026-10/0012.html
https://github.com/curl/curl/pull/23412
https://github.com/curl/curl/commit/ef6b17cf12d4af4dfb2af4f9bc35861772be71c7
From ef6b17cf12d4af4dfb2af4f9bc35861772be71c7 Mon Sep 17 00:00:00 2001
From: Rhino <195818135+1rhino2@users.noreply.github.com>
Date: Thu, 15 Oct 2026 06:41:54 -0400
Subject: [PATCH] conncache: poll an idle connection for pending input on every
reuse
Since 2f1e94eaed the alive check in Curl_cpool_conn_seems_healthy() is
skipped when it last ran less than a second ago, and that also skipped
the pending input check that follows it. An idle HTTP/1.1 connection
that had received unsolicited data from the server was then reused
within that second and the stray bytes came back as the response to the
next request, so every later response on that connection was shifted by
one. 8.21.0 refused to reuse a connection with input pending on every
reuse, and with 8.22.0 the tool reproduces it at a 900 ms gap between
requests while 1100 ms gets a fresh connection.
RFC 9112 section 9.2 says a client must not treat data received with no
outstanding request as a response, so poll for pending input on every
reuse of an idle non-multiplexed connection and keep the once a second
limit only for the other checks, the protocol specific
connection_is_dead callback and multiplexed connections.
Test 1701 reproduces it with sws sending a second response after the
first one completed and the tool starting the next request inside the
window.
Follow-up to 2f1e94eaed
Ref: #22169
Bug: https://hackerone.com/reports/4098761
Closes #23412
---
lib/conncache.c | 14 ++++++--
tests/data/Makefile.am | 1 +
tests/data/test1701 | 75 ++++++++++++++++++++++++++++++++++++++++++
3 files changed, 87 insertions(+), 3 deletions(-)
create mode 100644 tests/data/test1701
diff --git a/lib/conncache.c b/lib/conncache.c
index 2f759bed2f01..157db0f2d83a 100644
--- a/lib/conncache.c
+++ b/lib/conncache.c
@@ -1044,11 +1044,20 @@ bool Curl_cpool_conn_seems_healthy(struct connectdata *conn,
{
struct Curl_easy *admin;
bool healthy = TRUE;
+ bool poll_input;
DEBUGASSERT(!data->conn);
if(cpool_conn_can_be_closed(conn) && cpool_conn_too_old(data, conn, pnow))
return FALSE;
- if((curlx_ptimediff_ms(pnow, &conn->created) - conn->lastchecked_ms) < 1000)
+
+ /* An idle, non-multiplexed connection must not be reused when it has
+ * input pending (RFC 9112, section 9.2), so that is polled on every
+ * reuse. The once a second limit only applies to the other checks. */
+ poll_input = cpool_conn_can_be_closed(conn) &&
+ !Curl_conn_is_multiplex(conn, FIRSTSOCKET) &&
+ !conn->scheme->run->connection_is_dead;
+ if(!poll_input &&
+ (curlx_ptimediff_ms(pnow, &conn->created) - conn->lastchecked_ms) < 1000)
return TRUE;
admin = Curl_get_admin(data);
@@ -1063,8 +1072,7 @@ bool Curl_cpool_conn_seems_healthy(struct connectdata *conn,
Curl_attach_connection(admin, conn, FALSE);
healthy = Curl_conn_is_alive(admin, conn, &input_pending);
Curl_detach_connection(admin);
- if(healthy && input_pending && cpool_conn_can_be_closed(conn) &&
- !Curl_conn_is_multiplex(conn, FIRSTSOCKET)) {
+ if(healthy && input_pending && poll_input) {
/* Non-multiplexed connections without attached transfers should
* not have input pending. The input might be a TLS Notify Close,
* for all we know. */
diff --git a/tests/data/Makefile.am b/tests/data/Makefile.am
index d066f2db583d..0f4ce61062c2 100644
--- a/tests/data/Makefile.am
+++ b/tests/data/Makefile.am
@@ -1716,6 +1716,7 @@ TESTCASES = \
test1693 \
\
test1700 \
+ test1701 \
test1702 \
test1703 \
test1704 \
diff --git a/tests/data/test1701 b/tests/data/test1701
new file mode 100644
index 000000000000..e4e3d044302a
--- /dev/null
+++ b/tests/data/test1701
@@ -0,0 +1,75 @@
+
+
+
+
+HTTP
+HTTP GET
+DELAY
+
+
+# Server-side
+
+# the first response is exactly 60 bytes so that with writedelay the server
+# sends it in three 20 byte writes and the unsolicited second response only
+# starts arriving after the first transfer is complete and the connection
+# has been returned to the pool
+
+HTTP/1.1 200 OK
+Funny-head: yesyes
+Content-Length: 6
+
+first
+HTTP/1.1 200 OK
+Content-Length: 6
+
+stray
+
+
+HTTP/1.1 200 OK
+Content-Length: 7
+
+second
+
+
+writedelay: 150
+
+
+
+# Client-side
+
+
+http
+
+
+HTTP/1.1 idle connection with unsolicited data is not reused
+
+# --rate 1/s starts the second request 1000 ms after the first one started,
+# which is about 700 ms after the first response completed and the
+# connection went back to the pool, inside the one second window where the
+# pool used to skip the alive check. The stray response has fully arrived
+# about 750 ms in.
+
+http://%HOSTIP:%HTTPPORT/%TESTNUMBER http://%HOSTIP:%HTTPPORT/%TESTNUMBER0002 --rate 1/s
+
+
+
+# Verify data after the test has been "shot"
+
+
+GET /%TESTNUMBER HTTP/1.1
+Host: %HOSTIP:%HTTPPORT
+User-Agent: curl/%VERSION
+Accept: */*
+
+GET /%TESTNUMBER0002 HTTP/1.1
+Host: %HOSTIP:%HTTPPORT
+User-Agent: curl/%VERSION
+Accept: */*
+
+
+
+first
+second
+
+
+