From c8867001353c3c10d797cd33b6aa58b9d54a5640 Mon Sep 17 00:00:00 2001 From: David Carlier Date: Tue, 20 Oct 2026 19:30:39 +0100 Subject: [PATCH 1/4] ext/pdo: persistent_shutdown run while the connection is still shared. Only call it for the last PDO object, like the rollback before it. --- ext/pdo/pdo_dbh.c | 2 +- .../tests/gh24461_shared_connection.phpt | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 ext/pdo_pgsql/tests/gh24461_shared_connection.phpt diff --git a/ext/pdo/pdo_dbh.c b/ext/pdo/pdo_dbh.c index 58bb3b1727f6..9ccaebd5e8bb 100644 --- a/ext/pdo/pdo_dbh.c +++ b/ext/pdo/pdo_dbh.c @@ -1606,7 +1606,7 @@ static void pdo_dbh_free_storage(zend_object *std) dbh->in_txn = false; } - if (dbh->is_persistent && dbh->driver_data && dbh->methods && dbh->methods->persistent_shutdown) { + if (dbh->is_persistent && dbh->refcount <= 2 && dbh->driver_data && dbh->methods && dbh->methods->persistent_shutdown) { dbh->methods->persistent_shutdown(dbh); } zend_object_std_dtor(std); diff --git a/ext/pdo_pgsql/tests/gh24461_shared_connection.phpt b/ext/pdo_pgsql/tests/gh24461_shared_connection.phpt new file mode 100644 index 000000000000..f341beecb081 --- /dev/null +++ b/ext/pdo_pgsql/tests/gh24461_shared_connection.phpt @@ -0,0 +1,46 @@ +--TEST-- +GH-24461 (Persistent connections: session state is not reset while another PDO object still uses the connection) +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- + +--FILE-- + true])); + +require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc'; + +$pdo1 = PDOTest::test_factory(__DIR__ . '/common.phpt'); +$pdo2 = PDOTest::test_factory(__DIR__ . '/common.phpt'); + +$pid1 = (int) $pdo1->query('SELECT pg_backend_pid()')->fetchColumn(); +$pid2 = (int) $pdo2->query('SELECT pg_backend_pid()')->fetchColumn(); +var_dump($pid1 === $pid2); + +$pdo2->exec("SET application_name = 'gh24461'"); +$pdo2->exec('CREATE TEMP TABLE gh24461_shared (i int)'); +$pdo2->query('SELECT pg_advisory_lock(24461)'); +$stmt = $pdo2->prepare('SELECT 42'); +$stmt->execute(); +$stmt->closeCursor(); + +$pdo1 = null; + +var_dump($pdo2->query('SHOW application_name')->fetchColumn()); +var_dump($pdo2->query('SELECT count(*) FROM gh24461_shared')->fetchColumn()); +var_dump($pdo2->query("SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND objid = 24461 AND pid = pg_backend_pid()")->fetchColumn()); +$stmt->execute(); +var_dump($stmt->fetchColumn()); + +$pdo2->query('SELECT pg_advisory_unlock_all()'); +?> +--EXPECT-- +bool(true) +string(7) "gh24461" +string(1) "0" +string(1) "1" +string(2) "42" From b1e8704320c686850e58d0c5e4c62b9baeb30732 Mon Sep 17 00:00:00 2001 From: David Carlier Date: Tue, 20 Oct 2026 19:30:43 +0100 Subject: [PATCH 2/4] ext/pdo_pgsql: persistent connection reset drops prepared statements and plans. Fix #24461 Keep the rest of DISCARD ALL, and reset the connection if it fails. --- ext/pdo_pgsql/pgsql_driver.c | 8 ++++--- ext/pdo_pgsql/tests/gh24461.phpt | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 ext/pdo_pgsql/tests/gh24461.phpt diff --git a/ext/pdo_pgsql/pgsql_driver.c b/ext/pdo_pgsql/pgsql_driver.c index cbfd55fc1aa6..38d2044290dd 100644 --- a/ext/pdo_pgsql/pgsql_driver.c +++ b/ext/pdo_pgsql/pgsql_driver.c @@ -1398,11 +1398,13 @@ 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) { - res = PQexec(H->server, "DISCARD ALL"); + res = PQexec(H->server, "CLOSE ALL; SET SESSION AUTHORIZATION DEFAULT; RESET ALL; UNLISTEN *; " + "SELECT pg_advisory_unlock_all(); DISCARD TEMP; DISCARD SEQUENCES"); - if(res) { - PQclear(res); + if (PQresultStatus(res) != PGRES_COMMAND_OK && PQstatus(H->server) == CONNECTION_OK) { + PQreset(H->server); } + PQclear(res); } } diff --git a/ext/pdo_pgsql/tests/gh24461.phpt b/ext/pdo_pgsql/tests/gh24461.phpt new file mode 100644 index 000000000000..600a58f51230 --- /dev/null +++ b/ext/pdo_pgsql/tests/gh24461.phpt @@ -0,0 +1,40 @@ +--TEST-- +GH-24461 (Persistent connections: session reset keeps prepared statements) +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- + +--FILE-- + true])); + +require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc'; + +$pdo1 = PDOTest::test_factory(__DIR__ . '/common.phpt'); +$pid1 = (int) $pdo1->query('SELECT pg_backend_pid()')->fetchColumn(); + +$pdo1->exec("SET application_name = 'gh24461'"); +$pdo1->exec('CREATE TEMP TABLE gh24461_tmp (i int)'); +$pdo1->exec('PREPARE gh24461_stmt AS SELECT 42'); + +$pdo1 = null; + +$pdo2 = PDOTest::test_factory(__DIR__ . '/common.phpt'); +$pid2 = (int) $pdo2->query('SELECT pg_backend_pid()')->fetchColumn(); +var_dump($pid1 === $pid2); + +var_dump($pdo2->query('SHOW application_name')->fetchColumn()); +var_dump($pdo2->query("SELECT to_regclass('pg_temp.gh24461_tmp')")->fetchColumn()); +var_dump($pdo2->query('EXECUTE gh24461_stmt')->fetchColumn()); + +$pdo2->exec('DEALLOCATE gh24461_stmt'); +?> +--EXPECT-- +bool(true) +string(0) "" +NULL +string(2) "42" From a76c7c9a253ade2933912222b7ce1e7a83ad4606 Mon Sep 17 00:00:00 2001 From: David Carlier Date: Tue, 20 Oct 2026 20:27:13 +0100 Subject: [PATCH 3/4] pdo_pgsql_request_shutdown() leaves statements of freed PDO objects prepared. Track prepared statement names on the handle and close the leftovers. --- ext/pdo_pgsql/pgsql_driver.c | 24 ++++++++++++ ext/pdo_pgsql/pgsql_statement.c | 6 +++ ext/pdo_pgsql/php_pdo_pgsql_int.h | 1 + .../tests/gh24461_leftover_statement.phpt | 37 +++++++++++++++++++ 4 files changed, 68 insertions(+) create mode 100644 ext/pdo_pgsql/tests/gh24461_leftover_statement.phpt diff --git a/ext/pdo_pgsql/pgsql_driver.c b/ext/pdo_pgsql/pgsql_driver.c index 38d2044290dd..548a47c3ee83 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 2f0725534937..e5812132034f 100644 --- a/ext/pdo_pgsql/pgsql_statement.c +++ b/ext/pdo_pgsql/pgsql_statement.c @@ -193,6 +193,9 @@ static void pgsql_stmt_finish(pdo_pgsql_stmt *S, int fin_mode) PQclear(res); } + if (H->prepared_stmts) { + zend_hash_str_del(H->prepared_stmts, S->stmt_name, strlen(S->stmt_name)); + } S->is_prepared = false; } } @@ -339,6 +342,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 dc71d5ae7a5f..9ca729cd44a5 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_leftover_statement.phpt b/ext/pdo_pgsql/tests/gh24461_leftover_statement.phpt new file mode 100644 index 000000000000..a418e3ad358f --- /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-- + +--FILE-- + 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" From 88d39d6e4b49895296a41b24ae20c8fa6141cc23 Mon Sep 17 00:00:00 2001 From: David Carlier Date: Wed, 21 Oct 2026 08:45:55 +0100 Subject: [PATCH 4/4] apply suggestions Co-authored-by: Artem Ukrainskiy --- ext/pdo_pgsql/pgsql_statement.c | 7 ++-- .../gh24461_aborted_transaction_close.phpt | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 ext/pdo_pgsql/tests/gh24461_aborted_transaction_close.phpt diff --git a/ext/pdo_pgsql/pgsql_statement.c b/ext/pdo_pgsql/pgsql_statement.c index e5812132034f..de7d03bc7264 100644 --- a/ext/pdo_pgsql/pgsql_statement.c +++ b/ext/pdo_pgsql/pgsql_statement.c @@ -190,12 +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); } - - if (H->prepared_stmts) { - zend_hash_str_del(H->prepared_stmts, S->stmt_name, strlen(S->stmt_name)); - } S->is_prepared = false; } } 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 000000000000..9c07b739b9b7 --- /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-- + +--FILE-- + 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"