Skip to content

Fix uncaught PDOException in manual lookup when sqlite is unavailable#1969

Open
svpernova09 wants to merge 1 commit into
php:masterfrom
svpernova09:manual-lookup-crash-on-broken-sqlite
Open

Fix uncaught PDOException in manual lookup when sqlite is unavailable#1969
svpernova09 wants to merge 1 commit into
php:masterfrom
svpernova09:manual-lookup-crash-on-broken-sqlite

Conversation

@svpernova09

Copy link
Copy Markdown

Fix uncaught PDOException in manual lookup when SQLite is unavailable

Summary

find_manual_page() (in include/manual-lookup.inc) throws an uncaught
PDOException whenever the manual-lookup SQLite database can't be queried: a
read-only/locked file, or a corrupt/truncated one. It was written for PDO's
pre-8.0 ERRMODE_SILENT behaviour, where a failed prepare()/execute()
returns false and the code falls back to the filesystem search
(find_manual_page_slow()). PHP 8 changed PDO's default to
ERRMODE_EXCEPTION, so those failures now throw, and the only try/catch
wraps the connection, not the queries.

This is the second-largest source of PHP fatals on www.php.net:
19,892 fatal errors in a recent 15-day window, almost all reached through the
404 handler (error.phpfind_manual_page()), which runs a manual lookup for
every not-found URL.

The bug

The SQLite fast-path relies on the old silent-mode contract:

$dbh = new PDO('sqlite:' . ... , '', '', [PDO::ATTR_PERSISTENT => true, PDO::ATTR_EMULATE_PREPARES => true]);
// ...
$stm = $dbh->prepare($SQL);
if (!$stm) {                                 // <-- assumes prepare() returns false on error
    return find_manual_page_slow($lang, $keyword);
}
$stm->execute([...]);                        // <-- under PHP 8 this THROWS on error

Under PHP 8's default ERRMODE_EXCEPTION, prepare()/execute() throw instead
of returning false, so the if (!$stm) guards are dead and the exception
propagates uncaught.

A second, related defect sits in the same function: when prepare() fails in the
bare-keyword branch, the code calls error_noservice() instead of falling back to the slow search like
every other failure path. error_noservice() renders a hard "service not working"
page and then calls exit.

Evidence from production logs

The fatal (error.log, client IP redacted):

[Sat Jul 04 18:30:07.387384 2026] [php:error] [pid 453880:tid 453880] [client REDACTED] PHP Fatal error:  Uncaught PDOException: SQLSTATE[HY000]: General error: 8 attempt to write a readonly database in /var/www/sites/www.php.net/include/manual-lookup.inc:173
Stack trace:
#0 /var/www/sites/www.php.net/include/manual-lookup.inc(173): PDOStatement->execute()
#1 /var/www/sites/www.php.net/error.php(745): find_manual_page()
#2 {main}
  thrown in /var/www/sites/www.php.net/include/manual-lookup.inc on line 173

Volume: 19,892 fatals (30 Jun – 14 Jul 2026), every one
General error: 8 attempt to write a read-only database.

It hits every query path, both SQL branches, at both prepare() and
execute():

Throwing call Line Count
prepare() (dotted-keyword branch) manual-lookup.inc:169 9,951
execute() (dotted-keyword branch) manual-lookup.inc:173 8,366
prepare() (bare-keyword branch) manual-lookup.inc:186 820
execute() (bare-keyword branch) manual-lookup.inc:188 755

The 820 hits at line 186 are the prepare()-in-the-bare-branch case that reaches
error_noservice(): 820 users were shown a hard "service not working" page
for what should have been a transparent fallback.

How it's reached, almost entirely via the 404 handler running a manual
lookup for missing URLs:

Caller Count
error.phpfind_manual_page() 17,898
manual-lookup.phpfind_manual_page() 1,994

Root cause

PHP 8 changed PDO's default error mode from ERRMODE_SILENT to
ERRMODE_EXCEPTION. The code's "sqlite is a best-effort optimization; fall back
to the filesystem search on any problem" design depended on the silent behavior,
so every sqlite failure that used to degrade gracefully now throws an uncaught.

(Why the database is unwritable in the first place is a separate,
infrastructure-side issue; see below.)

The fix

Two changes, both restoring the function's clear original intent (any sqlite
failure → fall back to find_manual_page_slow()):

  1. Set PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT on the connection, so
    prepare()/execute() return false on failure and the existing fallback
    guards work again.
  2. Replace the error_noservice() call with a fall-back to the slow search, so a
    prepare() failure in the bare-keyword branch degrades gracefully like the
    other paths instead of showing a hard error page.

Testing

  • New: tests/Unit/ManualLookup/FindManualPageTest.php, integration tests
    that drive the real find_manual_page():
    • broken/corrupt database → falls back to the filesystem result (both SQL
      branches),
    • no database present → filesystem search,
    • healthy database → sqlite fast path.
  • Written test-first: the broken-database test was confirmed to reproduce the
    uncaught PDOException against the original code before the fix.
  • Full unit suite green (109 tests), PHPStan clean, php-cs-fixer
    clean.

  find_manual_page() was written for PDO's pre-8.0 ERRMODE_SILENT default,
  where a failed prepare()/execute() returns false and the code falls back
  to find_manual_page_slow(). PHP 8 defaults to ERRMODE_EXCEPTION, so those
  failures now throw and escaped uncaught -- 19,892 'General error: 8
  attempt to write a readonly database' fatals in a 15-day window, almost
  all reached via the 404 handler (error.php -> find_manual_page).

  Restore ERRMODE_SILENT on the connection so the existing fallback works,
  and make the bare-keyword branch fall back to the slow search instead of
  calling error_noservice() (a hard error page) when prepare() fails.

  Add integration tests covering both SQL branches with a broken database,
  a missing database, and the healthy fast path.
@svpernova09 svpernova09 force-pushed the manual-lookup-crash-on-broken-sqlite branch from c831f03 to 804460a Compare July 14, 2026 16:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant