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