Skip to content

[3.0] Fix the cache accelerators and cover them with tests - #9627

Merged
Sesquipedalian merged 7 commits into
SimpleMachines:release-3.0from
albertlast:3.0/cache-accel
Sep 6, 2026
Merged

Sesquipedalian merged 7 commits into
SimpleMachines:release-3.0from
albertlast:3.0/cache-accel

Conversation

@albertlast

Copy link
Copy Markdown
Collaborator

Description

Four separate faults in the cache accelerators, each as its own commit carrying its own regression test. A first commit adds tests for the behaviour that already worked, so the four that follow are readable as fix + proof.

Memcached made the forum unreachable. addServers() accumulates its result with $retVal |= $this->memcached->addServer(...). PHP evaluates a bitwise or of two booleans as an int, and the method declares it returns bool under strict_types, so the return threw:

Uncaught TypeError: MemcachedImplementation::addServers(): Return value must be of type bool, int returned

That happens inside CacheApi::load(), which is reached from Config::reloadModSettings() during startup, so every request died before producing any output. Choosing memcached in the ACP took the site down. The fallback to the file cache sitting next to it only catches connect() returning false, so an exception thrown from within is caught by nothing.

SQLite stored nothing when write ahead logging was on. The table was created only when the database file was zero bytes, standing in for "this database is new". The WAL pragma runs two lines earlier and writes a 4096 byte header, so on a fresh database the file is no longer empty by the time the check runs and the table is never created. Every read was a miss and every write threw, which connect() logs once and then swallows, so the only symptom was a cache that never held anything. Asking for the table with IF NOT EXISTS says what was meant and does not depend on the file's size.

The file cache fatalled on data it could not encode. Entries are written as JSON, and json_encode() returns false for anything it cannot represent — a string that is not valid UTF-8 above all. That false went straight on to the writer:

TypeError: str_split(): Argument #1 ($string) must be of type string, false given

Caching is documented as something that may miss and must not be depended on, so a value the store cannot hold simply does not get cached. It is not a reason to take the page down. The reachable case is a forum whose text is not clean UTF-8, which an upgrade from an older charset can easily leave behind.

A corrupt entry read back as false. CacheApi::get() returns whatever unserialize() gives it, and that is false both for a value somebody deliberately cached and for one that cannot be read at all — so a damaged entry was handed to the caller as a value nobody ever stored. That is reachable rather than theoretical: SQLite3::escapeString() is not binary safe and PostgreSQL text columns cannot hold a null byte, so either accelerator can truncate an entry at the first one and store the fragment, which then reads back as false. Only serialize(false) legitimately unserialises to false, so comparing against it separates the two and everything else reports a miss — a state every caller already handles, because that is what an expired or evicted entry looks like.

How this was checked

Each accelerator was driven through the CacheApi facade over a matrix of the values SMF actually caches, plus the ones easily confused with a miss. After these fixes FileBased, APCu, SQLite with and without WAL, memcached and PostgreSQL all round-trip every one of them.

Reverting Sources/Cache on this branch and running the suite fails exactly these four tests, each with its own symptom, while the shared behaviour tests stay green.

Notes for review

Config::$boardurl joins the bootstrap because CacheApi::setPrefix() reads it, and a typed static that nothing has assigned throws when it is read. It lands beside Config::$scripturl, which is already there for the same reason.

The tests cover FileBased, Sqlite and APCu, which need nothing but a writable directory. The memcached test needs the extension but no server, because addServer() only records where a server is meant to be and connects lazily; it skips where the extension is absent, as it will be on CI. The PostgreSQL accelerator needs a live database connection and is out of reach of the unit suite, so it was verified by hand against a running PostgreSQL instead.

Sources/Cache/CacheApi.php is also touched by #9302, at the $enable declaration and in load(). This changes get() instead, so the two do not overlap.

The file cache commit is larger than its fix: the block it edits sits one indent level too deep, a leftover from an if/else that went away, and straightening it moves lines the change itself does not.

Issues References (Fixes|Related|Closes)

  1. No issue filed; these came out of testing whether the cache accelerators still work. Happy to file one report per fault if that is preferred.
  2. Related: [3.0] Misc fixes #9302 touches the same file at different lines.

🤖 Generated with Claude Code

albertlast and others added 5 commits September 5, 2026 12:27
The cache API had no tests at all, so nothing said what an accelerator is
supposed to do with the values SMF hands it. This covers the three that need
nothing but a writable directory: FileBased, Sqlite, and APCu where the
extension is enabled for the SAPI running the suite.

What is asserted is the contract the callers rely on: a value comes back as
the value that went in, including the ones easily confused with a miss, such
as false, zero, an empty string and an empty array; an object comes back
equal; putting null removes the entry; a key nobody stored is a miss; and an
entry is gone once its time to live has passed.

Config::$boardurl joins the bootstrap because CacheApi::setPrefix() reads it,
and a typed static that nothing has assigned throws when it is read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Choosing memcached as the accelerator made the forum unreachable. Not slow,
not uncached: every request died before it produced any output.

addServers() collects its result with $retVal |= $this->memcached->addServer().
PHP evaluates a bitwise or of two booleans as an int, and the method declares
it returns bool under strict_types, so the return threw a TypeError:

  Uncaught TypeError: MemcachedImplementation::addServers(): Return value must
  be of type bool, int returned

The throw happens inside CacheApi::load(), which is reached from
Config::reloadModSettings() during startup. The fallback to the file cache
next to it only catches connect() returning false, so an exception thrown
from within is not caught by anything.

The accompanying test needs no memcached server, because addServer() only
records where a server is meant to be and connects lazily.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
With $cache_sqlite_wal set, the SQLite accelerator stored nothing at all. Every
read was a miss and every write threw, which connect() logs once and then
swallows, so the only symptom was a cache that never held anything.

The table was created only when the database file was zero bytes, as a stand-in
for "this database is new". The WAL pragma runs two lines earlier and writes a
4096 byte header, so on a fresh database the file is no longer empty by the time
the check runs, and the table is never created.

Asking for the table unconditionally with IF NOT EXISTS says what was meant and
does not depend on the file's size, so it holds whichever order the pragma and
the create end up in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
The file cache writes its entries as JSON, and json_encode() returns false for
anything it cannot represent, a string that is not valid UTF-8 above all. That
false went straight on to the writer, which fatalled the request:

  TypeError: str_split(): Argument #1 ($string) must be of type string, false
  given

Caching is documented as something that may miss and must not be depended on,
so a value the store cannot hold is a value that does not get cached. It is
not a reason to take the page down with it.

The reachable case is a forum whose text is not clean UTF-8, which an upgrade
from an older charset can easily leave behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
CacheApi::get() unserialises whatever the accelerator hands back and returns
the result. unserialize() answers false for a value it cannot read, which is
indistinguishable from the false somebody deliberately cached, so a damaged
entry was handed to the caller as a value that nobody ever stored.

That is reachable rather than theoretical. SQLite3::escapeString() is not
binary safe and PostgreSQL text columns cannot hold a null byte, so either of
those accelerators can truncate an entry at the first one and store the
fragment. The fragment reads back as false.

Only serialize(false) legitimately unserialises to false, so comparing against
it separates the two, and everything else that fails reports a miss. A miss is
a state every caller already handles, because that is what an expired or
evicted entry looks like.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
The suite asks for fileinfo and mbstring. The Linux runner happens to ship
sqlite3 and memcached on top of that, and the Windows one ships neither, so
which accelerators actually got tested depended on the runner rather than on
anything the workflow said.

On Linux every APCu case skipped. On Windows only the file cache ran at all:
APCu, SQLite and memcached all skipped, which took the write ahead logging
regression test with them. The counts said 302 tests passing on both, so the
gap did not show.

Naming apcu and sqlite3 makes the two that are installable everywhere run
everywhere. APCu also needs apc.enable_cli, because it stores nothing on the
command line without it, and the suite runs on the command line.

memcached is deliberately left out. It is already present on the Linux runner,
and asking for it on Windows is the one part of this likely to fail to build.
The test skips where the extension is missing, which is the right answer for
an accelerator that needs a server anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
@github-actions github-actions Bot added the github_actions Pull requests that update GitHub Actions code label Sep 5, 2026
Comment thread Sources/Cache/CacheApi.php Outdated
Comment on lines 627 to 631
try {
$temp = @unserialize($value);
$value = $temp;
} catch (\Throwable $e) {
$temp = false;
}

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.

This entire try block should just be removed and replaced with this:

			$temp = @unserialize($value);

If unserialize() fails, it just issues an E_WARNING and returns false. No exception is thrown, so there's nothing to catch.

Follows review. unserialize() answers a malformed value with an E_WARNING and
false rather than an exception, so the catch was not catching the case it looks
like it exists for, and the miss is decided by the comparison below it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
$value = $temp;
} catch (\Throwable $e) {
$temp = @unserialize($value);

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.

Done, in 3e0ff9d — the try is gone and it is just $temp = @unserialize($value); now.

For the record, since I went and measured it rather than taking it on faith: you are right for every way a cache entry actually goes wrong. Garbage, a truncated array, a value cut off at a NUL byte, an enum whose case no longer exists, an object whose class is gone — all of them return false quietly, nothing thrown.

The two things that do throw are __wakeup()/__unserialize() raising, and a typed property being handed a value of the wrong type (TypeError: Cannot assign string to property Typed::$n). Neither is reachable here: nothing in Sources/ defines __wakeup(), and the typed-property case would need a cache entry written by a different version of a class, which Upgrade.php rules out by calling CacheApi::clean().

So the catch was covering nothing that can happen. Removed.

@Sesquipedalian
Sesquipedalian merged commit db82c18 into SimpleMachines:release-3.0 Sep 6, 2026
9 checks passed
@jdarwood007 jdarwood007 added this to the 3.0 Alpha 5 milestone Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Caching github_actions Pull requests that update GitHub Actions code Unit Testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants