Commit b782a27242b for php.net

commit b782a27242b49e182811bc23bce40ce3c77cd139
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Thu Aug 13 09:13:00 2026 -0400

    Fix leak when persistent PDO liveness check fails

    zend_list_close() only runs list_dtor_ex, which is NULL for PDO, so a
    dead cached handle with refcount 1 was evicted and never freed.
    Delete the persistent_list entry so php_pdo_pdbh_dtor runs. Live
    handles (refcount > 1) still evict without freeing.

    Closes GH-23249

diff --git a/NEWS b/NEWS
index d9f2e25bcdf..7216f5c5a7d 100644
--- a/NEWS
+++ b/NEWS
@@ -26,6 +26,10 @@ PHP                                                                        NEWS
 - Opcache:
   . Fixed opcache.protect_memory race under ZTS. (realFlowControl)

+- PDO:
+  . Fixed a leak when a persistent connection failed a liveness check
+    with no other live PDO handle. (iliaal)
+
 - Standard:
   . Fixed a memory leak in array_merge_recursive() when the recursive merge of
     an object converted to an array fails. (David Carlier)
diff --git a/ext/pdo/pdo_dbh.c b/ext/pdo/pdo_dbh.c
index 782639be075..9f3d20745df 100644
--- a/ext/pdo/pdo_dbh.c
+++ b/ext/pdo/pdo_dbh.c
@@ -411,9 +411,12 @@ PDO_API void php_pdo_internal_construct_driver(INTERNAL_FUNCTION_PARAMETERS, zen

 					/* is the connection still alive ? */
 					if (pdbh->methods->check_liveness && FAILURE == (pdbh->methods->check_liveness)(pdbh)) {
-						/* nope... need to kill it */
-						pdbh->refcount--;
-						zend_list_close(le);
+						if (pdbh->refcount > 1) {
+							pdbh->refcount--;
+							zend_list_close(le);
+						} else {
+							zend_hash_str_del(&EG(persistent_list), hashkey, plen);
+						}
 						pdbh = NULL;
 					}
 				}
diff --git a/ext/pdo_mysql/tests/persistent_liveness_evict.phpt b/ext/pdo_mysql/tests/persistent_liveness_evict.phpt
new file mode 100644
index 00000000000..545c16ebdbd
--- /dev/null
+++ b/ext/pdo_mysql/tests/persistent_liveness_evict.phpt
@@ -0,0 +1,42 @@
+--TEST--
+Persistent reconnect after a dead cached handle does not leak the old pdo_dbh_t
+--EXTENSIONS--
+pdo_mysql
+--ENV--
+VALGRIND_OPTS=--leak-check=full
+--SKIPIF--
+<?php
+require_once __DIR__ . '/inc/mysql_pdo_test.inc';
+MySQLPDOTest::skip();
+if (!MySQLPDOTest::isPDOMySQLnd()) {
+    die('skip mysqlnd only (libmysql mysql_ping auto-reconnects)');
+}
+?>
+--FILE--
+<?php
+require_once __DIR__ . '/inc/mysql_pdo_test.inc';
+
+$dsn = MySQLPDOTest::getDSN();
+$user = PDO_MYSQL_TEST_USER;
+$pass = PDO_MYSQL_TEST_PASS;
+$opts = [
+    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+    PDO::ATTR_PERSISTENT => true,
+];
+
+$cached = new PDO($dsn, $user, $pass, $opts);
+$id = (int) $cached->query('SELECT CONNECTION_ID()')->fetchColumn();
+unset($cached);
+
+$killer = new PDO($dsn, $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
+$killer->exec('KILL ' . $id);
+unset($killer);
+
+$next = new PDO($dsn, $user, $pass, $opts);
+$nextId = (int) $next->query('SELECT CONNECTION_ID()')->fetchColumn();
+var_dump($nextId !== $id);
+echo $next->query('SELECT 1')->fetchColumn(), "\n";
+?>
+--EXPECT--
+bool(true)
+1