Skip to content

[3.0] Keeps error logging on prepared statements for any error message - #9882

Open
albertlast wants to merge 2 commits into
SimpleMachines:release-3.0from
albertlast:3.0/error-log-prepared-only
Open

albertlast wants to merge 2 commits into
SimpleMachines:release-3.0from
albertlast:3.0/error-log-prepared-only

Conversation

@albertlast

@albertlast albertlast commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Description

The error log is written through a prepared statement so that error messages, whatever they hold, never pass through SMF's query parser. This keeps it that way and closes the two gaps that still lost errors.

When the statement cannot be prepared, the error is dropped. mysqli_stmt_bind_param() was handed false and threw a TypeError. The error is not sent through insert() instead. Whatever caused the error may have broken that path too, and its own errors would be logged by calling error_insert() again. #9873 does fall back to insert(), and in testing that fallback fails on both engines: it passes a single flat row, which insert() rejects with "Invalid data structure sent to the database" unless backward compatibility is on.

What the statement binds is cleaned first, in a new DatabaseApi::sanitizeErrorRow():

  • strings are cut to their column with mb_strcut(), so the cut never ends inside a character;
  • invalid UTF-8 is replaced with mb_scrub();
  • NUL bytes are removed.

Without that, a message longer than its column, or one holding bytes the database rejects, made the insert fail and the error was lost. A byte-wise substr() has the same problem: cutting inside a multibyte character leaves invalid UTF-8.

PostgreSQL with pooling prepares the unnamed statement every time, as it did before, since any other query may replace it. It also reads $this->persist; $db_persist is not defined inside the class.

Testing

tests/Unit/DatabaseApiTest.php covers the clean-up. tests/Integration/ErrorInsertTest.php logs through the real error_insert() and reads the row back. It runs outside a transaction, because error_insert() rolls back an open transaction on PostgreSQL, and deletes its own rows. On release-3.0 all four awkward-message cases fail on MySQL; with this change both suites pass on MySQL and PostgreSQL.

A failed prepare is not in the suite. Forcing one needs either a server-wide MySQL setting or a clash of statement names on PostgreSQL, and the PHP warning that produces would fail the run under failOnWarning. It was checked by hand instead, with a script calling error_insert() with 17 kinds of message (quotes, backslashes, SQL, {db_prefix} and %s placeholders, emoji, newlines, HTML, NUL bytes, invalid UTF-8, empty, and 70 KB cut inside 2-, 3- and 4-byte characters), each under an IPv4, an IPv6 and an invalid IP:

normal prepare fails
#9873, MySQL 45 / 51 stored fatal: Invalid data structure
#9873, PostgreSQL 45 / 51 stored fatal: Invalid data structure
this PR, MySQL 51 / 51 stored 0 / 51 stored, no error, no loop
this PR, PostgreSQL 51 / 51 stored 0 / 51 stored, no error, no loop

The prepare was made to fail with SET GLOBAL max_prepared_stmt_count = 0 on MySQL, and on PostgreSQL by preparing another statement named smf_log_errors first. The script checked that the prepare really failed before it ran.

Issues References (Fixes|Related|Closes)

  1. Fixes mysqli_prepare can return false #7831
  2. Related [3.0] Handle failed prepare statements and oversized data in error logs #9873, which this offers an alternative to
  3. The same change for 2.1 is [2.1] Keeps error logging on prepared statements for any error message #9883
  4. Related [3.0] Stops a dead database turning error logging into a loop #9884, which stops the loop mysqli_prepare can return false #7831 reports when the connection dies

🤖 Generated with Claude Code

The error log is written only through its prepared statement. When that
cannot be prepared, mysqli_stmt_bind_param() was handed false and threw
a TypeError; the error is now dropped instead of being sent through the
regular query path, which may be what failed and whose own errors would
come straight back here.

What the statement binds is cleaned first: strings are cut to their
column on a character boundary, invalid UTF-8 is replaced and NUL bytes
are removed. Without that a long message, or one holding bytes the
database rejects, made the insert fail and the error was lost.

PostgreSQL with pooling prepares the unnamed statement every time, since
any other query may replace it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>

$value = mb_scrub(str_replace("\0", '', (string) $error_array[$key]), 'UTF-8');

$error_array[$key] = mb_strcut($value, 0, $limit, 'UTF-8');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have Utils::entitySubstr that should also do this. Although I like the \0 fix. I don't know if its needed or if Utils::entitySubstr already handles that.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Utils::entitySubstr() counts graphemes, and these columns are limited in bytes. message, url and backtrace are TEXT, which on MySQL is 65,535 bytes, so 65534 characters of a 3-byte script is about 196 KB and the insert fails. #9873 uses it, and against the current head the long 2-byte, 3-byte and emoji messages were not stored on MySQL. It also returns an empty string for invalid UTF-8, because grapheme_substr() returns false and the cast turns that into '', so the message is lost. Both runs are in #9873 (comment).

mb_strcut() cuts by bytes without splitting a character, which is what a byte-limited column needs, and mb_scrub() keeps the readable part of an invalid string. The NUL bytes are not handled by either entitySubstr() or mb_strcut(), which is why that line is separate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mysqli_prepare can return false

2 participants