pdo_odbc: fetching a long text / ntext value tries to allocate the declared column size (2 GB) when the driver returns SQL_NO_TOTAL (regression in 8.5.12)

オープン
#25,390 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

まだ誰も着手していません。

評価

難易度
4/5
見積もり時間
3〜5日
初心者へのやさしさ
72/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
c, sql
領域
backend, databases

調査の方向性

Start with ext/pdo_odbc/odbc_stmt.c, in odbc_stmt_get_col(), and compare the SQL_NO_TOTAL allocation logic with commit a4c4173d9a. Read the existing ext/pdo_odbc/tests/gh23443.phpt test, then add a regression case that fetches a long text value whose driver reports SQL_NO_TOTAL. Done means unknown-length values fetch correctly without allocating the declared column size, and the regression test passes.

索引モデルが issue の本文から書いたものです。

説明

Bug Status: Needs Triage

Description

Since PHP 8.5.12, PDOStatement::fetch() on a SQL Server text or ntext column kills the process as soon as the value is longer than about 4 KB:

Hard memory limit of 536870912 bytes exhausted (tried to allocate 2147483648 bytes) in /repro.php on line 9

The process exits with status 137; under FPM the worker dies and the web server returns 502. The value itself may be
only 5 000 bytes long: the 2 147 483 648 bytes are the declared size of the column plus one.

  • PHP 8.4.27: works.
  • PHP 8.5.11: works for text / ntext (but hangs on varchar(max), GH-23443).
  • PHP 8.5.12: fails. The code is unchanged in php-8.5.13RC1, PHP-8.5 and master (checked 2026-11-11).

The change that most likely introduced it is the fix for GH-23443, commit a4c4173d9a (PR #23462), first released in
8.5.12.

Version history

The long-column fetch of pdo_odbc was rewritten for 8.5.0, and each later fix changed which values fail with this
driver. All rows are for the same setup (unixODBC + FreeTDS 1.3.17, UTF-8 client charset, SQL Server).

PHP version Long text / ntext (≥ 4073 bytes) varchar(max) / nvarchar(max) ≥ ~4 KB NULL in a long column Commit that changed the behaviour How I know
8.4.x ok ok ok – (256-byte block loop) tested on 8.4.27
8.5.0 – 8.5.9 not tested; block size capped at 4063 by the code hangs, SQLGetData() called with a 1-byte buffer segfault 0d584c32c5 (PR #10809, "Don't fetch 256 byte blocks for long columns"), first released in 8.5.0 (max) and NULL tested on 8.5.7; version range from the sources of the release tags
8.5.10 – 8.5.11 ok hangs (GH-23443) ok 05202568c4 (fix for GH-23016), first released in 8.5.10 tested on 8.5.11; 8.5.10 from the tag source
8.5.12 exit 137, 2 GiB / 1 GiB allocation ok ok a4c4173d9a (PR #23462, fix for GH-23443), merged 2026-09-26, first released in 8.5.12 tested on 8.5.12
8.5.13RC1, PHP-8.5, master same code as 8.5.12 – – – source comparison only, not run

So the regression reported here appears exactly between 8.5.11 and 8.5.12, and the only change to
ext/pdo_odbc/odbc_stmt.c between those two tags is a4c4173d9a:

-			if (orig_fetched_len == SQL_NO_TOTAL) {
-				to_fetch_len = C->datalen > (LONG_COLUMN_BUFFER_SIZE - 1) ? (LONG_COLUMN_BUFFER_SIZE - 1) : C->datalen;
-			} else {
+			if (orig_fetched_len == SQL_NO_TOTAL && C->datalen > (LONG_COLUMN_BUFFER_SIZE - 1)) {
+				to_fetch_len = C->datalen;
+			} else if (orig_fetched_len > 0) {
+				/* implicitly not SQL_NO_TOTAL, should be OK */
 				to_fetch_len = orig_fetched_len;
+			} else {
+				/* size must be > 0 to actually get data */
+				to_fetch_len = (LONG_COLUMN_BUFFER_SIZE - 1);
 			}

With this driver there is no 8.5 release that reads every long column correctly: 8.5.0 – 8.5.11 fail on (max)
types, 8.5.12 fails on text / ntext.

Reproduction

Environment used:

  • Linux x86_64; official Docker image php:8.5-cli-bookworm (8.5.12) with pdo_odbc built via
    docker-php-ext-configure pdo_odbc --with-pdo-odbc=unixODBC,/usr. The Debian 12 packages from deb.sury.org
    (php8.5-odbc 8.5.12-3) behave the same.
  • unixODBC 2.3.11, FreeTDS ODBC driver 1.3.17 (tdsodbc), TDS protocol 7.4, client charset UTF-8 (FreeTDS default).
  • Microsoft SQL Server 2022 (16.0.4275). No table is needed, the queries use literals only.
<?php
$pdo = new PDO(
    'odbc:Driver=FreeTDS;Server=127.0.0.1;Port=1433;TDS_Version=7.4;ClientCharset=UTF-8',
    'sa', getenv('MSSQL_PASSWORD'),
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);

foreach ([4072, 4073, 300000] as $n) {
    $row = $pdo->query("SELECT CAST(REPLICATE(CAST('t' AS varchar(max)), $n) AS text) AS v")->fetch(PDO::FETCH_ASSOC);
    printf("%d => %d bytes\n", $n, strlen($row['v']));
}

Run it with any ordinary limit, e.g. php -d memory_limit=256M repro.php.

Resulted in this output (8.5.12):

4072 => 4072 bytes
Hard memory limit of 536870912 bytes exhausted (tried to allocate 2147483648 bytes) in /repro.php on line 9

But I expected this output instead (8.4.27, 8.5.11):

4072 => 4072 bytes
4073 => 4073 bytes
300000 => 300000 bytes

With ntext (CAST(REPLICATE(CAST(N'n' AS nvarchar(max)), 5000) AS ntext)) the message says
tried to allocate 1073741824 bytes.

A longer script that runs each case in a child process and prints length, md5 and peak memory is attached
(repro.php). Its results:

Case 8.4.27 8.5.11 8.5.12
text NULL / 3 bytes / 4072 bytes ok ok ok
text 4073 bytes, 300 000 bytes, 3 000 000 bytes ok ok exit 137, 2 GiB allocation
ntext 5 000 characters, 2.1 MB of multi-byte characters ok ok exit 137, 1 GiB allocation
two text columns of 5 000 and 6 000 bytes in one row ok ok exit 137
varchar(max) / nvarchar(max) of the same sizes ok hangs (GH-23443) ok
CAST(<text> AS varchar(max)) ok hangs (GH-23443) ok
varchar(8000), image ok ok ok

Attachments:

Conditions

All of these must hold:

  1. The column is fetched through the "long column" path of odbc_stmt_get_col(): its SQL_DESC_DISPLAY_SIZE is at
    least LONG_COLUMN_BUFFER_SIZE (4064 on 64-bit), or it follows such a column.
  2. The driver reports a large display size for the column. FreeTDS reports 2 147 483 647 for text and
    1 073 741 823 for ntext.
  3. The value does not fit the first SQLGetData() buffer, and the driver sets the length indicator to
    SQL_NO_TOTAL. FreeTDS does that whenever the remaining length cannot be computed, i.e. when the conversion between
    the server and the client charset is not fixed-width (src/odbc/convert_tds2sql.c); with a UTF-8 client that is
    always the case. The exact threshold seen with FreeTDS is 4073 bytes, because the driver can still report a total
    for the few bytes it holds in its own conversion buffer.
  4. memory_limit is lower than the declared size.

Notes:

  • varchar(max) and friends report display size 0, so they take the block-wise branch and work.
  • image works because the driver returns the total length.
  • With memory_limit=-1 the fetch succeeds and returns correct data, but each such value reserves a 2 GiB block
    first (memory_get_peak_usage(true) shows 2050 MB; the pages are never touched).
  • I only have FreeTDS to test with. Microsoft documents that its ODBC driver also returns SQL_NO_TOTAL when
    converting long character data, so it is probably affected for text / ntext as well, but I have not verified it.
  • Any other driver that reports a large display size together with SQL_NO_TOTAL should hit the same path.

Cause

ext/pdo_odbc/odbc_stmt.c, odbc_stmt_get_col(), as changed by a4c4173d9a:

ssize_t to_fetch_len;
if (orig_fetched_len == SQL_NO_TOTAL && C->datalen > (LONG_COLUMN_BUFFER_SIZE - 1)) {
	to_fetch_len = C->datalen;
} else if (orig_fetched_len > 0) {
	/* implicitly not SQL_NO_TOTAL, should be OK */
	to_fetch_len = orig_fetched_len;
} else {
	/* size must be > 0 to actually get data */
	to_fetch_len = (LONG_COLUMN_BUFFER_SIZE - 1);
}
ssize_t to_fetch_byte = to_fetch_len + 1;
char *buf2 = emalloc(to_fetch_byte);

C->datalen is the display size from SQLColAttribute(SQL_DESC_DISPLAY_SIZE). Before that commit the same case was
capped:

if (orig_fetched_len == SQL_NO_TOTAL) {
	to_fetch_len = C->datalen > (LONG_COLUMN_BUFFER_SIZE - 1) ? (LONG_COLUMN_BUFFER_SIZE - 1) : C->datalen;
}

That version had the GH-23443 problem for datalen == 0, but for a large datalen it read 4063-byte blocks, which is
what the comment above the code still describes ("The behavior in this case is the same as before, dividing the data
into blocks. However, it has been changed from 256 byte to LONG_COLUMN_BUFFER_SIZE."). The rewrite fixed the zero case
and, probably by accident, turned the cap into "use the declared size".

The declared size is an upper bound of the type, not of the value, so it cannot be used as an allocation size when the
total is unknown.

Suggested fix

Read block by block whenever the total length is unknown:

--- a/ext/pdo_odbc/odbc_stmt.c
+++ b/ext/pdo_odbc/odbc_stmt.c
@@ -775,13 +775,12 @@
 			 * changed from 256 byte to LONG_COLUMN_BUFFER_SIZE.
 			 */
 			ssize_t to_fetch_len;
-			if (orig_fetched_len == SQL_NO_TOTAL && C->datalen > (LONG_COLUMN_BUFFER_SIZE - 1)) {
-				to_fetch_len = C->datalen;
-			} else if (orig_fetched_len > 0) {
+			if (orig_fetched_len > 0) {
 				/* implicitly not SQL_NO_TOTAL, should be OK */
 				to_fetch_len = orig_fetched_len;
 			} else {
-				/* size must be > 0 to actually get data */
+				/* SQL_NO_TOTAL: the total length is unknown, so read block by block;
+				 * the declared column size says nothing about the value */
 				to_fetch_len = (LONG_COLUMN_BUFFER_SIZE - 1);
 			}
 			ssize_t to_fetch_byte = to_fetch_len + 1;

I built 8.5.12 with this change and ran the attached script and a second set of 23 cases (NULLs, empty strings,
(max) types up to 300 kB, NULLs after a long column, date and numeric types): every result has the same length and
md5 as on PHP 8.4.27, including the GH-23443 case. A 3 MB text value takes about 170 ms, against about 350 ms on 8.4.

If larger blocks are preferred for speed, capping works as well; I also tested
to_fetch_len = MIN(C->datalen, 1024 * 1024); with identical results.

A regression test next to ext/pdo_odbc/tests/gh23443.phpt:

$row = $db->query("SELECT CAST(REPLICATE(CAST('A' AS varchar(max)), 5000) AS text) AS v")->fetch(PDO::FETCH_ASSOC);
var_dump(strlen($row['v']));   // int(5000)

Workarounds

  • CAST(col AS varchar(max)) in the query.
  • Lowering the session TEXTSIZE (FreeTDS DSN attribute TextSize, or SET TEXTSIZE n): the server then declares
    the column with that size and the allocation shrinks to n bytes. It also truncates every large value to n bytes.
  • memory_limit above the declared size.

PHP Version

PHP 8.5.12 (cli) (built: Oct 28 2026 01:23:38) (NTS)

Operating System

Debian 12 (bookworm), x86_64

主要言語
C
スター
41.8k
フォーク
8.5k
平均マージ
2日 13時間
マージ済み PR(30日)
145

環境構築

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

php/php-src のほかの issue

php/php-src の issue をすべて見る

似ている issue

C の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。