Commit 296e8eac578 for php

commit 296e8eac57883cfd54e84d80201ed7e5de9f7ee1
Author: David Carlier <devnexen@gmail.com>
Date:   Thu Oct 22 12:05:20 2026 +0100

    pdo_pgsql_request_shutdown() leaves statements of freed PDO objects prepared.

    Track prepared statement names on the handle and close the leftovers.

    Co-authored-by: Artem Ukrainskiy <art.ukrainskiy@gmail.com>

diff --git a/ext/pdo_pgsql/pgsql_driver.c b/ext/pdo_pgsql/pgsql_driver.c
index 38d2044290d..548a47c3ee8 100644
--- a/ext/pdo_pgsql/pgsql_driver.c
+++ b/ext/pdo_pgsql/pgsql_driver.c
@@ -251,6 +251,11 @@ static void pgsql_handle_closer(pdo_dbh_t *dbh) /* {{{ */
 			pefree(H->lob_streams, dbh->is_persistent);
 			H->lob_streams = NULL;
 		}
+		if (H->prepared_stmts) {
+			zend_hash_destroy(H->prepared_stmts);
+			pefree(H->prepared_stmts, 1);
+			H->prepared_stmts = NULL;
+		}
 		pdo_pgsql_cleanup_notice_callback(H);
 		if (H->server) {
 			PQfinish(H->server);
@@ -1398,6 +1403,21 @@ static void pdo_pgsql_request_shutdown(pdo_dbh_t *dbh)
 	pdo_pgsql_db_handle *H = (pdo_pgsql_db_handle *)dbh->driver_data;

 	if(H->server) {
+		zend_string *name;
+
+		ZEND_HASH_MAP_FOREACH_STR_KEY(H->prepared_stmts, name) {
+#ifndef HAVE_PQCLOSEPREPARED
+			char *q;
+			spprintf(&q, 0, "DEALLOCATE %s", ZSTR_VAL(name));
+			res = PQexec(H->server, q);
+			efree(q);
+#else
+			res = PQclosePrepared(H->server, ZSTR_VAL(name));
+#endif
+			PQclear(res);
+		} ZEND_HASH_FOREACH_END();
+		zend_hash_clean(H->prepared_stmts);
+
 		res = PQexec(H->server, "CLOSE ALL; SET SESSION AUTHORIZATION DEFAULT; RESET ALL; UNLISTEN *; "
 			"SELECT pg_advisory_unlock_all(); DISCARD TEMP; DISCARD SEQUENCES");

@@ -1525,6 +1545,10 @@ static int pdo_pgsql_handle_factory(pdo_dbh_t *dbh, zval *driver_options) /* {{{
 	H->server = PQconnectdb(ZSTR_VAL(conn_str.s));
 	H->lob_streams = (HashTable *) pemalloc(sizeof(HashTable), dbh->is_persistent);
 	zend_hash_init(H->lob_streams, 0, NULL, NULL, 1);
+	if (dbh->is_persistent) {
+		H->prepared_stmts = (HashTable *) pemalloc(sizeof(HashTable), 1);
+		zend_hash_init(H->prepared_stmts, 0, NULL, NULL, 1);
+	}

 	if (tmp_user) {
 		zend_string_release_ex(tmp_user, 0);
diff --git a/ext/pdo_pgsql/pgsql_statement.c b/ext/pdo_pgsql/pgsql_statement.c
index 2f072553493..de7d03bc726 100644
--- a/ext/pdo_pgsql/pgsql_statement.c
+++ b/ext/pdo_pgsql/pgsql_statement.c
@@ -190,9 +190,11 @@ static void pgsql_stmt_finish(pdo_pgsql_stmt *S, int fin_mode)
 		res = PQclosePrepared(H->server, S->stmt_name);
 #endif
 		if (res) {
+			if (H->prepared_stmts && PQresultStatus(res) == PGRES_COMMAND_OK) {
+				zend_hash_str_del(H->prepared_stmts, S->stmt_name, strlen(S->stmt_name));
+			}
 			PQclear(res);
 		}
-
 		S->is_prepared = false;
 	}
 }
@@ -339,6 +341,9 @@ static int pgsql_stmt_execute(pdo_stmt_t *stmt)
 				case PGRES_TUPLES_OK:
 					/* it worked */
 					S->is_prepared = true;
+					if (H->prepared_stmts) {
+						zend_hash_str_add_empty_element(H->prepared_stmts, S->stmt_name, strlen(S->stmt_name));
+					}
 					PQclear(S->result);
 					S->result = NULL;
 					break;
diff --git a/ext/pdo_pgsql/php_pdo_pgsql_int.h b/ext/pdo_pgsql/php_pdo_pgsql_int.h
index dc71d5ae7a5..9ca729cd44a 100644
--- a/ext/pdo_pgsql/php_pdo_pgsql_int.h
+++ b/ext/pdo_pgsql/php_pdo_pgsql_int.h
@@ -45,6 +45,7 @@ typedef struct {
 	bool		emulate_prepares;
 	bool		disable_prepares;
 	HashTable       *lob_streams;
+	HashTable       *prepared_stmts;
 	zend_fcall_info_cache *notice_callback;
 	bool		default_fetching_laziness;
 	zend_long	default_chunk_size;
diff --git a/ext/pdo_pgsql/tests/gh24461_aborted_transaction_close.phpt b/ext/pdo_pgsql/tests/gh24461_aborted_transaction_close.phpt
new file mode 100644
index 00000000000..9c07b739b9b
--- /dev/null
+++ b/ext/pdo_pgsql/tests/gh24461_aborted_transaction_close.phpt
@@ -0,0 +1,40 @@
+--TEST--
+GH-24461 (Persistent connections: statements closed inside an aborted transaction are closed on reset)
+--EXTENSIONS--
+pdo_pgsql
+--SKIPIF--
+<?php
+require __DIR__ . '/config.inc';
+require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
+PDOTest::skip();
+?>
+--FILE--
+<?php
+putenv('PDOTEST_ATTR='.serialize([PDO::ATTR_PERSISTENT => true, PDO::ATTR_EMULATE_PREPARES => false]));
+
+require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
+
+$pdo = PDOTest::test_factory(__DIR__ . '/common.phpt');
+$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+$pid1 = (int) $pdo->query('SELECT pg_backend_pid()')->fetchColumn();
+$pdo->beginTransaction();
+$stmt = $pdo->prepare("SELECT 'gh24461_aborted'");
+$stmt->execute();
+try {
+    $pdo->exec('SELECT 1/0');
+} catch (PDOException $e) {
+}
+$stmt = null;
+$pdo->rollBack();
+$pdo = null;
+
+$pdo = PDOTest::test_factory(__DIR__ . '/common.phpt');
+$pid2 = (int) $pdo->query('SELECT pg_backend_pid()')->fetchColumn();
+var_dump($pid1 === $pid2);
+var_dump($pdo->query("SELECT count(*) FROM pg_prepared_statements WHERE statement = 'SELECT ''gh24461_aborted'''")->fetchColumn());
+?>
+--CREDITS--
+Artem Ukrainskiy
+--EXPECT--
+bool(true)
+string(1) "0"
diff --git a/ext/pdo_pgsql/tests/gh24461_leftover_statement.phpt b/ext/pdo_pgsql/tests/gh24461_leftover_statement.phpt
new file mode 100644
index 00000000000..a418e3ad358
--- /dev/null
+++ b/ext/pdo_pgsql/tests/gh24461_leftover_statement.phpt
@@ -0,0 +1,37 @@
+--TEST--
+GH-24461 (Persistent connections: statements outliving their PDO object are closed on reset)
+--EXTENSIONS--
+pdo_pgsql
+--SKIPIF--
+<?php
+require __DIR__ . '/config.inc';
+require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
+PDOTest::skip();
+?>
+--FILE--
+<?php
+putenv('PDOTEST_ATTR='.serialize([PDO::ATTR_PERSISTENT => true]));
+
+require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
+
+class HoldingPDO extends PDO {
+    public $stmt;
+}
+
+$pdo1 = PDOTest::test_factory(__DIR__ . '/common.phpt', HoldingPDO::class);
+$pid1 = (int) $pdo1->query('SELECT pg_backend_pid()')->fetchColumn();
+$pdo1->stmt = $pdo1->prepare("SELECT 'gh24461'");
+$pdo1->stmt->execute();
+
+unset($pdo1);
+var_dump(gc_collect_cycles() > 0);
+
+$pdo2 = PDOTest::test_factory(__DIR__ . '/common.phpt');
+$pid2 = (int) $pdo2->query('SELECT pg_backend_pid()')->fetchColumn();
+var_dump($pid1 === $pid2);
+var_dump($pdo2->query("SELECT count(*) FROM pg_prepared_statements WHERE statement = 'SELECT ''gh24461'''")->fetchColumn());
+?>
+--EXPECT--
+bool(true)
+bool(true)
+string(1) "0"