Repository navigation
[3.0] Fix the cache accelerators and cover them with tests - #9627
Conversation
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>
| try { | ||
| $temp = @unserialize($value); | ||
| $value = $temp; | ||
| } catch (\Throwable $e) { | ||
| $temp = false; | ||
| } |
There was a problem hiding this comment.
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); | ||
|
|
There was a problem hiding this comment.
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.
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 anint, and the method declares it returnsboolunderstrict_types, so the return threw:That happens inside
CacheApi::load(), which is reached fromConfig::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 catchesconnect()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 withIF NOT EXISTSsays 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()returnsfalsefor anything it cannot represent — a string that is not valid UTF-8 above all. Thatfalsewent straight on to the writer: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 whateverunserialize()gives it, and that isfalseboth 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 PostgreSQLtextcolumns cannot hold a null byte, so either accelerator can truncate an entry at the first one and store the fragment, which then reads back asfalse. Onlyserialize(false)legitimately unserialises tofalse, 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
CacheApifacade 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/Cacheon 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::$boardurljoins the bootstrap becauseCacheApi::setPrefix()reads it, and a typed static that nothing has assigned throws when it is read. It lands besideConfig::$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.phpis also touched by #9302, at the$enabledeclaration and inload(). This changesget()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/elsethat went away, and straightening it moves lines the change itself does not.Issues References (Fixes|Related|Closes)
🤖 Generated with Claude Code