From e5dab772d688e9f68095b87ce155ed62280f2540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E7=94=B0=20=E6=86=B2=E5=A4=AA=E9=83=8E?= Date: Thu, 6 Aug 2026 12:07:06 +0000 Subject: [PATCH 1/5] ext/pdo_pgsql: End a COPY before draining a lazy fetch --- NEWS | 4 +++ ext/pdo_pgsql/pgsql_statement.c | 27 ++++++++++++++++++ ext/pdo_pgsql/tests/lazy_fetch_copy.phpt | 35 ++++++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 ext/pdo_pgsql/tests/lazy_fetch_copy.phpt diff --git a/NEWS b/NEWS index 299678d0a008..d5c990b455c8 100644 --- a/NEWS +++ b/NEWS @@ -51,6 +51,10 @@ PHP NEWS . Fixed bug GH-23016 (NULL values in long columns come back as garbage binary strings). (Calvin Buckley, iliaal) +- PDO_PGSQL: + . Fixed an infinite loop when cleaning up a lazy fetch + (PDO::ATTR_PREFETCH => 0) left in a COPY. (KentarouTakeda) + - Reflection: . Fixed bug GH-22905 (Reflection exception messages truncate on null bytes). (DanielEScherzer) diff --git a/ext/pdo_pgsql/pgsql_statement.c b/ext/pdo_pgsql/pgsql_statement.c index 89f713ffcbff..8782c125570b 100644 --- a/ext/pdo_pgsql/pgsql_statement.c +++ b/ext/pdo_pgsql/pgsql_statement.c @@ -90,8 +90,35 @@ static void pgsql_stmt_finish(pdo_pgsql_stmt *S, int fin_mode) // instead of discarding results we could store them to their statement // so that their fetch() will get them (albeit not in lazy mode anymore). while ((S->result = PQgetResult(H->server))) { + ExecStatusType status = PQresultStatus(S->result); + PQclear(S->result); S->result = NULL; + + /* PQgetResult() keeps handing out the same result while the + * connection is copying: only these calls can end it */ + if (status == PGRES_COPY_IN || status == PGRES_COPY_BOTH) { + /* fail a copy in, so that abandoning a statement cannot + * commit it; a replication stream only accepts a clean end */ + const char *error = status == PGRES_COPY_IN + ? "COPY terminated by PDO" + : NULL; + + if (PQputCopyEnd(H->server, error) < 0) { + break; + } + } + if (status == PGRES_COPY_OUT || status == PGRES_COPY_BOTH) { + char *buf; + int nbytes; + + while ((nbytes = PQgetCopyData(H->server, &buf, 0)) > 0) { + PQfreemem(buf); + } + if (nbytes < -1) { + break; + } + } } S->is_running_unbuffered = false; } diff --git a/ext/pdo_pgsql/tests/lazy_fetch_copy.phpt b/ext/pdo_pgsql/tests/lazy_fetch_copy.phpt new file mode 100644 index 000000000000..91321e2bcde2 --- /dev/null +++ b/ext/pdo_pgsql/tests/lazy_fetch_copy.phpt @@ -0,0 +1,35 @@ +--TEST-- +PDO PgSQL a lazy fetch left in a COPY does not hang the connection cleanup +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT); +$pdo->setAttribute(PDO::ATTR_PREFETCH, 0); +$pdo->exec("CREATE TEMPORARY TABLE lazy_fetch_copy (i int)"); + +foreach ([ + 'COPY OUT' => "COPY (SELECT 1) TO STDOUT", + 'COPY IN' => "COPY lazy_fetch_copy FROM STDIN", +] as $label => $sql) { + $copy = $pdo->prepare($sql); + $copy->execute(); + + $stmt = $pdo->prepare("VALUES (1), (2)"); + $stmt->execute(); + echo "$label: "; + var_dump((bool) $stmt->fetchAll()); +} +?> +--EXPECT-- +COPY OUT: bool(true) +COPY IN: bool(true) From 472202768e560a489898f67e5c21d6448534e127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E7=94=B0=20=E6=86=B2=E5=A4=AA=E9=83=8E?= Date: Thu, 6 Aug 2026 12:07:31 +0000 Subject: [PATCH 2/5] ext/pdo_pgsql: Clear the connection's pointer to a destroyed statement --- NEWS | 2 ++ ext/pdo_pgsql/pgsql_statement.c | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/NEWS b/NEWS index d5c990b455c8..449032dce642 100644 --- a/NEWS +++ b/NEWS @@ -54,6 +54,8 @@ PHP NEWS - PDO_PGSQL: . Fixed an infinite loop when cleaning up a lazy fetch (PDO::ATTR_PREFETCH => 0) left in a COPY. (KentarouTakeda) + . Fixed a use-after-free when a lazy statement with emulated or disabled + prepares is destroyed. (KentarouTakeda) - Reflection: . Fixed bug GH-22905 (Reflection exception messages truncate on null bytes). diff --git a/ext/pdo_pgsql/pgsql_statement.c b/ext/pdo_pgsql/pgsql_statement.c index 8782c125570b..7fec6e37539f 100644 --- a/ext/pdo_pgsql/pgsql_statement.c +++ b/ext/pdo_pgsql/pgsql_statement.c @@ -140,9 +140,6 @@ static void pgsql_stmt_finish(pdo_pgsql_stmt *S, int fin_mode) } S->is_prepared = false; - if (H->running_stmt == S) { - H->running_stmt = NULL; - } } } @@ -153,6 +150,10 @@ static int pgsql_stmt_dtor(pdo_stmt_t *stmt) pgsql_stmt_finish(S, FIN_DISCARD|(server_obj_usable ? FIN_CLOSE|FIN_ABORT : 0)); + if (server_obj_usable && S->H->running_stmt == S) { + S->H->running_stmt = NULL; + } + if (S->stmt_name) { efree(S->stmt_name); S->stmt_name = NULL; From ec8461ede2dfea20a338d150a33c1f74f30dc2fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E7=94=B0=20=E6=86=B2=E5=A4=AA=E9=83=8E?= Date: Thu, 6 Aug 2026 12:07:29 +0000 Subject: [PATCH 3/5] ext/pdo_pgsql: Drain the connection when a lazy fetch ends --- NEWS | 2 ++ ext/pdo_pgsql/pgsql_statement.c | 7 ++--- ext/pdo_pgsql/tests/lazy_fetch_cancel.phpt | 36 ++++++++++++++++++++++ ext/pdo_pgsql/tests/lazy_fetch_drain.phpt | 36 ++++++++++++++++++++++ 4 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 ext/pdo_pgsql/tests/lazy_fetch_cancel.phpt create mode 100644 ext/pdo_pgsql/tests/lazy_fetch_drain.phpt diff --git a/NEWS b/NEWS index 449032dce642..a6b444e28b87 100644 --- a/NEWS +++ b/NEWS @@ -56,6 +56,8 @@ PHP NEWS (PDO::ATTR_PREFETCH => 0) left in a COPY. (KentarouTakeda) . Fixed a use-after-free when a lazy statement with emulated or disabled prepares is destroyed. (KentarouTakeda) + . Fixed a lazy fetch with emulated or disabled prepares leaving the + connection busy for the next one. (KentarouTakeda) - Reflection: . Fixed bug GH-22905 (Reflection exception messages truncate on null bytes). diff --git a/ext/pdo_pgsql/pgsql_statement.c b/ext/pdo_pgsql/pgsql_statement.c index 7fec6e37539f..62b24e833bbe 100644 --- a/ext/pdo_pgsql/pgsql_statement.c +++ b/ext/pdo_pgsql/pgsql_statement.c @@ -66,12 +66,12 @@ static void pgsql_stmt_finish(pdo_pgsql_stmt *S, int fin_mode) { pdo_pgsql_db_handle *H = S->H; - if (S->is_running_unbuffered && S->result && (fin_mode & FIN_ABORT)) { + /* a buffered query may have already drained this statement's stream */ + if (S->is_running_unbuffered && H->running_stmt == S && S->result && (fin_mode & FIN_ABORT)) { PGcancel *cancel = PQgetCancel(H->server); char errbuf[256]; PQcancel(cancel, errbuf, 256); PQfreeCancel(cancel); - S->is_running_unbuffered = false; } if (S->result) { @@ -80,7 +80,7 @@ static void pgsql_stmt_finish(pdo_pgsql_stmt *S, int fin_mode) S->result = NULL; } - if (S->is_running_unbuffered) { + if (S->is_running_unbuffered && H->running_stmt == S) { /* https://postgresql.org/docs/current/libpq-async.html: * "PQsendQuery cannot be called again until PQgetResult has returned NULL" * And as all single-row functions are connection-wise instead of statement-wise, @@ -618,7 +618,6 @@ static int pgsql_stmt_fetch(pdo_stmt_t *stmt, S->current_row = 0; if (!stmt->row_count) { - S->is_running_unbuffered = false; /* libpq requires looping until getResult returns null */ pgsql_stmt_finish(S, 0); } diff --git a/ext/pdo_pgsql/tests/lazy_fetch_cancel.phpt b/ext/pdo_pgsql/tests/lazy_fetch_cancel.phpt new file mode 100644 index 000000000000..7968c2653206 --- /dev/null +++ b/ext/pdo_pgsql/tests/lazy_fetch_cancel.phpt @@ -0,0 +1,36 @@ +--TEST-- +PDO PgSQL an abandoned lazy fetch frees the connection without a prepared statement +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +foreach ([ + 'PDO::ATTR_EMULATE_PREPARES' => [PDO::ATTR_EMULATE_PREPARES => true], + 'Pdo\Pgsql::ATTR_DISABLE_PREPARES' => [Pdo\Pgsql::ATTR_DISABLE_PREPARES => true], +] as $label => $options) { + $options[PDO::ATTR_PREFETCH] = 0; + + $stmt = $pdo->prepare("VALUES (1), (2)", $options); + $stmt->execute(); + $stmt = null; + + $stmt = $pdo->prepare("VALUES (1), (2)", $options); + $stmt->execute(); + echo "$label: "; + var_dump((bool) $stmt->fetchAll()); +} +?> +--EXPECT-- +PDO::ATTR_EMULATE_PREPARES: bool(true) +Pdo\Pgsql::ATTR_DISABLE_PREPARES: bool(true) diff --git a/ext/pdo_pgsql/tests/lazy_fetch_drain.phpt b/ext/pdo_pgsql/tests/lazy_fetch_drain.phpt new file mode 100644 index 000000000000..a650628d3cce --- /dev/null +++ b/ext/pdo_pgsql/tests/lazy_fetch_drain.phpt @@ -0,0 +1,36 @@ +--TEST-- +PDO PgSQL a drained lazy fetch frees the connection without a prepared statement +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +foreach ([ + 'PDO::ATTR_EMULATE_PREPARES' => [PDO::ATTR_EMULATE_PREPARES => true], + 'Pdo\Pgsql::ATTR_DISABLE_PREPARES' => [Pdo\Pgsql::ATTR_DISABLE_PREPARES => true], +] as $label => $options) { + $options[PDO::ATTR_PREFETCH] = 0; + + $stmt = $pdo->prepare("VALUES (1), (2)", $options); + $stmt->execute(); + $stmt->fetchAll(); + + $stmt = $pdo->prepare("VALUES (1), (2)", $options); + $stmt->execute(); + echo "$label: "; + var_dump((bool) $stmt->fetchAll()); +} +?> +--EXPECT-- +PDO::ATTR_EMULATE_PREPARES: bool(true) +Pdo\Pgsql::ATTR_DISABLE_PREPARES: bool(true) From 199bfd450ccd39df693fdb1e26df8f3a093b3aa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E7=94=B0=20=E6=86=B2=E5=A4=AA=E9=83=8E?= Date: Thu, 6 Aug 2026 12:07:48 +0000 Subject: [PATCH 4/5] ext/pdo_pgsql: Do not deliver rows of a result another statement freed --- NEWS | 2 ++ ext/pdo_pgsql/pgsql_statement.c | 3 ++- ext/pdo_pgsql/tests/lazy_fetch_takeover.phpt | 28 ++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 ext/pdo_pgsql/tests/lazy_fetch_takeover.phpt diff --git a/NEWS b/NEWS index a6b444e28b87..4548f5aa62f0 100644 --- a/NEWS +++ b/NEWS @@ -58,6 +58,8 @@ PHP NEWS prepares is destroyed. (KentarouTakeda) . Fixed a lazy fetch with emulated or disabled prepares leaving the connection busy for the next one. (KentarouTakeda) + . Fixed a lazy fetch returning a row of NULLs after another statement + took over the connection. (KentarouTakeda) - Reflection: . Fixed bug GH-22905 (Reflection exception messages truncate on null bytes). diff --git a/ext/pdo_pgsql/pgsql_statement.c b/ext/pdo_pgsql/pgsql_statement.c index 62b24e833bbe..3fb3e9444830 100644 --- a/ext/pdo_pgsql/pgsql_statement.c +++ b/ext/pdo_pgsql/pgsql_statement.c @@ -622,7 +622,8 @@ static int pgsql_stmt_fetch(pdo_stmt_t *stmt, pgsql_stmt_finish(S, 0); } } - if (S->current_row < stmt->row_count) { + /* another statement may have taken over and freed the result */ + if (S->result && S->current_row < stmt->row_count) { S->current_row++; return 1; } else { diff --git a/ext/pdo_pgsql/tests/lazy_fetch_takeover.phpt b/ext/pdo_pgsql/tests/lazy_fetch_takeover.phpt new file mode 100644 index 000000000000..eace678310de --- /dev/null +++ b/ext/pdo_pgsql/tests/lazy_fetch_takeover.phpt @@ -0,0 +1,28 @@ +--TEST-- +PDO PgSQL a lazy fetch whose stream was taken over reports no leftover rows +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$pdo->setAttribute(PDO::ATTR_PREFETCH, 0); + +$first = $pdo->prepare("VALUES (1), (2)"); +$first->execute(); + +$pdo->prepare("VALUES (1), (2)")->execute(); + +var_dump($first->fetchAll(PDO::FETCH_NUM)); +?> +--EXPECT-- +array(0) { +} From 7871f4d583b2591eaddaa516b9b91da485e8cfab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E7=94=B0=20=E6=86=B2=E5=A4=AA=E9=83=8E?= Date: Tue, 11 Aug 2026 11:56:54 +0000 Subject: [PATCH 5/5] ext/pdo_pgsql: Do not read from a connection another statement took over --- NEWS | 2 + ext/pdo_pgsql/pgsql_statement.c | 2 +- .../tests/lazy_fetch_takeover_buffered.phpt | 38 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 ext/pdo_pgsql/tests/lazy_fetch_takeover_buffered.phpt diff --git a/NEWS b/NEWS index 4548f5aa62f0..f6d653011f41 100644 --- a/NEWS +++ b/NEWS @@ -60,6 +60,8 @@ PHP NEWS connection busy for the next one. (KentarouTakeda) . Fixed a lazy fetch returning a row of NULLs after another statement took over the connection. (KentarouTakeda) + . Fixed a lazy fetch reading rows of another statement after a buffered + query took over the connection. (KentarouTakeda) - Reflection: . Fixed bug GH-22905 (Reflection exception messages truncate on null bytes). diff --git a/ext/pdo_pgsql/pgsql_statement.c b/ext/pdo_pgsql/pgsql_statement.c index 3fb3e9444830..be3f31f62a37 100644 --- a/ext/pdo_pgsql/pgsql_statement.c +++ b/ext/pdo_pgsql/pgsql_statement.c @@ -589,7 +589,7 @@ static int pgsql_stmt_fetch(pdo_stmt_t *stmt, return 0; } } else { - if (S->is_running_unbuffered && S->current_row >= stmt->row_count) { + if (S->is_running_unbuffered && S->H->running_stmt == S && S->current_row >= stmt->row_count) { ExecStatusType status; /* @todo in unbuffered mode, PQ allows multiple queries to be passed: diff --git a/ext/pdo_pgsql/tests/lazy_fetch_takeover_buffered.phpt b/ext/pdo_pgsql/tests/lazy_fetch_takeover_buffered.phpt new file mode 100644 index 000000000000..aa8c2aa86c4a --- /dev/null +++ b/ext/pdo_pgsql/tests/lazy_fetch_takeover_buffered.phpt @@ -0,0 +1,38 @@ +--TEST-- +PDO PgSQL a lazy fetch stale after a buffered query does not read the next statement's rows +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +$first = $pdo->prepare("VALUES (1), (2), (3)", [PDO::ATTR_PREFETCH => 0]); +$first->execute(); +$first->fetch(); + +// a buffered query drains the stream but does not end $first's lazy fetch +$pdo->prepare("VALUES (99)")->execute(); + +$third = $pdo->prepare("VALUES (777), (888)", [PDO::ATTR_PREFETCH => 0]); +$third->execute(); + +var_dump($first->fetch(PDO::FETCH_NUM)); +var_dump($third->fetchAll(PDO::FETCH_COLUMN)); +?> +--EXPECT-- +bool(false) +array(2) { + [0]=> + string(3) "777" + [1]=> + string(3) "888" +}