<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>BitPage</title><link>https://bitpage.me/</link><description>A technical engineer's blog: real problems, real fixes, real numbers. Backend, databases, infra, incident post-mortems, security, AI-native workflows.</description><generator>Hugo</generator><language>en-us</language><copyright>BitPage</copyright><lastBuildDate>Mon, 26 Oct 2026 05:25:00 +0000</lastBuildDate><atom:link href="https://bitpage.me/index.xml" rel="self" type="application/rss+xml"/><item><title>PHP 5.6 and PHP2Shell: is it vulnerable, what fixes it, and how to prove you fixed it</title><link>https://bitpage.me/security/php2shell-php56-minimal-backport/</link><pubDate>Mon, 26 Oct 2026 05:25:00 +0000</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.me/security/php2shell-php56-minimal-backport/</guid><description>PHP says nothing about 5.x, but both vulnerable paths are in vanilla 5.6.40: zend_mm_safe_error() ends in zend_bailout(), and session.upload_progress.enabled defaults to 1. The fix is sury patch 0261, and the half it deliberately left out arrived the next day in revision 5.6.40-105 as patch 0263.</description><content:encoded>&lt;p>&lt;strong>Short answer:&lt;/strong> PHP 5.6 is vulnerable, and you can confirm it from the source rather than taking a vendor&amp;rsquo;s word for it: in vanilla 5.6.40 &lt;code>zend_mm_safe_error()&lt;/code> ends in a &lt;code>zend_bailout()&lt;/code>, which is the &lt;code>longjmp&lt;/code> out of a failed allocation that CVE-2026-109618 is about, and &lt;code>session.upload_progress.enabled&lt;/code> defaults to &lt;code>&amp;quot;1&amp;quot;&lt;/code> with the callback registered unconditionally, which is the consumer path for CVE-2026-109619. The only free fix is Ondřej Surý&amp;rsquo;s sury patch &lt;code>0261&lt;/code>, applied to vanilla 5.6.40, and the piece it deliberately omitted shipped a day later in package revision &lt;code>5.6.40-105&lt;/code>.&lt;/p>
&lt;p>PHP&amp;rsquo;s advisories cover 7.4 and newer. For 5.x there is no statement at all: not vulnerable, not unaffected, nothing. Version scanners fill the gap with &amp;ldquo;EOL, please upgrade&amp;rdquo;, which is not an answer when the code in question uses &lt;code>mysql_*&lt;/code>, &lt;code>ereg&lt;/code> and the &lt;code>/e&lt;/code> modifier and the upgrade is a quarter of work rather than an afternoon.&lt;/p>
&lt;p>The two CVEs and the fleet-wide picture are in &lt;a href="https://bitpage.me/security/php2shell-mixed-fleet-audit/">the overview&lt;/a>. This is the narrowest branch of the set and the one where proving the fix is hardest.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Vulnerable:&lt;/strong> yes. Both code paths are present in vanilla 5.6.40, verifiable in &lt;code>Zend/zend_alloc.c&lt;/code> and &lt;code>ext/session/session.c&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Fix:&lt;/strong> sury patch &lt;code>0261-Avoid-state-corruption-via-bailout.patch&lt;/code>, by Arnaud Le Blanc, co-author of the upstream fix. It applies to vanilla 5.6.40 with line offsets only and no dependencies.&lt;/li>
&lt;li>&lt;strong>Take revision &lt;code>5.6.40-105&lt;/code>, not &lt;code>-104&lt;/code>.&lt;/strong> &lt;code>-104&lt;/code> on 21 October is the bailout fix alone; &lt;code>-105&lt;/code> on 22 October adds the CVE-2023-0662 multipart limit that the bailout patch explicitly relies on.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> with &lt;code>-104&lt;/code> alone there is no static marker whatsoever: not the version, not an ini value, not a new directive. Only a behaviour test.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> the 2019 docker-library Dockerfile for 5.6 no longer builds. Everything it fetches has moved or shut down.&lt;/li>
&lt;/ul>
&lt;h2 id="is-56-actually-vulnerable">Is 5.6 actually vulnerable&lt;/h2>
&lt;p>Yes, and the useful part is that it is checkable without a PoC. Wiz and Sansec both put the start of the vulnerable code at 5.4.0. Rather than trust that, I read vanilla &lt;code>php-5.6.40&lt;/code> from php-src.&lt;/p>
&lt;p>The 109618 root is &lt;code>zend_mm_safe_error()&lt;/code> in &lt;code>Zend/zend_alloc.c&lt;/code>. The shape of it is the same code the official EOL backports delete on 7.4 and 8.0:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span> heap&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>overflow &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#bd93f9">1&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> zend_try {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">zend_error_noreturn&lt;/span>(E_ERROR, format, limit, size);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } zend_catch {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } &lt;span style="color:#50fa7b">zend_end_try&lt;/span>();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } &lt;span style="color:#ff79c6">else&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> heap&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>overflow &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#bd93f9">2&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">zend_bailout&lt;/span>();
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>That &lt;code>zend_bailout()&lt;/code> is a &lt;code>longjmp&lt;/code> out of a failed allocation, and if the allocation failed while the request body was being parsed, it unwinds out of the middle of the parse. A detail from the same function explains the log signature worth grepping for: when the error happens before any script is executing, it sets &lt;code>error_filename = &amp;quot;Unknown&amp;quot;&lt;/code> and &lt;code>error_lineno = 0&lt;/code>, which is why &lt;code>Allowed memory size of … exhausted … in Unknown on line 0&lt;/code> is the line that tells you an OOM happened before user code started.&lt;/p>
&lt;p>The 109619 consumer is session upload progress, and on 5.6 it is on out of the box. In &lt;code>ext/session/session.c&lt;/code> the directive is declared as &lt;code>STD_PHP_INI_BOOLEAN(&amp;quot;session.upload_progress.enabled&amp;quot;, &amp;quot;1&amp;quot;, ZEND_INI_PERDIR, …)&lt;/code>, default &lt;code>&amp;quot;1&amp;quot;&lt;/code>, and &lt;code>php_rfc1867_callback = php_session_rfc1867_callback;&lt;/code> is assigned unconditionally at module startup, with the only guard being &lt;code>if (!PS(rfc1867_enabled))&lt;/code> inside the callback. The application never has to call &lt;code>session_start()&lt;/code> for that path to be live.&lt;/p>
&lt;p>Both entry points exist, then, and the class of bug with them. Whether the specific exploitation technique from the public write-ups works against 5.6 is not something I tested, and I would not claim it either way. Supporting evidence from a direction I did not expect: Ubuntu&amp;rsquo;s own &lt;a href="https://ubuntu.com/security/cves/CVE-2026-109618.json">CVE record&lt;/a> lists &lt;code>php5&lt;/code> on trusty as released at &lt;code>5.5.9+dfsg-1ubuntu4.29+esm18&lt;/code>. That is a vendor spending paid ESM effort on a branch two minor versions older than this one, which is not something you do for a bug you think is theoretical. Note the shape of that version string too: upstream stays &lt;code>5.5.9&lt;/code> and only the Ubuntu revision moves, so &lt;code>php -v&lt;/code> on a fixed trusty box is identical to &lt;code>php -v&lt;/code> on an unfixed one.&lt;/p>
&lt;h2 id="where-to-get-a-fix">Where to get a fix&lt;/h2>
&lt;p>One free source, and it is a good one.&lt;/p>
&lt;p>The sury &lt;code>php5.6&lt;/code> package carries &lt;code>0261-Avoid-state-corruption-via-bailout.patch&lt;/code>. The author is Arnaud Le Blanc, co-author of the upstream fix, with Ilija Tovilo credited; the header says &amp;ldquo;Backported from the PHP 7.4 version&amp;rdquo; and &amp;ldquo;Minimal mitigation for EOL version.&amp;rdquo; It applies to a vanilla 5.6.40 tarball with line offsets only, no fuzz, and no prerequisite patches at all, which is unusual, since on 7.1 through 7.3 the equivalent patch needs the &lt;code>ZSTR_MAX_LEN&lt;/code> backport first.&lt;/p>
&lt;p>What I like about this patch is that it documents its own gaps in the header. Verbatim, from the list of deviations:&lt;/p>
&lt;blockquote>
&lt;p>The &lt;code>max_multipart_body_parts&lt;/code> hunk of the 7.4 patch is left out; the limit comes with the CVE-2023-0662 backport (Introduce &lt;code>max_multipart_body_parts&lt;/code> INI).&lt;/p>
&lt;/blockquote>
&lt;p>A patch that tells you what it is not doing is a patch you can plan around. Which brings us to the interesting part.&lt;/p>
&lt;p>The rejected options, briefly. A community port for 5.6.40 exists and does include a parts limit, but its authors tested it against the built-in CLI server, so it is at best a donor for a missing piece. CloudLinux &lt;code>alt-php56&lt;/code> is paid and awkward in Docker. Official PHP backports, Freexian ELTS and Zend have nothing for 5.x.&lt;/p>
&lt;h2 id="the-gap-and-the-fact-that-it-closed-the-next-day">The gap, and the fact that it closed the next day&lt;/h2>
&lt;p>When I first worked through this, the omission above was an open problem. The sury maintainer had confirmed on the issue tracker that 5.6 has no multipart parts limit, because the CVE-2023-0662 fix was never backported to that branch, and an update was promised with no date. The conclusion was that the patch changes what happens when an OOM occurs without limiting the input that causes it, so a proxy-level body limit stays mandatory.&lt;/p>
&lt;p>Then I pulled the current source package and read the changelog. The update had shipped:&lt;/p>
&lt;pre tabindex="0">&lt;code>php5.6 (5.6.40-105) unstable; urgency=medium
* Backported from 8.0.28
+ [CVE-2023-0662]: DOS vulnerability when parsing multipart request body.
-- Ondřej Surý &amp;lt;ondrej@debian.org&amp;gt; Thu, 22 Oct 2026 08:02:38 +0200
php5.6 (5.6.40-104) unstable; urgency=medium
* Backported from 8.2.35
+ [CVE-2026-109618, CVE-2026-109619]: Avoid state corruption via bailout.
-- Ondřej Surý &amp;lt;ondrej@debian.org&amp;gt; Wed, 21 Oct 2026 08:55:15 +0200
&lt;/code>&lt;/pre>&lt;p>Revision &lt;code>-105&lt;/code> adds &lt;code>0263-Introduce-max_multipart_body_parts-INI.patch&lt;/code>, touching &lt;code>main/main.c&lt;/code> and &lt;code>main/rfc1867.c&lt;/code>, and the current series has 264 patches with both &lt;code>0261&lt;/code> and &lt;code>0263&lt;/code> in it. The missing half exists after all, one day behind the first revision, and the two patches together are the complete minimal fix for 5.6.&lt;/p>
&lt;p>One thing to expect when you go looking for it: nothing in the pool is named a bare &lt;code>-105&lt;/code>. The 5.6 packages ship as git-buildpackage snapshots, so the version you will actually see is of the form &lt;code>5.6.40-105+0~20261022.135+debian11~1.gbp37a17e&lt;/code>, and the top changelog entry in the source package is marked &lt;code>** SNAPSHOT build **&lt;/code> and &lt;code>UNRELEASED&lt;/code>. Compare on the &lt;code>-105&lt;/code> part and ignore the tail. If you compare on the whole string, or expect a clean Debian revision, you will conclude the fix is not published.&lt;/p>
&lt;p>Three consequences, in order of how much they matter:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>If you patched 5.6 on 21 October, you took &lt;code>-104&lt;/code> and you are not done.&lt;/strong> Same story on 7.1, where &lt;code>7.1.33-80&lt;/code> carried the bailout fix and &lt;code>7.1.33-81&lt;/code> added the prerequisite a day later. On EOL branches the fix can arrive in pieces, and patching early is a reason to re-check rather than to close the ticket.&lt;/li>
&lt;li>&lt;strong>The static marker problem goes away&lt;/strong> (see below).&lt;/li>
&lt;li>&lt;strong>The parts limit now has a number.&lt;/strong> &lt;code>max_multipart_body_parts&lt;/code> is declared &lt;code>PHP_INI_ENTRY(&amp;quot;max_multipart_body_parts&amp;quot;, &amp;quot;-1&amp;quot;, PHP_INI_SYSTEM|PHP_INI_PERDIR, NULL)&lt;/code> and the default &lt;code>-1&lt;/code> falls back to &lt;code>max_input_vars + max_file_uploads&lt;/code>, which on stock defaults is 1000 + 20. Beyond that you get &amp;ldquo;Multipart body parts limit exceeded %d. To increase the limit change max_multipart_body_parts in php.ini.&amp;rdquo; That fallback formula is identical in the 5.6 backport and in vanilla 8.2&amp;rsquo;s &lt;code>main/rfc1867.c&lt;/code>, so a 5.6 build with both patches now has the same effective 1020-part ceiling as a current PHP.&lt;/li>
&lt;/ol>
&lt;p>The proxy-level body limit is still worth keeping. One reason is that a limit at the edge costs nothing; the better reason is that blocking &lt;code>multipart/*&lt;/code> alone does not close the urlencoded variant of the amplification, so a body-size cap has to exist regardless of what PHP is doing.&lt;/p>
&lt;h2 id="trap-with-the-bailout-patch-alone-there-is-no-marker-at-all">Trap: with the bailout patch alone there is no marker at all&lt;/h2>
&lt;p>This is what makes 5.6 harder than every other branch. &lt;code>php -v&lt;/code> prints 5.6.40 before and after. &lt;code>php -i&lt;/code> prints &lt;code>session.upload_progress.enabled =&amp;gt; On&lt;/code> even though the patch disables the feature in code. On 7.1 through 7.4 you can at least look for &lt;code>max_multipart_body_parts&lt;/code>, since it is absent from every vanilla 7.x tree. I counted zero occurrences in &lt;code>main/main.c&lt;/code> for &lt;code>php-5.6.40&lt;/code>, &lt;code>php-7.1.33&lt;/code>, &lt;code>php-7.2.34&lt;/code>, &lt;code>php-7.3.33&lt;/code> and &lt;code>php-7.4.33&lt;/code>. With &lt;code>0261&lt;/code> alone, 5.6 has no new directive, so that marker does not exist either.&lt;/p>
&lt;p>Taking &lt;code>-105&lt;/code> fixes this as a side effect: with &lt;code>0263&lt;/code> applied, &lt;code>max_multipart_body_parts&lt;/code> does show up in &lt;code>php -i&lt;/code>, and 5.6 gets the same static marker the 7.x branches have. That is a second reason to take the later revision even if you believe your edge limits make the parts limit redundant — it makes the build auditable.&lt;/p>
&lt;p>If you are stuck on the bailout patch alone, the only proof is behavioural:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker run --rm IMG php -r &lt;span style="color:#f1fa8c">&amp;#39;register_shutdown_function(function(){echo &amp;#34;SHUTDOWN\n&amp;#34;;}); ini_set(&amp;#34;memory_limit&amp;#34;,&amp;#34;8M&amp;#34;); $a=str_repeat(&amp;#34;x&amp;#34;, 64*1024*1024);&amp;#39;&lt;/span>; &lt;span style="color:#8be9fd;font-style:italic">echo&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;exit=&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$?&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># patched: Fatal error: Allowed memory size ..., exit=1, no SHUTDOWN&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># unpatched: Fatal error ..., SHUTDOWN printed, exit=255&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The patched build routes the fatal error to stderr and terminates with &lt;code>_exit(1)&lt;/code>, so shutdown functions never run. I confirmed that same signature independently on a sury-patched &lt;code>php8.0-cli&lt;/code> build: exit 1, stderr, no shutdown function, against 255 and stdout on an unpatched one.&lt;/p>
&lt;p>And in the build itself, assert on a symbol the patch introduces:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>grep -c array_init_singleton Zend/zend_API.h &lt;span style="color:#6272a4"># 0 in vanilla 5.6.40, &amp;gt;=1 after the patch&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>I checked that count against vanilla 5.6.40, 7.4.33 and 8.0.30: zero in all three, and every one of the six EOL patch sets adds it. Do not use &lt;code>zend_objects_store_mark_destructed&lt;/code> for this: it exists in unpatched sources, so the assert passes unconditionally. That was my first choice and it was useless.&lt;/p>
&lt;h2 id="trap-the-2019-dockerfile-no-longer-builds">Trap: the 2019 Dockerfile no longer builds&lt;/h2>
&lt;p>The approach that keeps the blast radius small is the same as on the other branches: build a base image from the official docker-library Dockerfile for 5.6, add the patch, change one &lt;code>FROM&lt;/code> line in the application image. The right commit is &lt;code>fab49d4cb1c6&lt;/code>, the last one whose tree still carries &lt;code>5.6/&lt;/code>, directly before &lt;code>e9320fdb752e&lt;/code> &amp;lsquo;Revert &amp;ldquo;Update to 5.6.40&amp;rdquo; (this removes the EOL 5.6 series)&amp;rsquo;. The variant to take is &lt;code>5.6/stretch/fpm&lt;/code>, with &lt;code>ENV PHP_VERSION 5.6.40&lt;/code> on line 64 and the sha256 &lt;code>1369a51eee3995d7…&lt;/code> on line 66 — same OS and same layout as the running image.&lt;/p>
&lt;p>Then nothing it references still exists. Three separate breakages, none of them about PHP:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Debian stretch is off the mirrors.&lt;/strong> Point at &lt;code>archive.debian.org&lt;/code> and accept expired Release files, because the archive&amp;rsquo;s are.&lt;/li>
&lt;li>&lt;strong>The tarball URL is dead.&lt;/strong> &lt;code>secure.php.net/get/…/from/this/mirror&lt;/code> no longer resolves; &lt;code>www.php.net/distributions/…&lt;/code> does.&lt;/li>
&lt;li>&lt;strong>The keyserver shut down.&lt;/strong> &lt;code>ha.pool.sks-keyservers.net&lt;/code> is gone; &lt;code>keyserver.ubuntu.com&lt;/code> works. The signature still verifies — &amp;ldquo;Good signature&amp;rdquo;, with the signing key long expired, which was already true in 2019.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-dockerfile" data-lang="dockerfile">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">FROM&lt;/span>&lt;span style="color:#f1fa8c"> debian:stretch-slim&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># stretch moved to archive.debian.org in its entirety&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">RUN&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">printf&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;%s\n&amp;#39;&lt;/span> &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;deb http://archive.debian.org/debian stretch main&amp;#39;&lt;/span> &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;deb http://archive.debian.org/debian-security stretch/updates main&amp;#39;&lt;/span> &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &amp;gt; /etc/apt/sources.list &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &lt;span style="color:#ff79c6">&amp;amp;&amp;amp;&lt;/span> rm -f /etc/apt/sources.list.d/* &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &lt;span style="color:#ff79c6">&amp;amp;&amp;amp;&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">echo&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;Acquire::Check-Valid-Until &amp;#34;false&amp;#34;;&amp;#39;&lt;/span> &amp;gt; /etc/apt/apt.conf.d/99archive
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">ENV&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">PHP_URL&lt;/span>&lt;span style="color:#ff79c6">=&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;https://www.php.net/distributions/php-5.6.40.tar.xz&amp;#34;&lt;/span> &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">PHP_ASC_URL&lt;/span>&lt;span style="color:#ff79c6">=&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;https://www.php.net/distributions/php-5.6.40.tar.xz.asc&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># gpg --keyserver keyserver.ubuntu.com --recv-keys &amp;#34;$key&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">COPY&lt;/span> php2shell-5.6-0261-avoid-state-corruption.patch &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> php2shell-5.6-0263-max_multipart_body_parts.patch /usr/src/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">RUN&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">set&lt;/span> -eux; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> docker-php-source extract; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">cd&lt;/span> /usr/src/php; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> patch -p1 --forward &amp;lt; /usr/src/php2shell-5.6-0261-avoid-state-corruption.patch; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> patch -p1 --forward &amp;lt; /usr/src/php2shell-5.6-0263-max_multipart_body_parts.patch; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> grep -q array_init_singleton Zend/zend_API.h; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span>&lt;span style="color:#6272a4"># repack with the patch in: docker-php-ext-install re-extracts php.tar.xz later&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> tar -cJf /usr/src/php.tar.xz -C /usr/src php; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> ./configure ... &lt;span style="color:#6272a4"># as in the original&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Keep the edits confined to source delivery. The PHP build and every &lt;code>configure&lt;/code> flag stay exactly as the official image had them, which is the entire reason to start from the official Dockerfile instead of writing one.&lt;/p>
&lt;p>That repack of &lt;code>php.tar.xz&lt;/code> deserves a sentence. The official Dockerfile ends with &lt;code>docker-php-source delete&lt;/code>, which leaves the pristine tarball behind; an application image later running &lt;code>docker-php-ext-install&lt;/code> re-extracts it and compiles extensions against unpatched sources. I have not seen this break anything in practice, so it is a precaution rather than a reported bug — but it costs one line.&lt;/p>
&lt;p>I rejected &lt;code>debian:12&lt;/code> plus the sury &lt;code>php5.6-fpm&lt;/code> package. The fix is there and the OS keeps getting updates, which is genuinely better long-term, but the layout is different (&lt;code>/etc/php/5.6/&lt;/code>, &lt;code>php-fpm5.6&lt;/code>, extensions via apt) and rewriting the Dockerfile for a set of landing pages is not where the emergency budget goes. Moving to 7.4 is pointless, since 7.4 is EOL too. Moving to 8.3 is a real project, with &lt;code>mysql_*&lt;/code>, &lt;code>ereg&lt;/code>, the &lt;code>/e&lt;/code> modifier, &lt;code>mcrypt&lt;/code>, &lt;code>each()&lt;/code> and PHP 4 constructors all in the way.&lt;/p>
&lt;h2 id="what-changes-for-the-application">What changes for the application&lt;/h2>
&lt;p>Take a single &lt;code>php:5.6-fpm&lt;/code> container fronting a dozen or so landing-page domains, with nginx next to it and &lt;code>fastcgi_pass php56:9000&lt;/code>. Rebuilding that base on 5.6.40 plus the two patches leaves &lt;code>php -m&lt;/code> and &lt;code>php-fpm -tt&lt;/code> identical, and the only differences in &lt;code>php -i&lt;/code> are &lt;code>session.upload_progress.enabled&lt;/code> going to &lt;code>Off&lt;/code> from the added ini file, the new &lt;code>max_multipart_body_parts&lt;/code> entry, and whatever OpenSSL the final stretch update carried.&lt;/p>
&lt;p>The behavioural changes are the same as on every EOL backport:&lt;/p>
&lt;ul>
&lt;li>OOM terminates the worker through &lt;code>_exit(1)&lt;/code>, so shutdown functions do not run, the error goes to stderr, and nginx answers 502 where it used to answer 500.&lt;/li>
&lt;li>Session upload progress is off in code, whatever &lt;code>php.ini&lt;/code> says.&lt;/li>
&lt;li>Multipart parts beyond the new limit are dropped with a warning.&lt;/li>
&lt;li>Only &lt;code>Content-Disposition&lt;/code> and &lt;code>Content-Type&lt;/code> are retained on multipart parts.&lt;/li>
&lt;/ul>
&lt;p>For static landing pages none of that is visible. For anything with a large form or a shutdown handler doing real work, it is, and that is the thing to check before swapping the container rather than after.&lt;/p>
&lt;p>Worth comparing old and new before deploying, since it is three commands:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">for&lt;/span> i in OLD NEW; &lt;span style="color:#ff79c6">do&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> docker run --rm &lt;span style="color:#8be9fd;font-style:italic">$i&lt;/span> php -m &amp;gt; m-&lt;span style="color:#8be9fd;font-style:italic">$i&lt;/span>.txt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> docker run --rm &lt;span style="color:#8be9fd;font-style:italic">$i&lt;/span> php -i &amp;gt; i-&lt;span style="color:#8be9fd;font-style:italic">$i&lt;/span>.txt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> docker run --rm &lt;span style="color:#8be9fd;font-style:italic">$i&lt;/span> php-fpm -tt 2&amp;gt;&amp;amp;&lt;span style="color:#bd93f9">1&lt;/span> | sed -E &lt;span style="color:#f1fa8c">&amp;#39;s/^\[[^]]+\] NOTICE: //&amp;#39;&lt;/span> &amp;gt; f-&lt;span style="color:#8be9fd;font-style:italic">$i&lt;/span>.txt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">done&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>diff m-OLD.txt m-NEW.txt; diff f-OLD.txt f-NEW.txt; diff i-OLD.txt i-NEW.txt
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>One thing worth doing in parallel, which is outside the scope of patching: a genuinely static landing page does not need PHP in front of it at all, and for the ones that do need it, an allowlist of the specific &lt;code>.php&lt;/code> files is cheaper than keeping a 2019 interpreter correct forever.&lt;/p>
&lt;h2 id="step-by-step">Step by step&lt;/h2>
&lt;ol>
&lt;li>Confirm what you have: &lt;code>php -v&lt;/code> inside the running container, and the image&amp;rsquo;s build date.&lt;/li>
&lt;li>Fetch the current sury &lt;code>php5.6&lt;/code> source package and check the changelog revision. You want &lt;code>-105&lt;/code> or later, not &lt;code>-104&lt;/code>.&lt;/li>
&lt;li>Extract &lt;code>0261&lt;/code> and &lt;code>0263&lt;/code> from &lt;code>debian/patches/&lt;/code>.&lt;/li>
&lt;li>Build a base image from docker-library commit &lt;code>fab49d4cb1c6&lt;/code>, variant &lt;code>5.6/stretch/fpm&lt;/code>, with the archive and keyserver edits, both patches, the marker assert, and the &lt;code>php.tar.xz&lt;/code> repack.&lt;/li>
&lt;li>Diff &lt;code>php -m&lt;/code>, &lt;code>php -i&lt;/code> and &lt;code>php-fpm -tt&lt;/code> against the old image.&lt;/li>
&lt;li>Verify: &lt;code>max_multipart_body_parts&lt;/code> present in &lt;code>php -i&lt;/code>, plus the OOM behaviour test showing exit 1 with no shutdown function.&lt;/li>
&lt;li>Change the &lt;code>FROM&lt;/code> line in the application image and add &lt;code>session.upload_progress.enabled = 0&lt;/code>.&lt;/li>
&lt;li>Keep the body-size limit at the proxy. It is the layer that does not depend on a 5.6 patch being correct.&lt;/li>
&lt;/ol>
&lt;h2 id="bottom-line">Bottom line&lt;/h2>
&lt;p>&amp;ldquo;End of life&amp;rdquo; does not mean &amp;ldquo;not vulnerable&amp;rdquo;, and it does not mean &amp;ldquo;nothing to apply&amp;rdquo;. For 5.6 the fix comes from the co-author of the upstream patch, applies to the last release of the branch with no dependencies, and ships in a package whose changelog names the CVEs.&lt;/p>
&lt;p>The thing to get right is the revision. &lt;code>5.6.40-104&lt;/code> is the bailout fix with a documented hole in it; &lt;code>5.6.40-105&lt;/code>, one day later, fills the hole and, as a side effect, gives 5.6 the &lt;code>max_multipart_body_parts&lt;/code> marker that makes a patched build identifiable at all. Without it the only proof is an OOM exiting 1 with shutdown functions skipped, which is worth wiring into the build either way.&lt;/p>
&lt;p>A minimal backport closes the mechanism, not every input to it. Whatever it leaves out stays your perimeter&amp;rsquo;s problem, and the patch header is where you find out what that is.&lt;/p>
&lt;p>Companion pieces: &lt;a href="https://bitpage.me/security/php2shell-mixed-fleet-audit/">the fleet-wide audit and verification matrix&lt;/a>, &lt;a href="https://bitpage.me/security/php2shell-php8-frozen-docker-tags/">PHP 8 and the frozen Docker tags&lt;/a>, and &lt;a href="https://bitpage.me/security/php2shell-php7-eol-backports/">PHP 7.1 to 7.4&lt;/a>.&lt;/p>
&lt;p>Primary sources: &lt;a href="https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w">GHSA-w8hw-w9fx-j79w&lt;/a>, &lt;a href="https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp">GHSA-6wmf-4rv5-p3rp&lt;/a>, &lt;a href="https://github.com/php/php-src/tree/php-5.6.40">php-src at php-5.6.40&lt;/a>, &lt;a href="https://packages.sury.org/php/pool/main/p/php5.6/">packages.sury.org&lt;/a>, &lt;a href="https://github.com/docker-library/php">docker-library/php&lt;/a>, and &lt;a href="https://github.com/php/php-src/security/advisories/GHSA-54hq-v5wp-fqgv">GHSA-54hq-v5wp-fqgv&lt;/a> for CVE-2023-0662.&lt;/p></content:encoded></item><item><title>PHP2Shell on PHP 7: patch applied is not patch compiled is not patch working</title><link>https://bitpage.me/security/php2shell-php7-eol-backports/</link><pubDate>Mon, 26 Oct 2026 05:20:00 +0000</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.me/security/php2shell-php7-eol-backports/</guid><description>The official 7.4 backport is one commit, 17 files, +240/-59. For 7.1 to 7.3 the sury patch sets need a prerequisite that patch --dry-run cannot see: ZSTR_MAX_LEN, which the fix references 38 times and which is absent from every vanilla PHP 7.3 and older. Three layers of verification, and the one static marker that works on 7.x.</description><content:encoded>&lt;p>&lt;strong>Short answer:&lt;/strong> PHP 7 has no fixed release and still has a fix for every branch: the official minimal backport &lt;code>712e60f9edfd&lt;/code> for 7.4 (17 files, +240/−59) and Ondřej Surý&amp;rsquo;s sury patch sets for 7.3, 7.2 and 7.1. The trap is that a clean &lt;code>patch --dry-run&lt;/code> proves nothing: the backported fix uses &lt;code>ZSTR_MAX_LEN&lt;/code>, a macro that does not exist in vanilla 7.3 or earlier, so the patch applies with offsets only and then the build dies with &lt;code>error: 'ZSTR_MAX_LEN' undeclared&lt;/code>. Verify three separate things: that the patch applies, that it compiles, and that it works.&lt;/p>
&lt;p>PHP 7.4 went end of life in 2022 and 7.1 long before that. There is no release coming, docker-library stopped building &lt;code>php:7.*&lt;/code> images in November 2022, remi for EL6 froze in 2020, and moving an old Laravel or Yii2 project to 8.x is a project in its own right rather than a weekend. None of that means there is no patch.&lt;/p>
&lt;p>The two CVEs and the fleet-wide verification matrix are covered in &lt;a href="https://bitpage.me/security/php2shell-mixed-fleet-audit/">the overview&lt;/a>. This one is the 7.x mechanics.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Fix, 7.4:&lt;/strong> the official branch &lt;code>PHP-7.4-CVE-2026-109618-109619&lt;/code>, one commit on top of &lt;code>php-7.4.33&lt;/code>. Apply it to 7.4.33, not to whatever you are running.&lt;/li>
&lt;li>&lt;strong>Fix, 7.3 / 7.2 / 7.1:&lt;/strong> the sury patch sets (&lt;code>0139&lt;/code>, &lt;code>0159&lt;/code>, &lt;code>0186&lt;/code> respectively) plus their prerequisites from the same &lt;code>debian/patches/series&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> &lt;code>--dry-run&lt;/code> checks patch text, not symbol dependencies. On 7.3 and older you also need the &lt;code>ZSTR_MAX_LEN&lt;/code> backport (&lt;code>0107&lt;/code>, &lt;code>0135&lt;/code>, &lt;code>0167&lt;/code>), or the compile fails.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> the sury 7.1 package needed two revisions. &lt;code>7.1.33-80&lt;/code> on 21 October carried the bailout fix; &lt;code>7.1.33-81&lt;/code> the next day added the CVE-2023-0662 prerequisite. If you took &lt;code>-80&lt;/code> you are not finished.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> patching your current patch version leaves CVE-2019-11043 open on 7.1 and 7.2, and the official patch does not even apply to 7.4.5.&lt;/li>
&lt;li>&lt;strong>Marker:&lt;/strong> &lt;code>max_multipart_body_parts&lt;/code> appearing in &lt;code>php -i&lt;/code> is a valid static proof on 7.1 through 7.4: absent from every vanilla 7.x tree, added by the fix.&lt;/li>
&lt;/ul>
&lt;h2 id="where-to-get-a-fixed-php-7x">Where to get a fixed PHP 7.x&lt;/h2>
&lt;p>Every branch has a source. They differ in who maintains them and in how much work lands on you.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Branch&lt;/th>
&lt;th>Source of the fix&lt;/th>
&lt;th>What comes with it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>7.4&lt;/td>
&lt;td>&lt;strong>Official&lt;/strong> minimal backport, PHP Security Team: branch &lt;code>PHP-7.4-CVE-2026-109618-109619&lt;/code>, commit &lt;code>712e60f9edfd&lt;/code>&lt;/td>
&lt;td>17 files, +240/−59: &lt;code>Zend/*&lt;/code>, &lt;code>main/rfc1867.c&lt;/code>, &lt;code>main/SAPI.c&lt;/code>, &lt;code>main/main.c&lt;/code>, &lt;code>main/php_variables.c&lt;/code>, &lt;code>ext/session/session.c&lt;/code>. &lt;code>fpm&lt;/code> and &lt;code>apache2handler&lt;/code> untouched&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7.3&lt;/td>
&lt;td>sury &lt;code>php7.3 7.3.33-35&lt;/code>, patch &lt;code>0139-Avoid-state-corruption-via-bailout&lt;/code>&lt;/td>
&lt;td>needs &lt;code>0107&lt;/code> (&lt;code>ZSTR_MAX_LEN&lt;/code>) and &lt;code>0079&lt;/code> (&lt;code>max_multipart_body_parts&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7.2&lt;/td>
&lt;td>sury &lt;code>php7.2 7.2.34-66&lt;/code>, patch &lt;code>0159&lt;/code>&lt;/td>
&lt;td>needs &lt;code>0135&lt;/code> (&lt;code>ZSTR_MAX_LEN&lt;/code>) and &lt;code>0105&lt;/code> (&lt;code>max_multipart_body_parts&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7.1&lt;/td>
&lt;td>sury &lt;code>php7.1 7.1.33-81&lt;/code>, patch &lt;code>0186&lt;/code>&lt;/td>
&lt;td>needs &lt;code>0167&lt;/code> (&lt;code>ZSTR_MAX_LEN&lt;/code>) and &lt;code>0188&lt;/code> (&lt;code>max_multipart_body_parts&lt;/code>)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>If you intend to assert those versions in a config-management check, read the &lt;code>Version:&lt;/code> field from the &lt;code>.dsc&lt;/code> rather than the filename, because Debian strips the epoch out of filenames and the epochs here are not uniform:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Source package&lt;/th>
&lt;th>Full version&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>php7.1&lt;/code>&lt;/td>
&lt;td>&lt;code>7.1.33-81+0~20261022.128+debian12~1.gbpa88ccf&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>php7.2&lt;/code>&lt;/td>
&lt;td>&lt;code>7.2.34-66+0~20261021.122+debian12~1.gbpe0bcd4&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>php7.3&lt;/code>&lt;/td>
&lt;td>&lt;code>7.3.33-35+0~20261021.144+debian12~1.gbpecf76f&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>php7.4&lt;/code>&lt;/td>
&lt;td>&lt;code>1:7.4.33-31+0~20261021.121+debian12~1.gbpe9c428&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>php8.0&lt;/code>&lt;/td>
&lt;td>&lt;code>1:8.0.30-25+0~20261021.88+debian12~1.gbpabebea&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;code>1:&lt;/code> epoch is on 7.4 and 8.0 and on neither of the three older branches. A check written as a string comparison against &lt;code>7.4.33-31&lt;/code> therefore fails on exactly the branch where the official fix exists, which is a satisfying way to waste an hour. Compare on the upstream-plus-revision part and let the distro suffix vary.&lt;/p>
&lt;p>The sury patches say where they came from. &lt;code>0139&lt;/code> for 7.3 is headed &amp;ldquo;Backported from 8.2.35&amp;rdquo;; the series for the older branches is attributed to Arnaud Le Blanc, co-author of the upstream fix, and the 5.6 version of the same patch describes itself as a &amp;ldquo;Minimal mitigation for EOL version.&amp;rdquo; Those are the patches that go into the Debian PHP packages, with the CVE numbers written into &lt;code>debian/changelog&lt;/code>, which is why I chose them over the alternatives.&lt;/p>
&lt;p>The alternatives, for completeness. A community port covering 5.4 to 7.3 touches 8 files against the official 17, with &lt;code>zend_hash.c&lt;/code>, &lt;code>php_variables.c&lt;/code> and the VM left alone, and its authors tested it against the built-in CLI server; I rejected it. An independent MIT-licensed port for 7.3 with ready-made images on ghcr.io looked more serious but still less authoritative than the Debian maintainer&amp;rsquo;s. Paid routes exist and work. TuxCare backported the fix to every &lt;code>alt-php&lt;/code> branch from 5.2 to 8.5, and their own &lt;a href="https://tuxcare.com/els-php2shell-patches-faq/">FAQ&lt;/a> states the shape of it plainly: &amp;ldquo;alt-php74 stays at 7.4.33 and changes from release -79 to -81 on EL7.&amp;rdquo; That is the same version-number-does-not-move problem as the sury packages, from a different vendor, and it is why their documented verification route is a CVE tracker and the RPM release number rather than &lt;code>php -v&lt;/code>. Freexian ELTS covers &lt;code>php7.4&lt;/code> on Debian 11.&lt;/p>
&lt;p>Before paying anyone, check whether your distro already did the work. Ubuntu&amp;rsquo;s &lt;a href="https://ubuntu.com/security/cves/CVE-2026-109618.json">CVE record&lt;/a> lists every 7.x branch it ships as released, with the upstream version untouched in each case: &lt;code>php7.0&lt;/code> on xenial at &lt;code>7.0.33-0ubuntu0.16.04.16+esm22&lt;/code>, &lt;code>php7.2&lt;/code> on bionic at &lt;code>7.2.24-0ubuntu0.18.04.17+esm14&lt;/code>, &lt;code>php7.4&lt;/code> on focal at &lt;code>7.4.3-4ubuntu2.29+esm5&lt;/code>. If your PHP came from the distro rather than a PPA, the fix may be an &lt;code>apt upgrade&lt;/code> away under a subscription you already have.&lt;/p>
&lt;p>One gap worth naming because it has no answer: sury &lt;code>php7.4&lt;/code> on Ubuntu 18.04. The ondrej PPA dropped bionic in 2023, and Ubuntu&amp;rsquo;s bionic fix is for the &lt;em>distro&lt;/em> &lt;code>php7.2&lt;/code>, not for a PPA-installed &lt;code>php7.4&lt;/code>. A PPA package is not covered by ESM no matter what the subscription says. If you have that combination, the fix is to change something structural: the OS, the branch, or the exposure.&lt;/p>
&lt;p>A side benefit of the packages over hand-patching the official commit: the sury EOL revisions carry much more than PHP2Shell. The &lt;code>7.2.34-66&lt;/code> and &lt;code>7.3.33-35&lt;/code> changelog entries lead with libgd, an SQL injection through &lt;code>E'...'&lt;/code> backslash breakout and a recursive-symlink crash, all backported from 8.2.33; &lt;code>7.4.33-31&lt;/code> leads with an IPv6 ACL bypass in FastCGI &lt;code>listen.allowed_clients&lt;/code> and mysqlnd packet overreads. Across each of those changelogs there are between forty and sixty CVE lines. Hand-applying one commit closes one bug; taking the package closes the backlog.&lt;/p>
&lt;h3 id="patch-the-last-patch-release-of-the-branch">Patch the last patch release of the branch&lt;/h3>
&lt;p>Two reasons, and the second one is the one people skip.&lt;/p>
&lt;p>The patch often does not apply to older patch versions. The official 7.4 commit goes onto 7.4.33 and 7.4.29 cleanly and conflicts on 7.4.5 in &lt;code>zend_vm_*&lt;/code>. Applied to 7.3.33 it leaves rejected hunks in six files; to 7.2.34 it rejects 46 hunks and the whole VM; on 7.1 the same.&lt;/p>
&lt;p>Where it does apply, it can leave an older RCE untouched. The sury &lt;code>0186&lt;/code> patch applies cleanly to 7.1.8, 7.1.11 and 7.1.17, every one of which predates the fix for &lt;a href="https://github.com/advisories/GHSA-6qjm-m8fp-j2mm">CVE-2019-11043&lt;/a>, an unauthenticated RCE in php-fpm behind nginx with &lt;code>fastcgi_split_path_info&lt;/code>, fixed in 7.1.33, 7.2.24 and 7.3.11. Closing PHP2Shell on 7.1.8 and leaving that open is not a fix, it is a reshuffle. The only safe route is the last release of the branch plus the patch.&lt;/p>
&lt;h2 id="trap-1-the-dry-run-is-clean-and-the-build-fails">Trap 1: the dry run is clean and the build fails&lt;/h2>
&lt;p>Take 7.3.33 from a vanilla tarball with the sury pair &lt;code>0079&lt;/code> and &lt;code>0139&lt;/code>. Both apply with line offsets only, no fuzz, exit status zero. The compile then stops:&lt;/p>
&lt;pre tabindex="0">&lt;code>zend_vm_execute.h:4971: error: &amp;#39;ZSTR_MAX_LEN&amp;#39; undeclared
&lt;/code>&lt;/pre>&lt;p>&lt;code>--dry-run&lt;/code> checks whether the patch text matches the file, and nothing else. It has no opinion about whether the identifiers in the patch exist. The fix was backported from 8.2.35 and uses a macro that 7.3 never had. In the sury package it arrives as a separate, earlier patch: &lt;code>0107-backport-ZSTR_MAX_LEN.patch&lt;/code>, by Remi Collet, dated 22 November 2024, three lines in &lt;code>Zend/zend_string.h&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">+&lt;/span>#define &lt;span style="color:#50fa7b">ZSTR_MAX_OVERHEAD&lt;/span> (&lt;span style="color:#50fa7b">ZEND_MM_ALIGNED_SIZE&lt;/span>(_ZSTR_HEADER_SIZE &lt;span style="color:#ff79c6">+&lt;/span> &lt;span style="color:#bd93f9">1&lt;/span>))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">+&lt;/span>#define &lt;span style="color:#50fa7b">ZSTR_MAX_LEN&lt;/span> (SIZE_MAX &lt;span style="color:#ff79c6">-&lt;/span> ZSTR_MAX_OVERHEAD)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>I pinned the boundary so you do not have to guess which branches need it. &lt;code>ZSTR_MAX_LEN&lt;/code> is present in vanilla &lt;code>Zend/zend_string.h&lt;/code> on PHP 7.4 and 8.0 and absent on 7.3 and everything older. The 7.3 bailout patch references it 38 times and never touches &lt;code>zend_string.h&lt;/code> itself. That is exactly the shape of failure that passes every pre-flight check and dies in the compiler: the text matches, the symbol does not exist.&lt;/p>
&lt;p>Which is why 7.4 needs no prerequisite patch and 7.3, 7.2 and 7.1 all do: &lt;code>0107&lt;/code>, &lt;code>0135&lt;/code> and &lt;code>0167&lt;/code>. The 7.x notes I started from had this confirmed only for 7.3, with 7.2 and 7.1 inferred from the same code; the vanilla boundary closes that inference.&lt;/p>
&lt;p>The general rule is cheap to apply. Before building, read the patch for identifiers it uses but does not define, and find where they come from:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>curl -sSfO https://packages.sury.org/php/pool/main/p/php7.3/&amp;lt;php7.3_7.3.33-35…debian.tar.xz&amp;gt;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>tar -xJf php7.3_*.debian.tar.xz
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>grep -l -E &lt;span style="color:#f1fa8c">&amp;#39;state corruption via bailout|max_multipart_body_parts|define ZSTR_MAX_LEN&amp;#39;&lt;/span> debian/patches/*.patch
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>There is a shortcut hidden in the numbering, and it also tells you something the changelog does not. Compare the prerequisite&amp;rsquo;s patch number to the bailout patch&amp;rsquo;s number. A lower number means the prerequisite was already in the package before PHP2Shell and comes along for free. A higher number means it was added &lt;em>after&lt;/em> the fix, in a later revision — which means the first revision shipped incomplete.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Branch&lt;/th>
&lt;th>Bailout patch&lt;/th>
&lt;th>&lt;code>max_multipart_body_parts&lt;/code> patch&lt;/th>
&lt;th>Already present?&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>7.4&lt;/td>
&lt;td>0164&lt;/td>
&lt;td>0062&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7.3&lt;/td>
&lt;td>0139&lt;/td>
&lt;td>0079&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7.2&lt;/td>
&lt;td>0159&lt;/td>
&lt;td>0105&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7.1&lt;/td>
&lt;td>0186&lt;/td>
&lt;td>&lt;strong>0188&lt;/strong>&lt;/td>
&lt;td>&lt;strong>no, added later&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="trap-2-the-71-package-needed-a-second-revision-the-next-day">Trap 2: the 7.1 package needed a second revision the next day&lt;/h2>
&lt;p>The numbering above is not trivia. I read the &lt;code>debian/changelog&lt;/code> out of the published source packages, and 7.1 has two revisions where the others have one:&lt;/p>
&lt;ul>
&lt;li>&lt;code>php7.1 (7.1.33-80)&lt;/code>, 21 October 2026 — the bailout fix, CVE-2026-109618 and CVE-2026-109619.&lt;/li>
&lt;li>&lt;code>php7.1 (7.1.33-81)&lt;/code>, 22 October 2026 — &amp;ldquo;Backported from 8.0.28: [CVE-2023-0662]: DOS vulnerability when parsing multipart request body.&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;p>CVE-2023-0662 is the multipart-parts limit. It was fixed upstream in 8.0.28, which means 7.1 never received it in its supported life, which means the memory-amplification input that PHP2Shell depends on was unlimited on that branch until the second revision. For 7.2, 7.3 and 7.4 the limit had already been backported years earlier, so one revision was enough.&lt;/p>
&lt;p>If you patched 7.1 within the first day, you took &lt;code>-80&lt;/code> and you are missing the prerequisite. The same pattern applies to 5.6, which I cover &lt;a href="https://bitpage.me/security/php2shell-php56-minimal-backport/">separately&lt;/a>. The operational lesson is not about these two packages. It is that for EOL branches the fix can arrive in pieces over several days, and &amp;ldquo;I patched on day one&amp;rdquo; is a reason to check again rather than a reason to stop.&lt;/p>
&lt;h2 id="trap-3-the-build-assert-that-asserts-nothing">Trap 3: the build assert that asserts nothing&lt;/h2>
&lt;p>Put a marker check in the build so it fails loudly when the patch stops applying. Then make sure the marker is real.&lt;/p>
&lt;p>Mine was not. I used &lt;code>grep -q zend_objects_store_mark_destructed&lt;/code> after the &lt;code>patch&lt;/code> step, the build passed, and I believed it. That symbol exists in unpatched sources too, so the assert passed unconditionally. It was a comment that happened to compile. I counted it afterwards across all six EOL patch sets: zero occurrences in any of them. It was never going to work.&lt;/p>
&lt;p>The test for a candidate marker is one command: it must occur zero times in the vanilla tree and at least once after the patch.&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>grep -c array_init_singleton Zend/zend_API.h &lt;span style="color:#6272a4"># 0 before, &amp;gt;=1 after&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>array_init_singleton&lt;/code> is the one I would standardise on. It occurs zero times in vanilla 5.6.40, 7.4.33 and 8.0.30, and every one of the six EOL patch sets adds it, so a single marker covers 5.6 to 8.0 instead of a different grep per branch. &lt;code>zend_new_array_singleton&lt;/code> in &lt;code>Zend/zend_hash.h&lt;/code> also works, but only on 7.3 and newer, so it needs a per-branch table. Do not reach for &lt;code>zend_mm_safe_error_ex&lt;/code>, which the EOL backports add and the supported branches do not have at all — useful for telling an EOL backport apart from upstream, useless as a general patch marker.&lt;/p>
&lt;h2 id="trap-4-the-patch-is-in-and-the-version-and-ini-say-otherwise">Trap 4: the patch is in and the version and ini say otherwise&lt;/h2>
&lt;p>&lt;code>php -v&lt;/code> reports 7.4.33, 7.3.33, 7.2.34 or 7.1.33 identically with and without the patch, and &lt;code>php -i&lt;/code> still prints &lt;code>session.upload_progress.enabled =&amp;gt; On&lt;/code> even though the patch disables the feature in code. A scanner reading version plus ini will call a patched build vulnerable, and an ini showing &lt;code>Off&lt;/code> does not prove a patch is present.&lt;/p>
&lt;p>On 7.x there is a static marker, and this is the branch range where it works. &lt;code>max_multipart_body_parts&lt;/code> is absent from every vanilla PHP 7 tree and added by the fix. I counted the directive in &lt;code>main/main.c&lt;/code>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Vanilla tag&lt;/th>
&lt;th>&lt;code>max_multipart_body_parts&lt;/code>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>php-5.6.40&lt;/td>
&lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>php-7.1.33&lt;/td>
&lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>php-7.2.34&lt;/td>
&lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>php-7.3.33&lt;/td>
&lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>php-7.4.33&lt;/td>
&lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>php-8.0.28&lt;/td>
&lt;td>1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>php-8.0.30&lt;/td>
&lt;td>1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>php-8.1.34&lt;/td>
&lt;td>1&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>That makes &lt;code>php -i | grep max_multipart_body_parts&lt;/code> a usable answer on 7.1 through 7.4, and useless from 8.0 on, where the directive arrived in 8.0.28 independently of PHP2Shell.&lt;/p>
&lt;p>The reliable check on any EOL branch is behavioural. The backports replace the bailout with a hard exit, which you can see from userland:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd;font-style:italic">T&lt;/span>&lt;span style="color:#ff79c6">=&lt;/span>&lt;span style="color:#f1fa8c">&amp;#39;register_shutdown_function(function(){echo &amp;#34;SHUTDOWN-CALLED\n&amp;#34;;}); $a=str_repeat(&amp;#34;x&amp;#34;, 20*1024*1024);&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>php -d &lt;span style="color:#8be9fd;font-style:italic">memory_limit&lt;/span>&lt;span style="color:#ff79c6">=&lt;/span>8M -r &lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$T&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>; &lt;span style="color:#8be9fd;font-style:italic">echo&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;exit=&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$?&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># patched: Fatal error ..., exit=1, no SHUTDOWN-CALLED&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># unpatched: Fatal error ..., exit=255, SHUTDOWN-CALLED&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>I confirmed that signature independently against a sury &lt;code>php8.0-cli&lt;/code> &lt;code>1:8.0.30-25&lt;/code> build: exit 1, the fatal error on stderr rather than stdout, shutdown function skipped. One warning carried over from the &lt;a href="https://bitpage.me/security/php2shell-mixed-fleet-audit/">overview&lt;/a>: this test does not work on supported branches. On 8.2 the vulnerable 8.2.29 and the fixed 8.2.35 both exit 255 and both run shutdown functions, because the upstream fix leaves ordinary &lt;code>memory_limit&lt;/code> handling alone. It is an EOL test.&lt;/p>
&lt;h2 id="docker-your-own-base-image-from-the-last-official-dockerfile">Docker: your own base image, from the last official Dockerfile&lt;/h2>
&lt;p>For the Docker installs the recipe is the same on every branch: take the last docker-library Dockerfile that still had the branch, add the patches, repack the source tarball.&lt;/p>
&lt;p>Finding the right commit matters more than it sounds, because you want the Dockerfile as it was, not a reconstruction. Ask git for the file&amp;rsquo;s own history rather than eyeballing the log (&lt;code>git log --oneline -- 7.3/bullseye/fpm/Dockerfile&lt;/code>), then take the commit directly below the removal:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Branch&lt;/th>
&lt;th>Last commit with the branch&lt;/th>
&lt;th>&lt;code>PHP_VERSION&lt;/code>&lt;/th>
&lt;th>Removed in&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>7.4&lt;/td>
&lt;td>&lt;code>b9f17156020c&lt;/code> &amp;ldquo;Update 7.4 to 7.4.33&amp;rdquo;&lt;/td>
&lt;td>7.4.33&lt;/td>
&lt;td>&lt;code>f2999de80bd8&lt;/code> &amp;ldquo;Remove PHP 7.4 (EOL 28 November 2022)&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7.3&lt;/td>
&lt;td>&lt;code>b62fa33a7198&lt;/code> &amp;ldquo;Use gnu-libiconv for php iconv extension on alpine&amp;rdquo;&lt;/td>
&lt;td>7.3.33&lt;/td>
&lt;td>&lt;code>f2754514dafb&lt;/code> &amp;ldquo;Remove 7.3 (#1270)&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7.2&lt;/td>
&lt;td>&lt;code>3dc9a6988d47&lt;/code> &amp;ldquo;Add &amp;quot;&amp;ndash;with-pic&amp;quot; to configure flags&amp;rdquo;&lt;/td>
&lt;td>7.2.34&lt;/td>
&lt;td>&lt;code>4908ef2763a9&lt;/code> &amp;ldquo;Remove 7.2 (EOL)&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The variants survive as you would hope: &lt;code>7.4/bullseye/fpm&lt;/code> and &lt;code>7.3/bullseye/fpm&lt;/code> plus &lt;code>7.3/alpine3.15/fpm&lt;/code>, and for 7.2 the OS is one generation back: &lt;code>7.2/buster/fpm&lt;/code> and &lt;code>7.2/alpine3.12/fpm&lt;/code>. Note that none of those three last commits is a version bump except on 7.4; on 7.3 and 7.2 the final touch was an unrelated build-flag change, which is exactly why guessing from the log&amp;rsquo;s subject lines gets you the wrong tree.&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-dockerfile" data-lang="dockerfile">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">FROM&lt;/span>&lt;span style="color:#f1fa8c"> debian:bullseye-slim&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">RUN&lt;/span> sed -i &lt;span style="color:#f1fa8c">&amp;#39;s#http://deb.debian.org/debian-security#http://archive.debian.org/debian-security#&amp;#39;&lt;/span> /etc/apt/sources.list &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &lt;span style="color:#ff79c6">&amp;amp;&amp;amp;&lt;/span> grep -q &lt;span style="color:#f1fa8c">&amp;#39;archive.debian.org/debian-security bullseye-security&amp;#39;&lt;/span> /etc/apt/sources.list
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># ... unchanged; GPG_KEYS imported from keyserver.ubuntu.com ...&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">COPY&lt;/span> php2shell-7.3-0079-max_multipart_body_parts.patch &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> php2shell-7.3-0107-zstr_max_len.patch &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> php2shell-7.3-0139-avoid-state-corruption.patch /usr/src/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">RUN&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">set&lt;/span> -eux; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> apt-get update; apt-get install -y --no-install-recommends patch &amp;lt;build-deps&amp;gt;; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> docker-php-source extract; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">cd&lt;/span> /usr/src/php; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &lt;span style="color:#ff79c6">for&lt;/span> p in 0079-max_multipart_body_parts 0107-zstr_max_len 0139-avoid-state-corruption; &lt;span style="color:#ff79c6">do&lt;/span> &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> patch -p1 --forward &amp;lt; /usr/src/php2shell-7.3-&lt;span style="color:#8be9fd;font-style:italic">$p&lt;/span>.patch; &lt;span style="color:#ff79c6">done&lt;/span>; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> grep -q array_init_singleton Zend/zend_API.h; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span>&lt;span style="color:#6272a4"># repack with the patch in: docker-php-ext-install re-extracts php.tar.xz later&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> tar -cJf /usr/src/php.tar.xz -C /usr/src php; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> ./configure ... &lt;span style="color:#6272a4"># as in the original&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Because the layout stays identical to the official image (&lt;code>/usr/local/etc/php&lt;/code>, &lt;code>docker-php-ext-install&lt;/code>, &lt;code>pecl&lt;/code>, &lt;code>zz-docker.conf&lt;/code>), the application Dockerfile changes one line, and one base image per branch serves every project on that branch.&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-dockerfile" data-lang="dockerfile">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">FROM&lt;/span>&lt;span style="color:#f1fa8c"> php73-p2s/php-base:7.3.33-p2s-fpm-bullseye&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">COPY&lt;/span> zz-php2shell.ini /usr/local/etc/php/conf.d/zz-php2shell.ini &lt;span style="color:#6272a4"># session.upload_progress.enabled = 0&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Keep the OS family you already had. An Alpine project stays on Alpine, because moving to Debian changes musl to glibc and swaps the ImageMagick build underneath an &lt;code>imagick&lt;/code> extension that was built against the old one. A Debian project moving buster to bullseye is fine and sometimes necessary, since buster is entirely on &lt;code>archive.debian.org&lt;/code>. For anything integrating with a payment gateway that still negotiates legacy ciphers, bullseye is also the last stop with OpenSSL 1.1.1 — going to bookworm means OpenSSL 3 and that handshake needs testing before, not after.&lt;/p>
&lt;p>The &lt;code>zz-php2shell.ini&lt;/code> is redundant with the patch, which disables upload progress in code. Set it anyway, because it makes the state visible to whoever audits the box next.&lt;/p>
&lt;p>For 7.4, the whole patch step collapses to a single &lt;code>git format-patch 712e60f9edfd&lt;/code>, and a successful run prints 17 &lt;code>patching file …&lt;/code> lines with no offsets and no fuzz.&lt;/p>
&lt;h2 id="centos-6-your-own-rpm-and-nothing-to-roll-back-to">CentOS 6: your own RPM, and nothing to roll back to&lt;/h2>
&lt;p>For hosts where PHP 7.1 or 7.2 came from webtatic or remi on CentOS 6, the equivalent move is a rebuilt RPM from the same vendor&amp;rsquo;s spec, so dependencies and layout stay as the installed packages expect.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>7.1&lt;/strong> starts from the webtatic SRPM &lt;code>php71w-7.1.33-1.w6.src.rpm&lt;/code>, still on &lt;code>repo.webtatic.com/yum/el6/SRPMS/&lt;/code> and verifiable against the webtatic key. Add the sury patches as &lt;code>Patch400&lt;/code>–&lt;code>Patch402&lt;/code>, bump &lt;code>Release: 1.1&lt;/code> so the result is newer than what is installed. Build inside &lt;code>centos:6&lt;/code> pointed at &lt;code>vault.centos.org&lt;/code> and an EPEL 6 archive, deliberately &lt;strong>without&lt;/strong> remi, so dependencies come only from base and EPEL exactly as the original did.&lt;/li>
&lt;li>&lt;strong>7.2&lt;/strong> starts from remi&amp;rsquo;s &lt;code>php72.spec&lt;/code> at the commit remi built &lt;code>7.2.34-1.el6.remi&lt;/code> from, plus the sury 7.2 patches and the same &lt;code>Release&lt;/code> bump.&lt;/li>
&lt;/ul>
&lt;p>Apply the patches unconditionally, outside any &lt;code>%if&lt;/code> block, with &lt;code>--fuzz=0&lt;/code>, and put the marker grep in &lt;code>%prep&lt;/code> so the build fails rather than silently producing an unpatched RPM:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">9
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-spec" data-lang="spec">&lt;span style="display:flex;">&lt;span>&lt;span style="font-weight:bold">Release&lt;/span>: 1.1&lt;span style="color:#8be9fd;font-style:italic">%{?dist}&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="font-weight:bold">Patch400&lt;/span>: php2shell-7.1-0167-zstr_max_len.patch
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="font-weight:bold">Patch401&lt;/span>: php2shell-7.1-0186-avoid-state-corruption.patch
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="font-weight:bold">Patch402&lt;/span>: php2shell-7.1-0188-max_multipart_body_parts.patch
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># in %prep, after the vendor patches:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">%patch400&lt;/span> -p1 --fuzz=0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">%patch401&lt;/span> -p1 --fuzz=0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">%patch402&lt;/span> -p1 --fuzz=0
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>grep -q array_init_singleton Zend/zend_API.h
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Four things that cost time in that environment, none of them PHP&amp;rsquo;s fault:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>There is nothing to roll back to.&lt;/strong> The SRPMs and RPMs for the older 7.1 patch versions are gone from webtatic. Before &lt;code>rpm -Uvh&lt;/code>, archive every file the installed packages own and every config, because &lt;code>yum downgrade&lt;/code> has nowhere to go:&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd;font-style:italic">B&lt;/span>&lt;span style="color:#ff79c6">=&lt;/span>/root/php-backup; mkdir -p &lt;span style="color:#8be9fd;font-style:italic">$B&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>tar -czf &lt;span style="color:#8be9fd;font-style:italic">$B&lt;/span>/etc.tar.gz /etc/php.ini /etc/php.d /etc/php-fpm.conf /etc/php-fpm.d
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>rpm -qa &lt;span style="color:#f1fa8c">&amp;#39;php71w*&amp;#39;&lt;/span> | sort &amp;gt; &lt;span style="color:#8be9fd;font-style:italic">$B&lt;/span>/packages.txt
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>rpm -ql &lt;span style="color:#ff79c6">$(&lt;/span>rpm -qa &lt;span style="color:#f1fa8c">&amp;#39;php71w*&amp;#39;&lt;/span>&lt;span style="color:#ff79c6">)&lt;/span> | &lt;span style="color:#ff79c6">while&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">read&lt;/span> f; &lt;span style="color:#ff79c6">do&lt;/span> &lt;span style="color:#ff79c6">[&lt;/span> -f &lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$f&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span> &lt;span style="color:#ff79c6">]&lt;/span> &lt;span style="color:#ff79c6">||&lt;/span> &lt;span style="color:#ff79c6">[&lt;/span> -L &lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$f&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span> &lt;span style="color:#ff79c6">]&lt;/span> &lt;span style="color:#ff79c6">&amp;amp;&amp;amp;&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">echo&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$f&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>; &lt;span style="color:#ff79c6">done&lt;/span> &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> | tar -czf &lt;span style="color:#8be9fd;font-style:italic">$B&lt;/span>/files.tar.gz -T -
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>rpm -Uvh --test *.rpm &lt;span style="color:#ff79c6">&amp;amp;&amp;amp;&lt;/span> rpm -Uvh *.rpm
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Restoring means unpacking those archives; the rpm database stays on the new version, which is ugly but recoverable.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>PHP 7.2&amp;rsquo;s &lt;code>buildconf&lt;/code> wants autoconf 2.64 or newer&lt;/strong> and EL6 ships 2.63. Install &lt;code>autoconf268&lt;/code> from EPEL and build with &lt;code>PHP_AUTOCONF=autoconf268 PHP_AUTOHEADER=autoheader268&lt;/code>.&lt;/li>
&lt;li>&lt;strong>The &lt;code>oniguruma5php&lt;/code> dependency&lt;/strong> (&lt;code>libonig.so.105&lt;/code>) is missing on older remi installs; take it from the remi el6 archive.&lt;/li>
&lt;li>&lt;strong>PHP installed outside the rpm database&lt;/strong> happens on long-lived stages, and then &lt;code>rpm -Uvh&lt;/code> needs &lt;code>--nodeps&lt;/code>.&lt;/li>
&lt;/ul>
&lt;p>One pleasant surprise: &lt;code>rpm -Uvh&lt;/code> runs the &lt;code>condrestart&lt;/code> scriptlet itself, so php-fpm comes back without a separate restart and the gap is a second or two.&lt;/p>
&lt;h2 id="traps-in-the-old-build-environment">Traps in the old build environment&lt;/h2>
&lt;p>Reviving a Dockerfile from 2019 or 2020 breaks on infrastructure, not on code:&lt;/p>
&lt;ul>
&lt;li>&lt;code>apt-get update&lt;/code> 404s, because buster and &lt;code>bullseye-security&lt;/code> moved to &lt;code>archive.debian.org&lt;/code>. Rewrite &lt;code>sources.list&lt;/code> and follow it with a &lt;code>grep -q&lt;/code> assert so a future format change fails the build instead of quietly using the wrong mirror.&lt;/li>
&lt;li>The tarball signing key import fails, because &lt;code>ha.pool.sks-keyservers.net&lt;/code> shut down. &lt;code>keyserver.ubuntu.com&lt;/code> works and the signature is still verified — do not drop the verification step, change the keyserver.&lt;/li>
&lt;li>Alpine 3.12 and 3.15 packages are still on the mirrors, so those builds needed no repository edits at all.&lt;/li>
&lt;/ul>
&lt;p>Keep the edits confined to source delivery. The PHP build itself and every &lt;code>configure&lt;/code> flag stay exactly as the official image had them, which is the whole reason for starting from the official Dockerfile.&lt;/p>
&lt;h2 id="what-changes-for-the-application">What changes for the application&lt;/h2>
&lt;p>The behavioural changes are identical across all the EOL ports, because they are the same patch:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>OOM terminates the worker through &lt;code>_exit(1)&lt;/code>.&lt;/strong> Shutdown functions do not run, the message goes to stderr, and nginx returns 502 where it returned 500. An application that regularly brushes &lt;code>memory_limit&lt;/code> will start serving 502s, and monitoring that matches exit code 255 will not match 1.&lt;/li>
&lt;li>&lt;strong>Session upload progress is off in code&lt;/strong>, whatever &lt;code>php.ini&lt;/code> reports.&lt;/li>
&lt;li>&lt;strong>&lt;code>max_multipart_body_parts&lt;/code> appears&lt;/strong>, defaulting to &lt;code>-1&lt;/code>, which falls back to &lt;code>max_input_vars + max_file_uploads&lt;/code>, or 1020 parts on stock defaults. Past that, PHP emits &amp;ldquo;Multipart body parts limit exceeded %d. To increase the limit change max_multipart_body_parts in php.ini.&amp;rdquo; Forms with thousands of fields will notice.&lt;/li>
&lt;li>&lt;strong>Only &lt;code>Content-Disposition&lt;/code> and &lt;code>Content-Type&lt;/code> are retained&lt;/strong> on multipart parts.&lt;/li>
&lt;/ul>
&lt;p>What I did not test is whether the exploit worked before and stopped working after. No PoC was run. The claim here is that the official and maintainer patches are applied, compiled and behaving as their diffs say, which is a different and weaker claim than &amp;ldquo;verified not exploitable&amp;rdquo;. It is worth being precise about that.&lt;/p>
&lt;p>Two more things I am not claiming. Whether &lt;code>memory_limit = -1&lt;/code> under OpenVZ runs into beancounters the way it runs into &lt;code>RLIMIT_AS&lt;/code> is a guess — the shape of the failure is the same, but I have not tested it. And the &lt;code>docker-php-ext-install&lt;/code> repack is a precaution; I never saw it cause a real failure.&lt;/p>
&lt;h2 id="step-by-step">Step by step&lt;/h2>
&lt;ol>
&lt;li>Identify the exact patch version and SAPI of every 7.x install. Not the branch, the patch version — the route depends on it.&lt;/li>
&lt;li>Pick the source per branch: official commit for 7.4, sury patch set for 7.3, 7.2 and 7.1.&lt;/li>
&lt;li>Read &lt;code>debian/patches/series&lt;/code> and take the prerequisites, not just the bailout patch. Check whether the prerequisite&amp;rsquo;s number is higher than the bailout patch&amp;rsquo;s; if it is, make sure you have the later package revision.&lt;/li>
&lt;li>Target the last patch release of the branch. Check what other CVEs you are leaving behind if you do not.&lt;/li>
&lt;li>Build from the last docker-library Dockerfile for the branch, or the vendor&amp;rsquo;s RPM spec. Repack &lt;code>php.tar.xz&lt;/code> after patching.&lt;/li>
&lt;li>Put a marker assert in the build that returns zero on a vanilla tree.&lt;/li>
&lt;li>Verify the result three ways: &lt;code>max_multipart_body_parts&lt;/code> in &lt;code>php -i&lt;/code>, the marker symbol in the source tree, and the OOM behaviour test.&lt;/li>
&lt;li>On CentOS 6, archive the installed files before &lt;code>rpm -Uvh&lt;/code>. There is no package to go back to.&lt;/li>
&lt;/ol>
&lt;h2 id="bottom-line">Bottom line&lt;/h2>
&lt;p>For an EOL branch, &amp;ldquo;no release&amp;rdquo; is not &amp;ldquo;no fix&amp;rdquo;. The official backport for 7.4 and the sury patch sets for 7.1 through 7.3, applied on top of the last docker-library Dockerfile or the vendor&amp;rsquo;s RPM spec, produce a fixed PHP on the same branch. The application changes one &lt;code>FROM&lt;/code> line or installs packages one &lt;code>Release&lt;/code> newer.&lt;/p>
&lt;p>The part that actually goes wrong is verification, and it has three independent layers. The patch&amp;rsquo;s own dependencies: &lt;code>--dry-run&lt;/code> is clean on 7.3 and the build dies on &lt;code>ZSTR_MAX_LEN&lt;/code>, which exists in 7.4 and 8.0 and nowhere below. The marker in the tree: it has to be a symbol the patch introduces, which &lt;code>zend_objects_store_mark_destructed&lt;/code> is not and &lt;code>array_init_singleton&lt;/code> is. And behaviour: exit code 1 with shutdown functions skipped, which is the signature of an EOL backport and not of a fixed supported branch.&lt;/p>
&lt;p>Then patch the last release of the branch rather than the version you are on, because on 7.1 and 7.2 there is a second unauthenticated RCE underneath this one, and on 7.4.5 the official patch simply does not apply.&lt;/p>
&lt;p>Companion pieces: &lt;a href="https://bitpage.me/security/php2shell-mixed-fleet-audit/">the fleet-wide audit and verification matrix&lt;/a>, &lt;a href="https://bitpage.me/security/php2shell-php8-frozen-docker-tags/">PHP 8 and the frozen Docker tags&lt;/a>, and &lt;a href="https://bitpage.me/security/php2shell-php56-minimal-backport/">PHP 5.6&lt;/a>.&lt;/p>
&lt;p>Primary sources: &lt;a href="https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w">GHSA-w8hw-w9fx-j79w&lt;/a>, &lt;a href="https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp">GHSA-6wmf-4rv5-p3rp&lt;/a>, &lt;a href="https://github.com/php/php-src">php-src&lt;/a>, &lt;a href="https://packages.sury.org/php/pool/main/p/">packages.sury.org&lt;/a>, &lt;a href="https://github.com/docker-library/php">docker-library/php&lt;/a>, &lt;a href="https://www.php.net/ChangeLog-7.php">php.net ChangeLog-7&lt;/a>.&lt;/p></content:encoded></item><item><title>PHP2Shell on PHP 8: docker build --pull succeeded and PHP is still 8.2.29</title><link>https://bitpage.me/security/php2shell-php8-frozen-docker-tags/</link><pubDate>Mon, 26 Oct 2026 05:15:00 +0000</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.me/security/php2shell-php8-frozen-docker-tags/</guid><description>php:8.2-fpm-bullseye has been frozen at PHP 8.2.29 since 5 August 2025, so --pull on that tag downloads a vulnerable image and reports success. Five measured ways a Docker tag misleads you about the PHP version inside it, and what to use instead on 8.2, 8.4 and EOL 8.0.</description><content:encoded>&lt;p>&lt;strong>Short answer:&lt;/strong> &lt;code>docker build --pull&lt;/code> honestly downloads the newest image under your tag, which tells you nothing, because the tag may not have been rebuilt in over a year. &lt;code>php:8.2-fpm-bullseye&lt;/code> has carried PHP 8.2.29 since 5 August 2025 and never received the PHP2Shell fix; the fixed 8.2.35 exists only under &lt;code>-bookworm&lt;/code> and &lt;code>-trixie&lt;/code>. Check &lt;code>PHP_VERSION&lt;/code> in the base image config before you build, not the build log afterwards.&lt;/p>
&lt;p>When PHP2Shell landed I expected the 8.x half of the work to be the easy half. Bump the tag, recreate the container, move on to the EOL branches where the actual problem lives. In several places the rebuild went green and the PHP inside was still vulnerable, and the check everybody runs (reading the build output) did not show it.&lt;/p>
&lt;p>The background on the two CVEs and the fleet-wide audit is in &lt;a href="https://bitpage.me/security/php2shell-mixed-fleet-audit/">the overview piece&lt;/a>. This one is only about PHP 8 and Docker.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Fix:&lt;/strong> pin the OS suffix explicitly (&lt;code>php:8.2-fpm-bookworm&lt;/code>), build with &lt;code>--pull --platform linux/amd64&lt;/code>, and verify &lt;code>PHP_VERSION&lt;/code> in the base image before the application build.&lt;/li>
&lt;li>&lt;strong>Fix, supported branches:&lt;/strong> &lt;code>php -r 'var_dump(ini_get(&amp;quot;memory_hard_limit_percent&amp;quot;) !== false);'&lt;/code> returns true only on 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 and later. It is the one static check that survives a distro backport. The &lt;code>!== false&lt;/code> is load-bearing: the directive defaults to &lt;code>NULL&lt;/code>, so &lt;code>ini_get()&lt;/code> returns an empty string and a truthiness test calls a patched build unpatched.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> minor tags of third-party images freeze too. &lt;code>dunglas/frankenphp:1.8-php8.4-bookworm&lt;/code> has held PHP 8.4.10 since 14 July 2025; 8.4.27 ships only in FrankenPHP 1.13.x.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> &lt;code>wordpress:latest&lt;/code> has moved to PHP 8.3.36. A blind &lt;code>pull&lt;/code> changes your PHP minor version without saying so.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> EOL 8.0 has no fixed release at all. The only path is 8.0.30 plus the official backport commit, which means 23 patch versions of forward movement before you even start.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> the OOM behaviour test does not prove patching on 8.2. I measured 8.2.29 and 8.2.35 behaving identically.&lt;/li>
&lt;/ul>
&lt;h2 id="why-the-same-tag-with-a-fresh-pull-is-not-a-fix">Why the same tag with a fresh pull is not a fix&lt;/h2>
&lt;p>An official image is PHP plus an OS plus the docker-library tag policy, and the fixed PHP is not under every tag where you would expect it. I went through the registry API for every tag I had in a Dockerfile. Five distinct failure modes, all measured on 26 October 2026:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tag&lt;/th>
&lt;th>PHP inside&lt;/th>
&lt;th>Tag last pushed&lt;/th>
&lt;th>What it does to you&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>php:8.2-fpm-bullseye&lt;/code>&lt;/td>
&lt;td>8.2.29&lt;/td>
&lt;td>2025-08-05&lt;/td>
&lt;td>frozen; &lt;code>--pull&lt;/code> fetches a vulnerable image and succeeds&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>php:8.2-fpm-bookworm&lt;/code>&lt;/td>
&lt;td>8.2.35&lt;/td>
&lt;td>2026-10-21&lt;/td>
&lt;td>fixed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>php:8.2-fpm&lt;/code>&lt;/td>
&lt;td>8.2.35&lt;/td>
&lt;td>2026-10-24&lt;/td>
&lt;td>fixed, but same config digest as &lt;code>-trixie&lt;/code>; the OS moved under you&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>php:8.4-fpm-bookworm&lt;/code>&lt;/td>
&lt;td>8.4.27&lt;/td>
&lt;td>2026-10-21&lt;/td>
&lt;td>fixed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>php:8.0-fpm&lt;/code>&lt;/td>
&lt;td>8.0.30&lt;/td>
&lt;td>2023-11-22&lt;/td>
&lt;td>frozen; no fixed release exists for the branch&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>php:8.1-cli-alpine&lt;/code>&lt;/td>
&lt;td>8.1.34&lt;/td>
&lt;td>2025-12-22&lt;/td>
&lt;td>frozen; branch is EOL, no fixed release&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>wordpress:latest&lt;/code>&lt;/td>
&lt;td>8.3.36&lt;/td>
&lt;td>2026-10-22&lt;/td>
&lt;td>fixed, but the PHP minor version changed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>wordpress:php8.2-apache&lt;/code>&lt;/td>
&lt;td>8.2.35&lt;/td>
&lt;td>2026-10-22&lt;/td>
&lt;td>fixed, branch pinned&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>dunglas/frankenphp:1.8-php8.4-bookworm&lt;/code>&lt;/td>
&lt;td>8.4.10&lt;/td>
&lt;td>2025-07-14&lt;/td>
&lt;td>frozen minor tag&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>dunglas/frankenphp:1.13.2-php8.4.27-bookworm&lt;/code>&lt;/td>
&lt;td>8.4.27&lt;/td>
&lt;td>2026-10-21&lt;/td>
&lt;td>fixed&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>One caveat on that third column, because I got it wrong myself at first and the two numbers are a few days apart. An image has two timestamps: &lt;code>created&lt;/code> in its config blob, which is when it was built, and the tag&amp;rsquo;s own last push, which is when that name last moved. For &lt;code>php:8.2-fpm-bullseye&lt;/code> they are 2025-07-31 and 2025-08-05. The column above is the push date, because the question being asked is &amp;ldquo;when did this tag last change&amp;rdquo;, not &amp;ldquo;when was this layer compiled&amp;rdquo;. If you script this, pick one field and label it; mixing them produces a table that nobody including you can reconcile later.&lt;/p>
&lt;p>Reading down that list, the modes are:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Tags on older OS releases are frozen.&lt;/strong> &lt;code>-bullseye&lt;/code> stopped at 8.2.29. The branch is still supported, the tag is not still built. This is the one that cost me time, because every signal except &lt;code>PHP_VERSION&lt;/code> looks healthy: the pull succeeds, the layers for the application are new, the build date on the final image is today.&lt;/li>
&lt;li>&lt;strong>The suffix-less tag changed OS.&lt;/strong> &lt;code>php:8.2-fpm&lt;/code> now resolves to the same image config digest as &lt;code>php:8.2-fpm-trixie&lt;/code> (Debian 13), so the two names are the same image under different labels. Dockerfiles that call &lt;code>apt-key&lt;/code> stop building, and OpenSSL, ICU and libpq all move at once.&lt;/li>
&lt;li>&lt;strong>Minor tags of third-party images freeze the same way.&lt;/strong> FrankenPHP&amp;rsquo;s &lt;code>1.8-php8.4-bookworm&lt;/code> is a tag that looks floating and has not moved since July 2025.&lt;/li>
&lt;li>&lt;strong>A &lt;code>latest&lt;/code> tag can change the PHP branch.&lt;/strong> An image built when &lt;code>wordpress:latest&lt;/code> carried 8.2 now gets 8.3.36 from the identical Dockerfile line.&lt;/li>
&lt;li>&lt;strong>Distribution packages backport without a version bump.&lt;/strong> For a PPA or distro install, &lt;code>php -v&lt;/code> cannot answer the question at all; the package changelog can.&lt;/li>
&lt;/ol>
&lt;p>Alpine is not an escape hatch. Alpine minor tags freeze exactly like Debian ones: &lt;code>php:8.2-cli-alpine3.20&lt;/code> carries 8.2.28, &lt;code>alpine3.21&lt;/code> carries 8.2.29, &lt;code>alpine3.22&lt;/code> carries 8.2.31, and only the bare &lt;code>-alpine&lt;/code> suffix carries 8.2.35.&lt;/p>
&lt;p>The check that would have caught all of it takes one command and belongs before the application build, not after:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker pull php:8.2-fpm-bullseye
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker image inspect php:8.2-fpm-bullseye &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> --format &lt;span style="color:#f1fa8c">&amp;#39;{{range .Config.Env}}{{println .}}{{end}}&amp;#39;&lt;/span> | grep PHP_VERSION
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker image inspect -f &lt;span style="color:#f1fa8c">&amp;#39;{{.Created}}&amp;#39;&lt;/span> php:8.2-fpm-bullseye
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>That &lt;code>.Created&lt;/code> is the build date from the config blob, which is the only one of the two timestamps a local Docker can show you; the push date needs the registry API. For this purpose the build date is enough. If it is more than a few weeks old on a supported branch, the tag is not being rebuilt and no amount of &lt;code>--pull&lt;/code> will change it.&lt;/p>
&lt;h2 id="supported-branches-what-to-actually-pin">Supported branches: what to actually pin&lt;/h2>
&lt;p>Take a setup with a Laravel app on &lt;code>php:8.2-fpm-bookworm&lt;/code>, a couple of small services still on &lt;code>-bullseye&lt;/code>, a WordPress site on &lt;code>wordpress:latest&lt;/code>, an Octane service on the FrankenPHP 1.8 minor tag, and a CLI install from the ondrej PPA. Each needs a different change and only one of them is a version bump.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Move &lt;code>-bullseye&lt;/code> to &lt;code>-bookworm&lt;/code>.&lt;/strong> That is Debian 11 to 12, which moves more than PHP: OpenSSL 1.1.1w to 3.0.22, ICU 67 to 72, libpq 13 to 15, curl 7.74 to 7.88. OpenSSL 3 is the one that bites, because it drops legacy ciphers. Run a TLS handshake against every external API you talk to from both the old and the new image before deploying, not after.&lt;/li>
&lt;li>&lt;strong>Never use the suffix-less tag.&lt;/strong> &lt;code>php:8.2-fpm&lt;/code> is a moving OS target by design.&lt;/li>
&lt;li>&lt;strong>WordPress: &lt;code>wordpress:php8.2-apache&lt;/code>, not &lt;code>latest&lt;/code>.&lt;/strong> Then only the PHP patch version and the OS move, and going to 8.3 becomes a decision rather than a side effect. Worth knowing separately: changing the WordPress image does not update the WordPress core in the volume, because the entrypoint only copies core into an empty directory.&lt;/li>
&lt;li>&lt;strong>FrankenPHP: pin both versions and the digest&lt;/strong>, as in &lt;code>dunglas/frankenphp:1.13.2-php8.4.27-bookworm@sha256:…&lt;/code>. Going from 1.8 to 1.13 is a real upgrade of the server, not just of PHP: the header limit becomes 16 KiB, &lt;code>putenv()&lt;/code> no longer writes to the process environment, and &lt;code>SCRIPT_NAME&lt;/code> and &lt;code>PATH_INFO&lt;/code> are set differently. Read the &lt;a href="https://github.com/php/frankenphp/releases">FrankenPHP release notes&lt;/a> before the bump, not during the incident.&lt;/li>
&lt;li>&lt;strong>PPA or distro package: &lt;code>apt install --only-upgrade 'php8.2-*'&lt;/code>&lt;/strong>, then confirm from the changelog rather than the version. First run &lt;code>dpkg -l 'php8.2*'&lt;/code> and check whether an fpm, cgi or mod_php package is installed that you had forgotten about. A CLI-only box that quietly has &lt;code>libapache2-mod-php8.2&lt;/code> on it is a web-facing install.&lt;/li>
&lt;li>&lt;strong>Add &lt;code>session.upload_progress.enabled = 0&lt;/code>&lt;/strong> to the image&amp;rsquo;s ini directory regardless of branch. It closes the confirmed FPM path for CVE-2026-109619 and breaks nothing except upload progress bars. It has to be in &lt;code>php.ini&lt;/code> or the pool config: &lt;code>ini_set()&lt;/code> and &lt;code>.user.ini&lt;/code> are applied after the body is parsed, which is too late by construction.&lt;/li>
&lt;/ul>
&lt;p>Always build with &lt;code>--platform linux/amd64&lt;/code> if you build on an Apple Silicon laptop. An arm64 image that cannot start on the target is at least an obvious failure; the subtler version is a successful multi-arch build where you test the wrong architecture locally.&lt;/p>
&lt;p>I rejected &lt;code>memory_limit = -1&lt;/code> as a standalone mitigation here. The advisory lists it for 109618, but on a pool with a few hundred children and no container memory cap it trades a high-complexity RCE for a reliable OOM of the whole VM. &lt;code>RLIMIT_AS&lt;/code> and &lt;code>ulimit -v&lt;/code> are worse: they trigger the vulnerable allocation failure themselves.&lt;/p>
&lt;h2 id="eol-80-build-your-own-base-image">EOL 8.0: build your own base image&lt;/h2>
&lt;p>There is no fixed 8.0 release, no tarball and no Docker image. What exists is a branch in php-src: &lt;code>PHP-8.0-CVE-2026-109618-109619&lt;/code>, which is the &lt;code>php-8.0.30&lt;/code> tag plus a single commit, &lt;code>82f8f31b16a0&lt;/code>, titled &amp;ldquo;Avoid state corruption via bailout&amp;rdquo; and described in its own message as a minimal mitigation for an EOL version. It touches 17 files for +229/−59: &lt;code>Zend/*&lt;/code>, &lt;code>main/rfc1867.c&lt;/code>, &lt;code>main/SAPI.c&lt;/code>, &lt;code>main/main.c&lt;/code>, &lt;code>main/php_variables.c&lt;/code> and &lt;code>ext/session/session.c&lt;/code>. The &lt;code>fpm&lt;/code> and &lt;code>apache2handler&lt;/code> SAPI directories are untouched.&lt;/p>
&lt;p>The patch applies cleanly to 8.0.30 and conflicts on 8.0.7 in &lt;code>zend_vm_*&lt;/code>. That settles the route: there is exactly one, and it is 8.0.30 plus the patch. For an application pinned to 8.0.7 from a 2021 image that is 23 patch versions of forward movement, which is work you have to scope honestly rather than wish away. The &lt;a href="https://www.php.net/ChangeLog-8.php">8.0 NEWS&lt;/a> entries between those two versions that actually change behaviour: 8.0.28 introduced &lt;code>max_multipart_body_parts&lt;/code> and fixed &lt;code>password_verify&lt;/code>; 8.0.24 and 8.0.27 stopped url-decoding cookie names; 8.0.30 stopped libxml loading external entities; 8.0.8 made &lt;code>FILTER_VALIDATE_URL&lt;/code> stricter.&lt;/p>
&lt;p>The base image comes from the last docker-library Dockerfile that still had the branch. For 8.0 that is commit &lt;code>41c9ad0b43a5&lt;/code>, variant &lt;code>8.0/bullseye/fpm&lt;/code>, the last commit to touch that file before &lt;code>4d3ed4a9bcb0&lt;/code> &amp;ldquo;Remove PHP 8.0 (EOL 26 November 2023)&amp;rdquo;. It carries &lt;code>ENV PHP_VERSION 8.0.30&lt;/code> on line 63, which is the version the official patch wants anyway. Using the official Dockerfile rather than writing your own matters more than it looks: the layout stays identical, so &lt;code>/usr/local/etc/php&lt;/code>, &lt;code>docker-php-ext-install&lt;/code> and &lt;code>pecl&lt;/code> all keep working, and the application Dockerfile changes one &lt;code>FROM&lt;/code> line.&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-dockerfile" data-lang="dockerfile">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">FROM&lt;/span>&lt;span style="color:#f1fa8c"> debian:bullseye-slim&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># bullseye-security moved to archive.debian.org when Debian 11 left LTS&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">RUN&lt;/span> sed -i &lt;span style="color:#f1fa8c">&amp;#39;s#http://deb.debian.org/debian-security#http://archive.debian.org/debian-security#&amp;#39;&lt;/span> /etc/apt/sources.list &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &lt;span style="color:#ff79c6">&amp;amp;&amp;amp;&lt;/span> grep -q &lt;span style="color:#f1fa8c">&amp;#39;archive.debian.org/debian-security bullseye-security&amp;#39;&lt;/span> /etc/apt/sources.list
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># ... unchanged up to the PHP build ...&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">COPY&lt;/span> php2shell-8.0.patch /usr/src/php2shell-8.0.patch &lt;span style="color:#6272a4"># git format-patch 82f8f31b16a0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">RUN&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">set&lt;/span> -eux; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> apt-get update; apt-get install -y --no-install-recommends patch &amp;lt;build-deps&amp;gt;; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> docker-php-source extract; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">cd&lt;/span> /usr/src/php; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> patch -p1 --forward &amp;lt; /usr/src/php2shell-8.0.patch; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> grep -c array_init_singleton Zend/zend_API.h; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span>&lt;span style="color:#6272a4"># repack the patched tree: docker-php-ext-install re-extracts php.tar.xz later&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> tar -cJf /usr/src/php.tar.xz -C /usr/src php; &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> ./configure ... &lt;span style="color:#6272a4"># as in the original&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The &lt;code>grep -q&lt;/code> after &lt;code>patch&lt;/code> is an assert, and choosing the right symbol matters. My first attempt used &lt;code>zend_objects_store_mark_destructed&lt;/code>, which exists in unpatched source as well, so the assert passed unconditionally and proved nothing. &lt;code>array_init_singleton&lt;/code> in &lt;code>Zend/zend_API.h&lt;/code> occurs zero times in vanilla 8.0.30 and is added by the patch, which is what an assert needs.&lt;/p>
&lt;p>I rejected the sury &lt;code>php8.0&lt;/code> packages on &lt;code>debian:bookworm-slim&lt;/code> for this one. The fix is there, in &lt;code>0137-Avoid-state-corruption-via-bailout.patch&lt;/code>, and the package also carries dozens of post-EOL CVE fixes the official backport does not, but the layout is different (&lt;code>/etc/php/8.0&lt;/code>, &lt;code>php-fpm8.0&lt;/code>, extensions via apt), so the application Dockerfile would have to be rewritten. For a fleet-wide emergency, one changed &lt;code>FROM&lt;/code> line beats a better-maintained package.&lt;/p>
&lt;h2 id="gotchas">Gotchas&lt;/h2>
&lt;h3 id="the-patch-is-in-and-the-version-says-otherwise">The patch is in and the version says otherwise&lt;/h3>
&lt;p>&lt;code>php -v&lt;/code> reports 8.0.30 with and without the patch. Worse, the obvious marker does not work on this branch: &lt;code>max_multipart_body_parts&lt;/code> exists in 8.0.28 and later regardless of the patch — I counted it in vanilla &lt;code>main/main.c&lt;/code> for &lt;code>php-8.0.28&lt;/code> and &lt;code>php-8.0.30&lt;/code>, one occurrence each. And &lt;code>php -i&lt;/code> still prints &lt;code>session.upload_progress.enabled =&amp;gt; On&lt;/code> even though the patch disables the feature in code with &lt;code>if (!PS(rfc1867_enabled) || true)&lt;/code>.&lt;/p>
&lt;p>On 8.0, then, a version-and-ini scanner will call your patched image vulnerable, and an ini showing &lt;code>Off&lt;/code> does not prove a patch is present. The proof is behavioural:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd;font-style:italic">T&lt;/span>&lt;span style="color:#ff79c6">=&lt;/span>&lt;span style="color:#f1fa8c">&amp;#39;register_shutdown_function(function(){echo &amp;#34;SHUTDOWN-CALLED\n&amp;#34;;}); $a=str_repeat(&amp;#34;x&amp;#34;, 20*1024*1024);&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker run --rm IMG php -d &lt;span style="color:#8be9fd;font-style:italic">memory_limit&lt;/span>&lt;span style="color:#ff79c6">=&lt;/span>8M -r &lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$T&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>; &lt;span style="color:#8be9fd;font-style:italic">echo&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;exit=&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$?&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># patched: Fatal error ..., exit=1, no SHUTDOWN-CALLED&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># unpatched: Fatal error ..., exit=255, SHUTDOWN-CALLED&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Then tag the result so nobody has to rerun the test to identify the build: an image tag like &lt;code>8.0.30-p2s-…&lt;/code> plus a &lt;code>LABEL&lt;/code>, and the behaviour test kept in the build checklist.&lt;/p>
&lt;h3 id="that-same-test-proves-nothing-on-82">That same test proves nothing on 8.2&lt;/h3>
&lt;p>This is the correction I had to make to my own notes. I assumed the exit-code-1 signature was what &amp;ldquo;patched&amp;rdquo; looked like everywhere. It is not. I ran the identical script in a 512 MB container with &lt;code>memory_limit=64M&lt;/code> against three builds:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Build&lt;/th>
&lt;th>Exit&lt;/th>
&lt;th>Fatal error to&lt;/th>
&lt;th>Shutdown function&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>php:8.2-cli-alpine3.21&lt;/code>, 8.2.29 (vulnerable)&lt;/td>
&lt;td>255&lt;/td>
&lt;td>stdout&lt;/td>
&lt;td>ran&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>php:8.2-cli-alpine&lt;/code>, 8.2.35 (fixed)&lt;/td>
&lt;td>255&lt;/td>
&lt;td>stdout&lt;/td>
&lt;td>ran&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>sury &lt;code>php8.0-cli&lt;/code> &lt;code>1:8.0.30-25&lt;/code> (EOL backport)&lt;/td>
&lt;td>1&lt;/td>
&lt;td>stderr&lt;/td>
&lt;td>did not run&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The upstream fix for supported branches deliberately leaves ordinary &lt;code>memory_limit&lt;/code> handling alone — the soft OOM is deferred to a safe point rather than turned into a process exit. Only the EOL backports convert it into &lt;code>_exit(1)&lt;/code>. Use the behaviour test on EOL branches and the new &lt;code>memory_hard_limit_percent&lt;/code> directive on supported ones; swapping them gives you a confident wrong answer in both directions.&lt;/p>
&lt;h3 id="the-image-is-updated-and-the-running-php-is-not">The image is updated and the running PHP is not&lt;/h3>
&lt;p>This one is delayed, which makes it worse. A new image in the registry is not a new PHP in production.&lt;/p>
&lt;p>The stage deployment role in one place ran &lt;code>docker compose up -d --no-recreate&lt;/code> with no &lt;code>pull&lt;/code>, so the stages would never have picked the new image up on their own. I found that by reading the role before the rollout rather than after, which was luck as much as process. CI runners are the same shape of problem: a runner pulls &lt;code>php-cli:8.2&lt;/code> when its next job starts, so until then it is still running the vulnerable image, and your report needs a line that says &amp;ldquo;fixed later&amp;rdquo; rather than &amp;ldquo;fixed&amp;rdquo;.&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker inspect -f &lt;span style="color:#f1fa8c">&amp;#39;{{.Image}}&amp;#39;&lt;/span> &amp;lt;container&amp;gt; &lt;span style="color:#6272a4"># must equal the new image ID&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker image inspect &amp;lt;image:tag&amp;gt; -f &lt;span style="color:#f1fa8c">&amp;#39;{{.Id}}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>docker &lt;span style="color:#8be9fd;font-style:italic">exec&lt;/span> &amp;lt;container&amp;gt; php -r &lt;span style="color:#f1fa8c">&amp;#39;echo PHP_VERSION, &amp;#34; up=&amp;#34;, ini_get(&amp;#34;session.upload_progress.enabled&amp;#34;), PHP_EOL;&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="docker-php-ext-install-rebuilds-from-unpatched-sources">&lt;code>docker-php-ext-install&lt;/code> rebuilds from unpatched sources&lt;/h3>
&lt;p>The official Dockerfile ends with &lt;code>docker-php-source delete&lt;/code>, leaving &lt;code>/usr/src/php.tar.xz&lt;/code> in place. Later, an application image running &lt;code>docker-php-ext-install&lt;/code> re-extracts that tarball (the pristine one, without your patch) and compiles extensions against it. Repacking the patched tree into the same &lt;code>php.tar.xz&lt;/code> right after &lt;code>patch&lt;/code> costs one line and removes the question.&lt;/p>
&lt;p>I have not seen this cause a real failure, so treat it as a precaution rather than a reported bug. It is cheap enough that the distinction does not matter.&lt;/p>
&lt;h3 id="a-2021-dockerfile-no-longer-builds-and-without-a-build-there-is-no-patch">A 2021 Dockerfile no longer builds, and without a build there is no patch&lt;/h3>
&lt;p>Two separate breakages, both unrelated to PHP:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Debian 11 left LTS in August 2026.&lt;/strong> &lt;code>bullseye-security&lt;/code> moved to &lt;code>archive.debian.org&lt;/code> and returns 404 on &lt;code>deb.debian.org&lt;/code>. Rewrite &lt;code>sources.list&lt;/code> and put a &lt;code>grep -q&lt;/code> after it so the build fails loudly if the file format changes under you rather than silently falling back.&lt;/li>
&lt;li>&lt;strong>The pecl.php.net REST channel no longer serves old releases for PHP 8.0&lt;/strong>, so &lt;code>pecl install redis&lt;/code> fails. Download the exact version you were already running (&lt;code>redis-5.3.4.tgz&lt;/code> in my case) and verify the sha256 instead.&lt;/li>
&lt;/ul>
&lt;p>Neither is interesting. Both are the reason an emergency rebuild of a five-year-old image takes a day instead of an hour, and both are worth discovering before the day you need the patch.&lt;/p>
&lt;h2 id="what-changes-for-the-application">What changes for the application&lt;/h2>
&lt;p>On the EOL backport, three behavioural changes, all visible from the application side:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>OOM terminates through &lt;code>_exit(1)&lt;/code>.&lt;/strong> Shutdown functions do not run, the fatal error goes to stderr, and FPM behind nginx answers 502 where it answered 500. An application that regularly brushes &lt;code>memory_limit&lt;/code> will start serving 502s, and anything you do in a shutdown handler (flushing an error reporter, releasing a queue job) stops happening.&lt;/li>
&lt;li>&lt;strong>Session upload progress is off in code&lt;/strong>, whatever &lt;code>php.ini&lt;/code> says.&lt;/li>
&lt;li>&lt;strong>&lt;code>max_multipart_body_parts&lt;/code> caps form size.&lt;/strong> Default &lt;code>-1&lt;/code> falls back to &lt;code>max_input_vars + max_file_uploads&lt;/code>; on stock defaults that is 1020 parts, after which PHP emits &amp;ldquo;Multipart body parts limit exceeded %d. To increase the limit change max_multipart_body_parts in php.ini.&amp;rdquo; Forms with thousands of fields will hit it.&lt;/li>
&lt;/ul>
&lt;p>On the supported branches the picture is different, and I checked this in the source because the advisory does not spell it out. Upstream keeps session upload progress and adds a guard instead of amputating the feature: &lt;code>if (PG(modules_activated)) { /* request_parse_body(). Do not run here as it corrupts the session. */ return retval; }&lt;/code>. That guard is new in the fixed release: &lt;code>PG(modules_activated)&lt;/code> appears twice in &lt;code>ext/session/session.c&lt;/code> on both 8.2.29 and 8.2.34, and three times on 8.2.35. An 8.2 app with upload progress bars keeps them; an EOL app loses them the moment it is patched.&lt;/p>
&lt;h2 id="step-by-step">Step by step&lt;/h2>
&lt;ol>
&lt;li>For every Dockerfile, print &lt;code>PHP_VERSION&lt;/code> and &lt;code>Created&lt;/code> from the base image under the tag you currently reference. Decide from that, not from the tag name.&lt;/li>
&lt;li>Replace suffix-less and OS-suffixed-but-frozen tags with a tag that is still being built, pinning the OS explicitly.&lt;/li>
&lt;li>For third-party images, pin the full version triple plus the digest.&lt;/li>
&lt;li>Build with &lt;code>--pull --platform linux/amd64&lt;/code>.&lt;/li>
&lt;li>Before deploying a Debian 11 to 12 move, test TLS handshakes to every external API from both images.&lt;/li>
&lt;li>Verify, per branch: &lt;code>memory_hard_limit_percent&lt;/code> on 8.2 and newer, the package changelog for distro installs, the OOM behaviour test for self-built EOL images.&lt;/li>
&lt;li>Recreate every PHP container explicitly. Then re-verify inside the running container, not in the registry.&lt;/li>
&lt;li>List the places where the fix will land later (CI runners, stages behind &lt;code>--no-recreate&lt;/code>) as outstanding rather than done.&lt;/li>
&lt;/ol>
&lt;h2 id="bottom-line">Bottom line&lt;/h2>
&lt;p>A Docker tag is not a promise of updates. &lt;code>--pull&lt;/code> can leave PHP entirely unchanged, as &lt;code>php:8.2-fpm-bullseye&lt;/code> does at 8.2.29, or change far more than PHP, as &lt;code>php:8.2-fpm&lt;/code> does by moving to Debian 13. The only reliable inputs are &lt;code>PHP_VERSION&lt;/code> in the base image config before the build and the running container&amp;rsquo;s own &lt;code>php -v&lt;/code> after it.&lt;/p>
&lt;p>How you prove a build is fixed depends on where it came from: official image by version, distro package by changelog, self-built backport by behaviour, and supported branch by the new &lt;code>memory_hard_limit_percent&lt;/code> directive. For EOL 8.0 the official minimal backport on top of 8.0.30, built with the last docker-library Dockerfile for the branch, gives a fixed PHP without changing branch — and the application changes one line.&lt;/p>
&lt;p>Branch-by-branch companions: &lt;a href="https://bitpage.me/security/php2shell-mixed-fleet-audit/">the fleet-wide audit and the verification matrix&lt;/a>, &lt;a href="https://bitpage.me/security/php2shell-php7-eol-backports/">PHP 7.1 to 7.4&lt;/a>, and &lt;a href="https://bitpage.me/security/php2shell-php56-minimal-backport/">PHP 5.6&lt;/a>.&lt;/p>
&lt;p>Primary sources: &lt;a href="https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w">GHSA-w8hw-w9fx-j79w&lt;/a>, &lt;a href="https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp">GHSA-6wmf-4rv5-p3rp&lt;/a>, &lt;a href="https://www.php.net/ChangeLog-8.php">php.net ChangeLog-8&lt;/a>, &lt;a href="https://github.com/docker-library/php">docker-library/php&lt;/a>, &lt;a href="https://github.com/php/frankenphp/releases">FrankenPHP releases&lt;/a>. Tag data read from the Docker Hub and registry v2 APIs on 26 October 2026.&lt;/p></content:encoded></item><item><title>PHP2Shell on a mixed fleet: auditing 5.6 to 8.4 and proving each build is actually patched</title><link>https://bitpage.me/security/php2shell-mixed-fleet-audit/</link><pubDate>Mon, 26 Oct 2026 05:10:00 +0000</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.me/security/php2shell-mixed-fleet-audit/</guid><description>The fixed releases 8.2.35, 8.3.36, 8.4.27 and 8.5.12 add one new INI directive, memory_hard_limit_percent, and that is the only static proof a supported build carries the PHP2Shell fix. On 5.6 through 8.1 neither php -v nor php -i changes at all, so the proof is behavioural: an OOM exits 1 with shutdown functions skipped instead of 255 with them running.</description><content:encoded>&lt;p>&lt;strong>Short answer:&lt;/strong> for PHP 8.2 and newer, a patched build is provable statically. The fixed releases 8.2.35, 8.3.36, 8.4.27 and 8.5.12 introduce a new INI directive, &lt;code>memory_hard_limit_percent&lt;/code>, which exists in none of their predecessors. For every branch from 5.6 through 8.1 there is no fixed release and no version change, so the only proof is behavioural: on a patched build an out-of-memory condition terminates the worker with exit code 1 and skips &lt;code>register_shutdown_function&lt;/code>, where an unpatched build exits 255 and runs it.&lt;/p>
&lt;p>PHP2Shell is two linked CVEs, published 21 October 2026 and fixed in releases dated the 22nd. The part that made it expensive was not the patching. It was answering, machine by machine, a question that normally takes one command: is this PHP fixed or not.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Fix:&lt;/strong> stay on the branch, move to the last patch release of that branch, then apply the patch. Never patch the patch version you happen to be running — on 7.1 and 7.2 that leaves &lt;a href="https://github.com/advisories/GHSA-6qjm-m8fp-j2mm">CVE-2019-11043&lt;/a> open, and on 7.4.5 and 8.0.7 the official patch does not even apply.&lt;/li>
&lt;li>&lt;strong>Proof, supported branches:&lt;/strong> &lt;code>php -r 'var_dump(ini_get(&amp;quot;memory_hard_limit_percent&amp;quot;) !== false);'&lt;/code>. True only on 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 and later.&lt;/li>
&lt;li>&lt;strong>Proof, EOL branches:&lt;/strong> the OOM behaviour test. Exit 1 and no shutdown function means the backport is compiled in; exit 255 and a shutdown function means it is not.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> the behaviour test proves nothing on a &lt;em>supported&lt;/em> branch. The upstream fix deliberately leaves normal &lt;code>memory_limit&lt;/code> handling alone, so 8.2.29 and 8.2.35 behave identically under it. I measured both.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> &lt;code>max_multipart_body_parts&lt;/code> in &lt;code>php -i&lt;/code> is a valid marker on 7.1 through 7.4 and useless on 8.0 and 8.1, where the directive has existed since 8.0.28.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> the mitigations that look most obvious (&lt;code>RLIMIT_AS&lt;/code>, &lt;code>ulimit -v&lt;/code>, systemd &lt;code>LimitAS=&lt;/code>) trigger the vulnerable allocation failure themselves.&lt;/li>
&lt;/ul>
&lt;h2 id="what-php2shell-actually-is">What PHP2Shell actually is&lt;/h2>
&lt;p>Two bugs in request body parsing, both reachable before a single line of application code runs. The php-src advisories are the only authoritative description; several vendor write-ups have the two CVE numbers swapped, so take the GHSA pages as the reference.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w">GHSA-w8hw-w9fx-j79w (CVE-2026-109618)&lt;/a>: an out-of-memory condition during body parsing aborts the parse with a &lt;code>longjmp()&lt;/code>. Half-initialised structures are later read as valid. Triggering it is a memory-amplification problem, not a size problem: a small multipart body with a large number of parts occupies orders of magnitude more in PHP&amp;rsquo;s heap than it does on the wire.&lt;/li>
&lt;li>&lt;a href="https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp">GHSA-6wmf-4rv5-p3rp (CVE-2026-109619)&lt;/a>: stale global state. A producer request that dies abnormally (fatal error, timeout, OOM) leaves residue, and a consumer request in the same worker reads it.&lt;/li>
&lt;/ul>
&lt;p>The two advisories are identical in their metadata, which is a hint about how closely linked the bugs are. Both are CVSS 4.0 score 9.2, critical, on the same vector &lt;code>CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N&lt;/code>: high complexity and present attack requirements, but no privileges and no user interaction. Both list the same five weaknesses rather than one each: CWE-416, CWE-755, CWE-824, CWE-1325, CWE-1419. Both were published on 21 October 2026 within about twenty seconds of one another, 13:34 UTC. Both give the same four fixed versions. If you are reading a vendor summary that assigns distinct CWEs to the two CVEs, it is editorialising.&lt;/p>
&lt;p>Two consequences decide the whole response. The body is parsed before routing, so authentication, CSRF tokens, maintenance mode and any framework-level filtering sit downstream of the bug and protect nothing. And the confirmed consumer path on FPM is session upload progress, which is on by default and runs without the application ever calling &lt;code>session_start()&lt;/code>. I measured &lt;code>session.upload_progress.enabled&lt;/code> at &lt;code>1&lt;/code> on a stock &lt;code>php:8.2-cli-alpine&lt;/code>. An application that never touches sessions is still exposed.&lt;/p>
&lt;p>Fixed releases exist for four branches only: 8.5.12, 8.4.27, 8.3.36 and 8.2.35, all dated 22 October 2026 in &lt;a href="https://www.php.net/ChangeLog-8.php">ChangeLog-8&lt;/a>.&lt;/p>
&lt;p>The reason this could not wait for a maintenance window is the timeline. Amazon&amp;rsquo;s threat intelligence team reports a first exploitation attempt in its MadPot honeypot telemetry on 17 October, four days before disclosure and while the bugs were still under embargo, with more following from 02:00 UTC on 21 October; &lt;a href="https://sansec.io/research/php2shell">Sansec&lt;/a> records Cloudflare seeing attacks at 07:43 UTC on 21 October. The advisories went public at 13:34 UTC that day. Whichever number you take, the grace period between &amp;ldquo;this is public&amp;rdquo; and &amp;ldquo;this is being attacked&amp;rdquo; was negative.&lt;/p>
&lt;h2 id="why-a-mass-scan-script-gives-you-the-wrong-answer">Why a mass-scan script gives you the wrong answer&lt;/h2>
&lt;p>A script that SSHes everywhere and runs &lt;code>php -v&lt;/code> reports &amp;ldquo;no PHP&amp;rdquo; on machines where PHP is the only thing running. I had one written and threw it away.&lt;/p>
&lt;p>The failure is structural. On a host whose PHP lives in a container, &lt;code>php -v&lt;/code> on the host is not installed, so the script records a clean machine. On a Proxmox node the PHP is inside an LXC container that &lt;code>docker ps&lt;/code> knows nothing about. On a legacy OpenVZ host you need &lt;code>vzlist -a&lt;/code>. And &lt;code>docker exec&lt;/code> against a container that has stopped answering hangs with no timeout at all, which parks the scan and leaves a stuck process on the host for someone to find later.&lt;/p>
&lt;p>Take a registry of about two hundred entries (physical servers, VMs, LXC containers, legacy OpenVZ, cloud instances) where some are in an Ansible inventory and some exist only in a text file. Walking it by hand against a fixed checklist, one machine at a time, PHP turns up in roughly forty places across branches from 5.6 to 8.4, and the status &amp;ldquo;probably vulnerable&amp;rdquo; is never needed: wherever there is access, the exact version and SAPI are knowable.&lt;/p>
&lt;p>Before any of that, reconcile the inventory against the registry. On a fleet that size expect a dozen or so entries to disagree, and each disagreement is a machine that silently drops out of the audit: containers listed in one source and absent from the other, decommissioned nodes still described as live, the same container ID apparently running on two different nodes, a &amp;ldquo;mail server&amp;rdquo; that turns out to be empty. None of those are exotic. They are what happens when two people maintain two lists for three years.&lt;/p>
&lt;p>One checklist item that cost me real time: do not test SSH access with a plain &lt;code>ssh&lt;/code> command. My &lt;code>~/.ssh/config&lt;/code> was substituting a personal key for every host, so &amp;ldquo;the shared key works here&amp;rdquo; was true of a key I was not testing. On one machine that made me roll back a key that had already been added correctly, then add it again. The only honest form is:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>ssh -F /dev/null -o &lt;span style="color:#8be9fd;font-style:italic">IdentitiesOnly&lt;/span>&lt;span style="color:#ff79c6">=&lt;/span>yes -i ~/.ssh/&amp;lt;key&amp;gt; -p &amp;lt;port&amp;gt; root@&amp;lt;host&amp;gt; &lt;span style="color:#8be9fd;font-style:italic">true&lt;/span> &lt;span style="color:#ff79c6">&amp;amp;&amp;amp;&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">echo&lt;/span> OK
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="the-per-machine-checklist-read-only">The per-machine checklist, read-only&lt;/h2>
&lt;p>Seven items, in this order, nothing that writes:&lt;/p>
&lt;ol>
&lt;li>OS; PHP on the host; processes matching &lt;code>php&lt;/code>, &lt;code>php-fpm&lt;/code>, &lt;code>apache2|httpd&lt;/code>, &lt;code>nginx&lt;/code>, &lt;code>frankenphp&lt;/code>. Container processes are visible from the host, which is often the first hint that the host-level &lt;code>php -v&lt;/code> was lying.&lt;/li>
&lt;li>Where PHP lives: host, &lt;code>docker ps&lt;/code>, &lt;code>pct list&lt;/code> for Proxmox, &lt;code>vzlist -a&lt;/code> for OpenVZ.&lt;/li>
&lt;li>The version of every SAPI that is actually serving, not the one on &lt;code>$PATH&lt;/code>. &lt;code>php -v&lt;/code>, &lt;code>php-fpm -v&lt;/code>, and inside containers via &lt;code>docker exec&lt;/code>.&lt;/li>
&lt;li>For distribution packages, the CVE in the package changelog — a backport does not change the version number.&lt;/li>
&lt;li>For Docker, whether the running container&amp;rsquo;s image matches the image currently under that tag, and when that image was built.&lt;/li>
&lt;li>Whether it takes web requests, and what proxies it.&lt;/li>
&lt;li>Mitigations in force: &lt;code>session.upload_progress.enabled&lt;/code>, &lt;code>post_max_size&lt;/code>, accounting for &lt;code>conf.d&lt;/code> and pool-level &lt;code>php_admin_value&lt;/code>.&lt;/li>
&lt;/ol>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># What PHP is really serving, host plus containers, with a timeout for hung containers&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ps -eo user,comm | grep -E &lt;span style="color:#f1fa8c">&amp;#39;php|apache2|httpd|nginx|frankenphp&amp;#39;&lt;/span> | sort | uniq -c
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">for&lt;/span> c in &lt;span style="color:#ff79c6">$(&lt;/span>docker ps --format &lt;span style="color:#f1fa8c">&amp;#39;{{.Names}}&amp;#39;&lt;/span>&lt;span style="color:#ff79c6">)&lt;/span>; &lt;span style="color:#ff79c6">do&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#8be9fd;font-style:italic">v&lt;/span>&lt;span style="color:#ff79c6">=&lt;/span>&lt;span style="color:#ff79c6">$(&lt;/span>timeout &lt;span style="color:#bd93f9">20&lt;/span> docker &lt;span style="color:#8be9fd;font-style:italic">exec&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$c&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span> sh -c &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;for b in php php-fpm frankenphp; do command -v $b &amp;gt;/dev/null &amp;amp;&amp;amp; printf &amp;#34;%s: %s; &amp;#34; $b &amp;#34;$($b -v | head -1)&amp;#34;; done&amp;#39;&lt;/span> &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &amp;lt;/dev/null 2&amp;gt;/dev/null&lt;span style="color:#ff79c6">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#8be9fd;font-style:italic">echo&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$c&lt;/span>&lt;span style="color:#f1fa8c"> | &lt;/span>&lt;span style="color:#ff79c6">$(&lt;/span>docker inspect -f &lt;span style="color:#f1fa8c">&amp;#39;{{.Config.Image}}&amp;#39;&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$c&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>&lt;span style="color:#ff79c6">)&lt;/span>&lt;span style="color:#f1fa8c"> | &lt;/span>&lt;span style="color:#f1fa8c">${&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">v&lt;/span>&lt;span style="color:#ff79c6">:-&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">no&lt;/span> php&lt;span style="color:#f1fa8c">}&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">done&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The &lt;code>timeout 20&lt;/code> is not decoration. Without it one unresponsive container stops the audit. When &lt;code>docker exec&lt;/code> is hopeless you can still get the version from the image config without entering the container:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>docker image inspect &amp;lt;image:tag&amp;gt; --format &lt;span style="color:#f1fa8c">&amp;#39;{{range .Config.Env}}{{println .}}{{end}}&amp;#39;&lt;/span> | grep PHP_VERSION
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="which-branches-have-a-fix-at-all">Which branches have a fix at all&lt;/h2>
&lt;p>Every branch from 5.6 upward has a path to a fix. Having a fix and having a package are different things.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Branch&lt;/th>
&lt;th>Official&lt;/th>
&lt;th>What is actually installable&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>8.5, 8.4, 8.3, 8.2&lt;/td>
&lt;td>releases 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35&lt;/td>
&lt;td>official Docker images, sury packages&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8.1, 8.0, 7.4&lt;/td>
&lt;td>minimal source backports from the PHP Security Team: branches &lt;code>PHP-{7.4,8.0,8.1}-CVE-2026-109618-109619&lt;/code>, one commit on top of the last tag&lt;/td>
&lt;td>plus sury packages for Debian 11–13; 7.4 also via Freexian ELTS and Ubuntu Pro ESM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7.3, 7.2, 7.1, 5.6&lt;/td>
&lt;td>none&lt;/td>
&lt;td>&lt;a href="https://packages.sury.org/php/pool/main/p/">sury&lt;/a> patches by Ondřej Surý; Ubuntu Pro ESM for the distro &lt;code>php7.2&lt;/code> and &lt;code>php5&lt;/code>; TuxCare alt-php, paid&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The official backports are a single commit each, and the commit stats are worth knowing before you plan the work: &lt;code>712e60f9edfd&lt;/code> on the 7.4 branch touches 17 files for +240/−59, &lt;code>82f8f31b16a0&lt;/code> on 8.0 touches 17 files for +229/−59, and &lt;code>1d7dec178a09&lt;/code> on 8.1 touches 23 files for +311/−73. None of them touch the &lt;code>fpm&lt;/code> or &lt;code>apache2handler&lt;/code> SAPI directories.&lt;/p>
&lt;p>The sury patch for the oldest branch documents its own limits, which is why I trust it more than the community ports. The header of &lt;code>0261-Avoid-state-corruption-via-bailout.patch&lt;/code>, by Arnaud Le Blanc, co-author of the upstream fix, states plainly: &amp;ldquo;Minimal mitigation for EOL version.&amp;rdquo; It then lists what it deliberately leaves out, including that &amp;ldquo;the &lt;code>max_multipart_body_parts&lt;/code> hunk of the 7.4 patch is left out; the limit comes with the CVE-2023-0662 backport&amp;rdquo;. A patch that tells you what it is not doing is a patch you can plan around.&lt;/p>
&lt;p>A third-party &amp;ldquo;universal&amp;rdquo; port covering 5.4 to 7.3 was circulating. It modifies 8 files against the official 17, with &lt;code>zend_hash.c&lt;/code>, &lt;code>php_variables.c&lt;/code> and the VM untouched, and its own authors tested it on the built-in CLI server. I did not use it.&lt;/p>
&lt;h3 id="the-ordering-rule-that-matters">The ordering rule that matters&lt;/h3>
&lt;p>Patch the &lt;strong>last patch release of the branch&lt;/strong>, not the version you are running. Two independent reasons, both verified on vanilla tarballs:&lt;/p>
&lt;ul>
&lt;li>The patch frequently does not apply. The official 7.4 commit goes onto 7.4.33 and 7.4.29 cleanly and conflicts on 7.4.5 in &lt;code>zend_vm_*&lt;/code>. The 8.0 commit is clean on 8.0.30 and conflicts on 8.0.7.&lt;/li>
&lt;li>Where it does apply, it can leave an older hole open. The sury 7.1 patch applies cleanly to 7.1.8, 7.1.11 and 7.1.17 — all of which predate the CVE-2019-11043 fix that landed in 7.1.33. Closing PHP2Shell on 7.1.8 buys you an unauthenticated FPM RCE you already had.&lt;/li>
&lt;/ul>
&lt;h2 id="how-to-prove-a-build-is-patched">How to prove a build is patched&lt;/h2>
&lt;p>This is where I spent the most time, and where the published advice is thinnest. Three layers, and they fail differently.&lt;/p>
&lt;p>&lt;strong>The version number is not a proof, in either direction.&lt;/strong> A self-built &amp;ldquo;7.4.33 plus patch&amp;rdquo; reports exactly &lt;code>7.4.33&lt;/code>, the same as an unpatched one. And distributions do not even agree with each other about whether the version should move. Ubuntu backports into a frozen upstream version; Debian rebases to the fixed upstream version. From the two vendors&amp;rsquo; own trackers:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Vendor&lt;/th>
&lt;th>Suite&lt;/th>
&lt;th>Package&lt;/th>
&lt;th>Fixed version&lt;/th>
&lt;th>Upstream version moved?&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Ubuntu&lt;/td>
&lt;td>trusty (ESM)&lt;/td>
&lt;td>&lt;code>php5&lt;/code>&lt;/td>
&lt;td>&lt;code>5.5.9+dfsg-1ubuntu4.29+esm18&lt;/code>&lt;/td>
&lt;td>no&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ubuntu&lt;/td>
&lt;td>xenial (ESM)&lt;/td>
&lt;td>&lt;code>php7.0&lt;/code>&lt;/td>
&lt;td>&lt;code>7.0.33-0ubuntu0.16.04.16+esm22&lt;/code>&lt;/td>
&lt;td>no&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ubuntu&lt;/td>
&lt;td>bionic (ESM)&lt;/td>
&lt;td>&lt;code>php7.2&lt;/code>&lt;/td>
&lt;td>&lt;code>7.2.24-0ubuntu0.18.04.17+esm14&lt;/code>&lt;/td>
&lt;td>no&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ubuntu&lt;/td>
&lt;td>focal (ESM)&lt;/td>
&lt;td>&lt;code>php7.4&lt;/code>&lt;/td>
&lt;td>&lt;code>7.4.3-4ubuntu2.29+esm5&lt;/code>&lt;/td>
&lt;td>no&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ubuntu&lt;/td>
&lt;td>jammy&lt;/td>
&lt;td>&lt;code>php8.1&lt;/code>&lt;/td>
&lt;td>&lt;code>8.1.2-1ubuntu2.28&lt;/code>&lt;/td>
&lt;td>no&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ubuntu&lt;/td>
&lt;td>noble&lt;/td>
&lt;td>&lt;code>php8.3&lt;/code>&lt;/td>
&lt;td>&lt;code>8.3.6-0ubuntu0.24.04.13&lt;/code>&lt;/td>
&lt;td>no&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Debian&lt;/td>
&lt;td>bookworm&lt;/td>
&lt;td>&lt;code>php8.2&lt;/code>&lt;/td>
&lt;td>&lt;code>8.2.35-1~deb12u1&lt;/code>, from &lt;code>8.2.32-1~deb12u1&lt;/code>&lt;/td>
&lt;td>&lt;strong>yes&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Debian&lt;/td>
&lt;td>trixie&lt;/td>
&lt;td>&lt;code>php8.4&lt;/code>&lt;/td>
&lt;td>&lt;code>8.4.27-1~deb13u1&lt;/code>, from &lt;code>8.4.24-1~deb13u1&lt;/code>&lt;/td>
&lt;td>&lt;strong>yes&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two rows there are worth a second look. Ubuntu&amp;rsquo;s &lt;code>php8.1&lt;/code> on jammy is fixed at &lt;code>8.1.2&lt;/code>, a branch upstream declared end of life with no fixed release. The distro is maintaining it anyway. Ubuntu&amp;rsquo;s &lt;code>php8.3&lt;/code> on noble stays at &lt;code>8.3.6&lt;/code> while upstream&amp;rsquo;s fix is &lt;code>8.3.36&lt;/code>; a scanner comparing against &amp;ldquo;8.3.36 or newer&amp;rdquo; marks a fully patched machine as vulnerable. Meanwhile on Debian bookworm the version genuinely did move to &lt;code>8.2.35&lt;/code>, so there &lt;code>php -v&lt;/code> works fine.&lt;/p>
&lt;p>The practical consequence is that a version check needs to know which vendor built the PHP before it can interpret the number, which is a strange thing to need and a good argument for checking something else instead. Both tables above come from machine-readable sources you can query yourself: &lt;code>https://ubuntu.com/security/cves/CVE-2026-109618.json&lt;/code> and the Debian security tracker&amp;rsquo;s JSON. A scanner working from &lt;code>php -v&lt;/code> alone will call a patched image vulnerable and a backported package safe, and be wrong both times.&lt;/p>
&lt;p>&lt;strong>The INI output is a proof on supported branches only, and this is new.&lt;/strong> The fixed releases add a directive that no earlier release has. I checked &lt;code>main/main.c&lt;/code> across tags:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tag&lt;/th>
&lt;th>&lt;code>memory_hard_limit_percent&lt;/code> occurrences&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>php-8.2.29&lt;/td>
&lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>php-8.2.34&lt;/td>
&lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>php-8.2.35&lt;/strong>&lt;/td>
&lt;td>&lt;strong>4&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>php-8.3.36&lt;/td>
&lt;td>4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>php-8.4.27&lt;/td>
&lt;td>4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>php-8.5.12&lt;/td>
&lt;td>4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>php-8.1.34&lt;/td>
&lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>PHP-7.4-CVE-...&lt;/code> backport branch&lt;/td>
&lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>PHP-8.0-CVE-...&lt;/code> backport branch&lt;/td>
&lt;td>0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>PHP-8.1-CVE-...&lt;/code> backport branch&lt;/td>
&lt;td>0&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The directive appears exactly in the four fixed releases and in none of their predecessors. It is declared as &lt;code>PHP_INI_ENTRY(&amp;quot;memory_hard_limit_percent&amp;quot;, NULL, PHP_INI_ALL, OnChangeMemoryHardLimitPercent)&lt;/code> and rejects nonsense with &amp;ldquo;memory_hard_limit_percent must be &amp;gt;= 100, or -1&amp;rdquo;. That gives one check valid on any supported branch, including a distro package whose version string refuses to move:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>php -r &lt;span style="color:#f1fa8c">&amp;#39;var_dump(ini_get(&amp;#34;memory_hard_limit_percent&amp;#34;) !== false);&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># true -&amp;gt; this build is 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 or newer&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># false -&amp;gt; unpatched, or an EOL backport, or a partial distro backport&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Two things about that check, both of which I got wrong before I got them right.&lt;/p>
&lt;p>The first is the &lt;code>!== false&lt;/code>, which is not defensive style but the whole mechanism. The directive&amp;rsquo;s default value is &lt;code>NULL&lt;/code>, so on a patched build &lt;code>ini_get()&lt;/code> returns an empty string, and an empty string is falsy in PHP. Written the obvious way, the check reports every build in the fleet as unpatched:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Image&lt;/th>
&lt;th>&lt;code>ini_get(...)&lt;/code>&lt;/th>
&lt;th>&lt;code>!== false&lt;/code>&lt;/th>
&lt;th>&lt;code>if (ini_get(...))&lt;/code>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>php:8.2-cli-alpine3.21&lt;/code> (8.2.29)&lt;/td>
&lt;td>&lt;code>false&lt;/code>&lt;/td>
&lt;td>&lt;code>false&lt;/code>&lt;/td>
&lt;td>&lt;code>false&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>php:8.2-cli-alpine&lt;/code> (8.2.35)&lt;/td>
&lt;td>&lt;code>''&lt;/code>&lt;/td>
&lt;td>&lt;strong>&lt;code>true&lt;/code>&lt;/strong>&lt;/td>
&lt;td>&lt;code>false&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>php:8.1-cli-alpine&lt;/code> (8.1.34, EOL)&lt;/td>
&lt;td>&lt;code>false&lt;/code>&lt;/td>
&lt;td>&lt;code>false&lt;/code>&lt;/td>
&lt;td>&lt;code>false&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Measured on those three images. The middle row is the one that matters: a patched build and an unpatched build are indistinguishable under a truthiness test, and the failure is silent and uniform. A scanner written that way produces a clean-looking report in which nothing is patched, which is the kind of output people believe because it matches their fears. &lt;code>array_key_exists('memory_hard_limit_percent', ini_get_all())&lt;/code> works too and is harder to get wrong.&lt;/p>
&lt;p>The second is the false case. The directive is absent from all three official EOL backport branches and from all six sury EOL patch sets I unpacked, so a &lt;code>false&lt;/code> on 7.x or 8.0 tells you nothing at all. It is a positive marker, not a negative one.&lt;/p>
&lt;p>&lt;strong>The behaviour test is the proof on EOL branches, and only there.&lt;/strong> The EOL backports replace the bailout with a hard exit. In the 8.0 patch the diff reads, verbatim:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">-&lt;/span> heap&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>overflow &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#bd93f9">1&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">-&lt;/span> zend_try {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">-&lt;/span> &lt;span style="color:#50fa7b">zend_error_noreturn&lt;/span>(E_ERROR, format, limit, size);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">-&lt;/span> } zend_catch {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">-&lt;/span> } &lt;span style="color:#50fa7b">zend_end_try&lt;/span>();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">-&lt;/span> heap&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>overflow &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#bd93f9">0&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">-&lt;/span> &lt;span style="color:#50fa7b">zend_bailout&lt;/span>();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">-&lt;/span> &lt;span style="color:#50fa7b">exit&lt;/span>(&lt;span style="color:#bd93f9">1&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">+&lt;/span> (&lt;span style="color:#8be9fd">void&lt;/span>) heap;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">+&lt;/span> &lt;span style="color:#50fa7b">zend_mm_safe_error_ex&lt;/span>(format, limit, size);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>and the new function ends in &lt;code>_exit(1)&lt;/code> after writing the fatal error to stderr directly. That is observable from userland. The whole test is this:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-php" data-lang="php">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">&amp;lt;?&lt;/span>php
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>register_shutdown_function(&lt;span style="color:#ff79c6">function&lt;/span> () { fwrite(STDERR, &lt;span style="color:#f1fa8c">&amp;#34;SHUTDOWN-RAN&lt;/span>&lt;span style="color:#f1fa8c">\n&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>); });
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ini_set(&lt;span style="color:#f1fa8c">&amp;#39;memory_limit&amp;#39;&lt;/span>, &lt;span style="color:#f1fa8c">&amp;#39;64M&amp;#39;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd;font-style:italic">$a&lt;/span> &lt;span style="color:#ff79c6">=&lt;/span> [];
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">while&lt;/span> (&lt;span style="color:#ff79c6">true&lt;/span>) { &lt;span style="color:#8be9fd;font-style:italic">$a&lt;/span>[] &lt;span style="color:#ff79c6">=&lt;/span> str_repeat(&lt;span style="color:#f1fa8c">&amp;#39;x&amp;#39;&lt;/span>, &lt;span style="color:#bd93f9">100000&lt;/span>); }
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>docker run --rm --memory 512m -v &lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$PWD&lt;/span>&lt;span style="color:#f1fa8c">/oom.php:/oom.php:ro&amp;#34;&lt;/span> &amp;lt;image&amp;gt; php /oom.php
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd;font-style:italic">echo&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;exit=&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$?&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Three builds, same script, &lt;code>memory_limit=64M&lt;/code> in a 512 MB container:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Build&lt;/th>
&lt;th>Exit code&lt;/th>
&lt;th>Fatal error written to&lt;/th>
&lt;th>Shutdown function&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>php:8.2-cli-alpine3.21&lt;/code>, PHP 8.2.29 (unpatched)&lt;/td>
&lt;td>255&lt;/td>
&lt;td>stdout&lt;/td>
&lt;td>ran&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>php:8.2-cli-alpine&lt;/code>, PHP 8.2.35 (fixed)&lt;/td>
&lt;td>255&lt;/td>
&lt;td>stdout&lt;/td>
&lt;td>ran&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>sury &lt;code>php8.0-cli&lt;/code> &lt;code>1:8.0.30-25+0~20261021.88+debian12~1.gbpabebea&lt;/code>, EOL backport&lt;/td>
&lt;td>&lt;strong>1&lt;/strong>&lt;/td>
&lt;td>&lt;strong>stderr&lt;/strong>&lt;/td>
&lt;td>&lt;strong>did not run&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The destructor ran in none of the three.&lt;/p>
&lt;p>The middle row is the one that surprised me, and it corrects what I had assumed from reading the patches. The supported-branch fix leaves ordinary &lt;code>memory_limit&lt;/code> handling exactly as it was (soft OOM is deferred to a safe point rather than converted into a process exit), so 8.2.29 and 8.2.35 are indistinguishable under this test. Only the EOL backports turn a &lt;code>memory_limit&lt;/code> breach into &lt;code>_exit(1)&lt;/code>. Anyone using the OOM test as a general &amp;ldquo;is it patched&amp;rdquo; check on 8.2 will get a false negative every time.&lt;/p>
&lt;p>The supported branches do have a hard path, through the new directive: &lt;code>zend_mm_hard_limit_error&lt;/code> prints &amp;ldquo;Hard memory limit of %zu bytes exhausted (tried to allocate %zu bytes) in %s on line %d&amp;rdquo; and calls &lt;code>_exit(137)&lt;/code>. I could not reach it in testing, because the soft limit fires first even with &lt;code>-d memory_hard_limit_percent=100&lt;/code>, so I am describing that one from the source rather than from a measurement.&lt;/p>
&lt;h3 id="the-marker-table-per-branch">The marker table, per branch&lt;/h3>
&lt;p>Putting it together, because the answer differs by branch and getting it wrong wastes a day:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Branch&lt;/th>
&lt;th>Static marker in &lt;code>php -i&lt;/code>&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>8.2–8.5&lt;/td>
&lt;td>&lt;code>memory_hard_limit_percent&lt;/code>&lt;/td>
&lt;td>new in the fixed releases&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8.1, 8.0&lt;/td>
&lt;td>none&lt;/td>
&lt;td>&lt;code>max_multipart_body_parts&lt;/code> already exists since 8.0.28&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7.4, 7.3, 7.2, 7.1&lt;/td>
&lt;td>&lt;code>max_multipart_body_parts&lt;/code>&lt;/td>
&lt;td>absent from every vanilla 7.x, added by the fix&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5.6&lt;/td>
&lt;td>&lt;code>max_multipart_body_parts&lt;/code>, but only with the second sury patch&lt;/td>
&lt;td>the bailout patch alone adds no directive&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>I verified the second column by counting the directive in &lt;code>main/main.c&lt;/code> across vanilla tags: zero in 5.6.40, 7.1.33, 7.2.34, 7.3.33 and 7.4.33, one in 8.0.28, 8.0.30 and 8.1.34.&lt;/p>
&lt;h3 id="the-build-assert-that-asserts-nothing">The build assert that asserts nothing&lt;/h3>
&lt;p>If you compile your own, put a marker check in the build so it fails when the patch silently stops applying. My first choice was wrong: I used &lt;code>grep -q zend_objects_store_mark_destructed&lt;/code>, which passes on unpatched source as well, so the assert was decorative. The rule is to check the candidate symbol against a vanilla tree first and require zero.&lt;/p>
&lt;p>&lt;code>array_init_singleton&lt;/code> in &lt;code>Zend/zend_API.h&lt;/code> is the one I would use now. It occurs zero times in vanilla 5.6.40, 7.4.33 and 8.0.30, and is added by every one of the six EOL patch sets — one marker covering 5.6 through 8.0 rather than a different grep per branch.&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>grep -c array_init_singleton Zend/zend_API.h &lt;span style="color:#6272a4"># 0 before the patch, &amp;gt;=1 after&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>One gotcha if you verify a &lt;em>package&lt;/em> rather than a source tree inside a container: &lt;code>debian:*-slim&lt;/code> images ship &lt;code>/etc/dpkg/dpkg.cfg.d/docker&lt;/code> with &lt;code>path-exclude /usr/share/doc/*&lt;/code> and &lt;code>path-include /usr/share/doc/*/copyright&lt;/code>. The package changelog you were told to grep for the CVE is not in the image at all; only &lt;code>copyright&lt;/code> survives. I spent a while convinced a sury package had shipped without a changelog entry.&lt;/p>
&lt;h2 id="mitigations-that-do-not-work-and-one-that-makes-it-worse">Mitigations that do not work, and one that makes it worse&lt;/h2>
&lt;p>Most of the obvious knobs are downstream of the bug.&lt;/p>
&lt;ul>
&lt;li>&lt;code>file_uploads = Off&lt;/code> does not help. The multipart body is still parsed.&lt;/li>
&lt;li>&lt;code>ini_set()&lt;/code> and &lt;code>.user.ini&lt;/code> are applied after the body is parsed. Too late by construction. Only &lt;code>php.ini&lt;/code>, pool-level &lt;code>php_admin_value&lt;/code>, or the Apache config, followed by a full worker restart — for Apache, not a &lt;code>graceful&lt;/code> one.&lt;/li>
&lt;li>Blocking &lt;code>multipart/*&lt;/code> at the proxy does not close the urlencoded variant, so you need a body-size limit as well. Do not scope the block to POST either: since 8.4, &lt;code>request_parse_body()&lt;/code> parses other methods too.&lt;/li>
&lt;li>&lt;code>RLIMIT_AS&lt;/code>, &lt;code>RLIMIT_DATA&lt;/code>, &lt;code>ulimit -v&lt;/code> and systemd &lt;code>LimitAS=&lt;/code> are actively harmful. They cause the same allocation failure the bug lives in. I would expect OpenVZ beancounters to behave the same way, but that is a guess I have not tested.&lt;/li>
&lt;li>&lt;code>memory_limit = -1&lt;/code>, which the advisory offers as a mitigation for 109618, needs care. On a pool with a few hundred workers and no container memory limit it trades a hard-to-exploit RCE for a reliable OOM of the whole VM, and in a container sharing space with a database it hands the OOM killer the database.&lt;/li>
&lt;/ul>
&lt;p>The one mitigation worth applying everywhere is &lt;code>session.upload_progress.enabled = 0&lt;/code> in &lt;code>php.ini&lt;/code> or the pool config. It closes the confirmed FPM path for 109619 and breaks almost nothing — upload progress bars, if the application has them.&lt;/p>
&lt;h2 id="what-the-patch-changes-in-application-behaviour">What the patch changes in application behaviour&lt;/h2>
&lt;p>The EOL backports change three things you can notice from the application side, and all three are worth knowing before deploying rather than after.&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Shutdown functions stop running on OOM.&lt;/strong> The worker goes through &lt;code>_exit(1)&lt;/code>, so &lt;code>register_shutdown_function&lt;/code> never fires, the message goes to stderr instead of the output stream, and FPM behind nginx returns 502 where it used to return 500. An application that routinely brushes against &lt;code>memory_limit&lt;/code> will start producing 502s, and whatever you do in a shutdown handler (flushing an error reporter, releasing a queue job, writing a last log line) stops happening. If your monitoring matches on exit code 255, it will not match on 1.&lt;/li>
&lt;li>&lt;strong>Session upload progress is disabled in code, not in configuration.&lt;/strong> The backports patch the guard to &lt;code>if (!PS(rfc1867_enabled) || true)&lt;/code>, so the feature is off while &lt;code>php -i&lt;/code> still reports &lt;code>session.upload_progress.enabled =&amp;gt; On&lt;/code>. The supported branches took the other route: they keep the feature and add a guard, &lt;code>if (PG(modules_activated)) { /* request_parse_body(). Do not run here as it corrupts the session. */ return retval; }&lt;/code>. That guard is new in the fixed release: &lt;code>PG(modules_activated)&lt;/code> occurs twice in &lt;code>ext/session/session.c&lt;/code> on 8.2.29 and 8.2.34, and three times on 8.2.35.&lt;/li>
&lt;li>&lt;strong>A new limit truncates very large forms.&lt;/strong> &lt;code>max_multipart_body_parts&lt;/code> defaults to &lt;code>-1&lt;/code>, which falls back to &lt;code>max_input_vars + max_file_uploads&lt;/code>. On stock defaults that is 1000 + 20, so 1020 parts, after which you get: &amp;ldquo;Multipart body parts limit exceeded %d. To increase the limit change max_multipart_body_parts in php.ini.&amp;rdquo; I measured the stock values on &lt;code>php:8.2-cli-alpine&lt;/code>: &lt;code>max_input_vars&lt;/code> 1000, &lt;code>max_file_uploads&lt;/code> 20, &lt;code>max_multipart_body_parts&lt;/code> &lt;code>-1&lt;/code>. The fallback formula is identical in &lt;code>main/rfc1867.c&lt;/code> on vanilla 8.2 and in the 5.6 backport.&lt;/li>
&lt;/ol>
&lt;h2 id="looking-for-traces">Looking for traces&lt;/h2>
&lt;p>Because exploitation preceded disclosure, retrospective hunting is part of the job. The signatures come from the mechanism, not from a particular payload:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sh" data-lang="sh">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># OOM before the script even starts: the parse never reached user code&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>grep -h &lt;span style="color:#f1fa8c">&amp;#39;Allowed memory size.*Unknown on line 0&amp;#39;&lt;/span> /var/log/php*/*.log
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># worker crashes&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>grep -h &lt;span style="color:#f1fa8c">&amp;#39;AH00052&amp;#39;&lt;/span> /var/log/apache2/error.log &lt;span style="color:#6272a4"># mod_php segfault&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>grep -h &lt;span style="color:#f1fa8c">&amp;#39;exited on signal 11&amp;#39;&lt;/span> /var/log/php*-fpm.log &lt;span style="color:#6272a4"># FPM&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Beyond the logs: runs of large POSTs from one address in a short window, &lt;code>PHP_SESSION_UPLOAD_PROGRESS&lt;/code> appearing in request bodies, an unusually long &lt;code>PHPSESSID&lt;/code> in a &lt;code>Set-Cookie&lt;/code> response, and shell processes such as &lt;code>sh -c&lt;/code>, &lt;code>curl&lt;/code> or &lt;code>wget&lt;/code> parented to a PHP worker. Adding &lt;code>$content_type&lt;/code> and &lt;code>$request_length&lt;/code> to the nginx access log format makes the first of those searchable, and costs nothing to leave on.&lt;/p>
&lt;h2 id="step-by-step">Step by step&lt;/h2>
&lt;ol>
&lt;li>Reconcile the registry against the inventory before touching any machine. Every disagreement is a machine that would not have been checked.&lt;/li>
&lt;li>Walk the fleet by hand against the seven-item checklist. Record the exact version and SAPI, never &amp;ldquo;probably&amp;rdquo;.&lt;/li>
&lt;li>For each branch found, decide the source of the fix from the availability table — official release, official EOL backport, or sury patch set.&lt;/li>
&lt;li>For every self-built target, apply the patch to the &lt;strong>last patch release of the branch&lt;/strong>, and check for older CVEs in whatever you are moving off.&lt;/li>
&lt;li>Verify in three layers: patch applies, patch compiles, patch works. The third is the marker table plus the behaviour test for EOL.&lt;/li>
&lt;li>Tag self-built images and packages so the build is identifiable later — an image tag and a &lt;code>LABEL&lt;/code>, or an RPM &lt;code>Release&lt;/code> bump.&lt;/li>
&lt;li>Apply &lt;code>session.upload_progress.enabled = 0&lt;/code> everywhere regardless, in &lt;code>php.ini&lt;/code> or the pool config.&lt;/li>
&lt;li>Run the retrospective log hunt back to 17 October before you close the incident.&lt;/li>
&lt;/ol>
&lt;h2 id="bottom-line">Bottom line&lt;/h2>
&lt;p>&amp;ldquo;EOL means there is no fix&amp;rdquo; is false — every branch from 5.6 up has a usable patch, from the PHP Security Team for 7.4 through 8.1 and from the Debian maintainer for everything older. &amp;ldquo;There is a fix&amp;rdquo; is not the same as &amp;ldquo;there is a package&amp;rdquo;, and for the old branches it means a build you now maintain. Patch the last patch release of the branch, never the version you are on.&lt;/p>
&lt;p>The part worth taking away is the verification. &lt;code>php -v&lt;/code> cannot answer the question on any branch. On 8.2 and newer, &lt;code>ini_get('memory_hard_limit_percent') !== false&lt;/code> can, and it works through a distro backport that never changes the version string. On 5.6 through 8.1 nothing static answers it and you need the OOM behaviour test — which, inverted, means that same test proves nothing on 8.2, where both the vulnerable and the fixed build exit 255 and run their shutdown functions. I measured that one the hard way.&lt;/p>
&lt;p>The branch-by-branch work is written up separately: &lt;a href="https://bitpage.me/security/php2shell-php8-frozen-docker-tags/">PHP 8 and the frozen Docker tags&lt;/a>, &lt;a href="https://bitpage.me/security/php2shell-php7-eol-backports/">PHP 7.1 to 7.4 with the official and sury backports&lt;/a>, and &lt;a href="https://bitpage.me/security/php2shell-php56-minimal-backport/">PHP 5.6, where the only proof is behavioural&lt;/a>.&lt;/p>
&lt;p>Primary sources: &lt;a href="https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w">GHSA-w8hw-w9fx-j79w&lt;/a>, &lt;a href="https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp">GHSA-6wmf-4rv5-p3rp&lt;/a>, &lt;a href="https://www.php.net/ChangeLog-8.php">php.net ChangeLog-8&lt;/a>, &lt;a href="https://github.com/php/php-src">php-src&lt;/a> for the backport branches, &lt;a href="https://packages.sury.org/php/pool/main/p/">packages.sury.org&lt;/a> for the EOL patch sets, and &lt;a href="https://github.com/docker-library/php">docker-library/php&lt;/a> for the historical Dockerfiles.&lt;/p></content:encoded></item><item><title>Room 3 froze on a suspending @Transaction: the SQLite driver you pass picks your connection pool</title><link>https://bitpage.me/mobile/room3-suspending-transaction-hang-sqlite-driver/</link><pubDate>Sat, 10 Oct 2026 12:35:00 +0000</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.me/mobile/room3-suspending-transaction-hang-sqlite-driver/</guid><description>Room 3 reads hasConnectionPool off the driver you hand setDriver(). AndroidSQLiteDriver returns true, so Room installs a passthrough pool holding exactly one thread-confined connection, and a suspending @Transaction can block there with no crash, no ANR and nothing in logcat. BundledSQLiteDriver returns false, so Room runs its own pool of four readers and one writer, and resets PRAGMA synchronous on every connection it opens.</description><content:encoded>&lt;p>&lt;strong>Short answer:&lt;/strong> Room 3 does not manage connections the same way for every driver. It reads the &lt;code>hasConnectionPool&lt;/code> property off whatever &lt;code>SQLiteDriver&lt;/code> you pass to &lt;code>setDriver()&lt;/code>, and that single boolean picks the concurrency model. &lt;code>AndroidSQLiteDriver&lt;/code> returns &lt;code>true&lt;/code>, so Room wraps it in &lt;code>PassthroughConnectionPool&lt;/code>, which holds one thread-confined connection and has no acquisition timeout at all. A suspending &lt;code>@Transaction&lt;/code> DAO function on that pool can block forever: the main thread stays idle, so there is no ANR, no crash, and no log line. Swapping to &lt;code>BundledSQLiteDriver&lt;/code> makes Room run its own pool instead, and Room configures &lt;code>PRAGMA synchronous&lt;/code> on every connection it opens, which quietly discards anything you set in &lt;code>RoomDatabase.Callback.onOpen&lt;/code>.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Fix:&lt;/strong> pass a driver that reports &lt;code>hasConnectionPool = false&lt;/code> (&lt;code>BundledSQLiteDriver&lt;/code> does, by inheriting the default) so Room manages its own pool. On WAL that is four readers and one writer, and it has a documented acquisition timeout (30 seconds by default) that throws &lt;code>SQLiteException&lt;/code> instead of waiting.&lt;/li>
&lt;li>&lt;strong>Diagnosis:&lt;/strong> &lt;code>kill -3&lt;/code> on the process, then read &lt;code>/data/anr/trace_00&lt;/code>. SIGQUIT dumps every thread without needing a crash, an ANR dialog, or a debugger attach.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> the driver swap moves you from a single connection to several, and Room runs its per-connection PRAGMA setup on each of them. &lt;code>RoomDatabase.Callback.onOpen&lt;/code> fires once per database, not once per connection, so a pragma you set there survives on exactly one connection.&lt;/li>
&lt;li>&lt;strong>Second trap:&lt;/strong> if you write a driver wrapper to fix that, delegate &lt;code>inTransaction()&lt;/code>. The default implementation in &lt;code>androidx.sqlite&lt;/code> throws &lt;code>NotImplementedError&lt;/code>, and Room calls it before every write transaction.&lt;/li>
&lt;/ul>
&lt;h2 id="why-does-an-android-app-hang-on-the-splash-screen-with-no-crash-and-no-anr">Why does an Android app hang on the splash screen with no crash and no ANR?&lt;/h2>
&lt;p>Because the main thread is not stuck. Android raises an ANR when the main thread fails to service input or draw, and a main thread parked in &lt;code>epoll_pwait&lt;/code> waiting for the next message is, as far as the system is concerned, perfectly healthy. If your splash gate is a boolean that some background work is supposed to flip, and that background work is blocked, the main thread will happily keep drawing the splash forever.&lt;/p>
&lt;p>This is what I was looking at one morning in early autumn: reinstall the app with &lt;code>adb install -r&lt;/code>, cold start, splash, nothing. Three cold starts, three hangs, every time. The process was alive, &lt;code>dumpsys activity activities&lt;/code> showed it as &lt;code>topResumedActivity&lt;/code>, and the splash condition came from the standard pattern:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-kotlin" data-lang="kotlin">&lt;span style="display:flex;">&lt;span>splashScreen.setKeepOnScreenCondition { !viewModel.isReady }
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>I spent the first stretch of that session chasing the wrong things, and all three dead ends were my own.&lt;/p>
&lt;p>&lt;strong>Dead end one: looking for a crash that was not there.&lt;/strong> My first instinct was that something had thrown on a background thread and been swallowed.&lt;/p>
&lt;pre tabindex="0">&lt;code>$ adb logcat -d --pid=$(adb shell pidof com.example.tracker) | grep -c &amp;#34;FATAL\|AndroidRuntime&amp;#34;
0
&lt;/code>&lt;/pre>&lt;p>Zero. There was no exception. A deadlock produces no log output at all, which is exactly what makes it expensive: every tool that reports failures had nothing to report.&lt;/p>
&lt;p>&lt;strong>Dead end two: blaming the network.&lt;/strong> The app opens a server-sent-events stream on startup, so I assumed the stream had stalled and the readiness flag was waiting on it. The OkHttp log said otherwise: the requests went out and came back with &lt;code>200 OK&lt;/code>, including the long-lived stream. The network layer had done its job and gone quiet.&lt;/p>
&lt;p>&lt;strong>Dead end three: blaming the splash API.&lt;/strong> I read &lt;code>setKeepOnScreenCondition&lt;/code> three times looking for a lifecycle mistake. There was none. The condition was correct; the value it read never changed.&lt;/p>
&lt;p>The lesson I keep relearning: when there is no error, stop reading your own code and ask the runtime what the threads are doing.&lt;/p>
&lt;h2 id="how-do-you-get-a-stack-trace-out-of-a-process-that-has-not-crashed">How do you get a stack trace out of a process that has not crashed?&lt;/h2>
&lt;p>Send it SIGQUIT. The Android runtime installs a signal handler that dumps every thread&amp;rsquo;s stack to a trace file, which is the same mechanism that produces an ANR trace, except you get to trigger it on demand:&lt;/p>
&lt;pre tabindex="0">&lt;code>$ adb root
$ adb shell kill -3 $(adb shell pidof com.example.tracker)
$ adb shell cat /data/anr/trace_00
&lt;/code>&lt;/pre>&lt;p>That dump changed the problem from &amp;ldquo;the app hangs&amp;rdquo; to &amp;ldquo;I know which lock&amp;rdquo;. The main thread was where I expected it:&lt;/p>
&lt;pre tabindex="0">&lt;code>&amp;#34;main&amp;#34; prio=5 tid=1 Native
native: #00 pc ... /apex/com.android.runtime/lib64/bionic/libc.so (__epoll_pwait+8)
at android.os.MessageQueue.nativePollOnce(Native method)
at android.os.Looper.loopOnce(Looper.java:...)
&lt;/code>&lt;/pre>&lt;p>Idle. The interesting thread was a coroutine worker. Trimmed, and with my own class and package names replaced by generic ones:&lt;/p>
&lt;pre tabindex="0">&lt;code>&amp;#34;DefaultDispatcher-worker-2&amp;#34; prio=5 tid=24 Waiting
at java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:...)
at androidx.room3.TriggerBasedInvalidationTracker$syncTriggers$2$1.invokeSuspend(InvalidationTracker.kt:634)
at androidx.room3.coroutines.PassthroughConnectionPool$useConnection$3.invokeSuspend(PassthroughConnectionPool.kt:90)
...
at androidx.room3.util.DBUtil__DBUtilKt.performInTransactionSuspending(DBUtil.kt:102)
at com.example.tracker.data.OutboxDao_Impl.takeNextForUpload(OutboxDao_Impl.kt:192)
&lt;/code>&lt;/pre>&lt;p>Three names in that stack told the whole story: &lt;code>PassthroughConnectionPool&lt;/code>, &lt;code>TriggerBasedInvalidationTracker&lt;/code>, and &lt;code>performInTransactionSuspending&lt;/code>. A DAO function of mine, marked &lt;code>@Transaction&lt;/code> and &lt;code>suspend&lt;/code>, had entered Room&amp;rsquo;s transaction machinery and was waiting on a lock held by something that was waiting on it.&lt;/p>
&lt;p>The DAO function was unremarkable. It claims the next queued item by moving it to an in-flight state inside a transaction, so that two concurrent workers cannot pick up the same row:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-kotlin" data-lang="kotlin">&lt;span style="display:flex;">&lt;span>@Transaction
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">open&lt;/span> &lt;span style="color:#ff79c6">suspend&lt;/span> &lt;span style="color:#ff79c6">fun&lt;/span> &lt;span style="color:#50fa7b">takeNextForUpload&lt;/span>(userId: String, nowMillis: Long): OutboxEntity? {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">val&lt;/span> next = oldestUploadable(userId) &lt;span style="color:#ff79c6">?:&lt;/span> &lt;span style="color:#ff79c6">return&lt;/span> &lt;span style="color:#ff79c6">null&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> updateState(userId, next.clientId, &lt;span style="color:#f1fa8c">&amp;#34;UPLOADING&amp;#34;&lt;/span>, nowMillis)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">return&lt;/span> next.copy(state = &lt;span style="color:#f1fa8c">&amp;#34;UPLOADING&amp;#34;&lt;/span>, updatedAtEpochMillis = nowMillis)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>A &lt;code>@Transaction&lt;/code> function with a body, rather than a single query, is what the Room team calls a transaction &lt;em>delegate&lt;/em>. Room generates a wrapper that opens a transaction and invokes your code inside it; the generated entry point is &lt;code>performInTransactionSuspending&lt;/code>, whose own documentation says it &amp;ldquo;is needed to support &lt;code>@Transaction&lt;/code> delegate DAO functions&amp;rdquo;. That distinction matters here, and I will come back to it.&lt;/p>
&lt;h2 id="why-does-the-sqlite-driver-decide-whether-room-can-deadlock">Why does the SQLite driver decide whether Room can deadlock?&lt;/h2>
&lt;p>Because Room asks the driver whether it already has a pool, and takes the answer at face value. &lt;code>SQLiteDriver&lt;/code> carries a &lt;code>hasConnectionPool&lt;/code> property whose default is &lt;code>false&lt;/code>, and the Android framework driver overrides it. From &lt;a href="https://github.com/androidx/androidx/blob/androidx-main/sqlite/sqlite-framework/src/androidMain/kotlin/androidx/sqlite/driver/AndroidSQLiteDriver.android.kt">&lt;code>AndroidSQLiteDriver.android.kt&lt;/code>&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-kotlin" data-lang="kotlin">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">/**
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * A [SQLiteDriver] implemented by [android.database] and that uses the Android&amp;#39;s SDK SQLite APIs.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> *
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * This driver internally has a connection pool and a opened connection from it is safe to be used
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * in a multi-thread and concurrent environment.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">public&lt;/span> &lt;span style="color:#ff79c6">class&lt;/span> &lt;span style="color:#50fa7b">AndroidSQLiteDriver&lt;/span> : SQLiteDriver {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> @get:JvmName(&lt;span style="color:#f1fa8c">&amp;#34;hasConnectionPool&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">override&lt;/span> &lt;span style="color:#ff79c6">val&lt;/span> hasConnectionPool: Boolean
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">get&lt;/span>() = &lt;span style="color:#ff79c6">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>BundledSQLiteDriver&lt;/code> does not override the property at all, so it inherits &lt;code>false&lt;/code>. The consequence runs opposite to the intuition: the driver that &lt;em>advertises&lt;/em> a connection pool is the one that leaves Room without a pool of its own. &lt;a href="https://github.com/androidx/androidx/blob/androidx-main/room3/room3-runtime/src/androidMain/kotlin/androidx/room3/RoomConnectionManager.android.kt">&lt;code>RoomConnectionManager.android.kt&lt;/code>&lt;/a> branches on it directly, and the comment names the driver:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-kotlin" data-lang="kotlin">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">this&lt;/span>.connectionPool =
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span> (config.sqliteDriver.hasConnectionPool) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6272a4">// If the driver already has a connection pool then use a pass-through pool to
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> &lt;span style="color:#6272a4">// support drivers such as the Android since internally it already has a
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> &lt;span style="color:#6272a4">// thread-confined connection pool.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> PassthroughConnectionPool(&lt;span style="color:#ff79c6">..&lt;/span>.)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } &lt;span style="color:#ff79c6">else&lt;/span> &lt;span style="color:#ff79c6">if&lt;/span> (config.name &lt;span style="color:#ff79c6">==&lt;/span> &lt;span style="color:#ff79c6">null&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> newSingleConnectionPool(&lt;span style="color:#ff79c6">..&lt;/span>.)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } &lt;span style="color:#ff79c6">else&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">..&lt;/span>.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&amp;ldquo;Thread-confined&amp;rdquo; is the load-bearing word. The framework&amp;rsquo;s pool hands out a connection tied to the calling thread, and coroutines change threads whenever the dispatcher feels like it. &lt;a href="https://github.com/androidx/androidx/blob/androidx-main/room3/room3-runtime/src/commonMain/kotlin/androidx/room3/coroutines/PassthroughConnectionPool.kt">&lt;code>PassthroughConnectionPool&lt;/code>&lt;/a> is candid about what it is:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-kotlin" data-lang="kotlin">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">/**
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * An implementation of a connection pool that doesn&amp;#39;t do any connection management.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> *
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * Used in two scenarios:
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * * When Room is in compatibility mode (no driver is provided to Room).
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * * When a driver that reports having a connection pool (returns `true` from
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * [SQLiteDriver.hasConnectionPool]) is provided to Room.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> */&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>It holds a single &lt;code>private lateinit var connection: SQLiteConnection&lt;/code> and finds reentrant access by looking for a &lt;code>ConnectionElement&lt;/code> in the current coroutine context. Lose the thread, and you still have the context element; lose the context element, and you queue behind yourself.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>&lt;code>AndroidSQLiteDriver&lt;/code>&lt;/th>
&lt;th>&lt;code>BundledSQLiteDriver&lt;/code>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>hasConnectionPool&lt;/code>&lt;/td>
&lt;td>&lt;code>true&lt;/code> (overridden)&lt;/td>
&lt;td>&lt;code>false&lt;/code> (inherited default)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pool Room installs&lt;/td>
&lt;td>&lt;code>PassthroughConnectionPool&lt;/code>&lt;/td>
&lt;td>&lt;code>newSingleConnectionPool&lt;/code> or &lt;code>newConnectionPool&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Connections Room sees&lt;/td>
&lt;td>one, thread-confined&lt;/td>
&lt;td>one (in-memory) or four readers + one writer on WAL&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Acquisition timeout&lt;/td>
&lt;td>none&lt;/td>
&lt;td>30 s by default, then &lt;code>SQLiteException&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Native code in your APK&lt;/td>
&lt;td>none&lt;/td>
&lt;td>&lt;code>libsqliteJni.so&lt;/code>, four ABIs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SQLite version&lt;/td>
&lt;td>whatever the OS ships&lt;/td>
&lt;td>whatever the artifact ships&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The reader and writer counts are not folklore; they are constants in &lt;a href="https://github.com/androidx/androidx/blob/androidx-main/room3/room3-runtime/src/commonMain/kotlin/androidx/room3/ConnectionPoolConfiguration.kt">&lt;code>ConnectionPoolConfiguration.kt&lt;/code>&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-kotlin" data-lang="kotlin">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">internal&lt;/span> &lt;span style="color:#ff79c6">const&lt;/span> &lt;span style="color:#ff79c6">val&lt;/span> WAL_DEFAULT_NUMBER_OF_READERS = &lt;span style="color:#bd93f9">4&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">internal&lt;/span> &lt;span style="color:#ff79c6">const&lt;/span> &lt;span style="color:#ff79c6">val&lt;/span> WAL_DEFAULT_NUMBER_OF_WRITERS = &lt;span style="color:#bd93f9">1&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="where-exactly-does-a-suspending-transaction-wait-on-itself">Where exactly does a suspending @Transaction wait on itself?&lt;/h2>
&lt;p>In the invalidation tracker sync that Room runs immediately before opening a write transaction. &lt;a href="https://github.com/androidx/androidx/blob/androidx-main/room3/room3-runtime/src/commonMain/kotlin/androidx/room3/util/DBUtil.kt">&lt;code>DBUtil.kt&lt;/code>&lt;/a> acquires the connection first, then calls &lt;code>invalidationTracker.sync()&lt;/code> inside that connection&amp;rsquo;s scope:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-kotlin" data-lang="kotlin">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">internal&lt;/span> &lt;span style="color:#ff79c6">suspend&lt;/span> &lt;span style="color:#ff79c6">inline&lt;/span> &lt;span style="color:#ff79c6">fun&lt;/span> &amp;lt;&lt;span style="color:#50fa7b">R&lt;/span>&amp;gt; &lt;span style="color:#50fa7b">RoomDatabase&lt;/span>.internalPerform(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> isReadOnly: Boolean,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> inTransaction: Boolean,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">crossinline&lt;/span> block: &lt;span style="color:#ff79c6">suspend&lt;/span> (PooledConnection) &lt;span style="color:#ff79c6">-&amp;gt;&lt;/span> R,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>): R =
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> useConnection(isReadOnly) { transactor &lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span> (inTransaction) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">..&lt;/span>.
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span> (&lt;span style="color:#ff79c6">!is&lt;/span>ReadOnly &lt;span style="color:#ff79c6">&amp;amp;&amp;amp;&lt;/span> !transactor.inTransaction()) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> invalidationTracker.sync()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">val&lt;/span> result = transactor.withTransaction(type) { block.invoke(&lt;span style="color:#ff79c6">this&lt;/span>) }
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>sync()&lt;/code> reaches &lt;code>TriggerBasedInvalidationTracker.syncTriggers()&lt;/code>, which asks for a &lt;em>writer&lt;/em> connection of its own (&lt;code>useConnection(isReadOnly = false)&lt;/code>) and then takes a plain &lt;code>ReentrantLock&lt;/code> guarding the observed-table state. That state is guarded by two different kinds of lock at once, and &lt;a href="https://github.com/androidx/androidx/blob/androidx-main/room3/room3-runtime/src/commonMain/kotlin/androidx/room3/InvalidationTracker.kt">&lt;code>InvalidationTracker.kt&lt;/code>&lt;/a> documents the ordering rule in the source:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-kotlin" data-lang="kotlin">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">/**
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * Mutex to serialize [onSync] calls. Should never be acquired within a [lock] region, the order
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * must always be `[onSyncMutex]? -&amp;gt; [lock]` to avoid deadlocks.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">private&lt;/span> &lt;span style="color:#ff79c6">val&lt;/span> onSyncMutex = Mutex()
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>A &lt;code>kotlinx.coroutines.sync.Mutex&lt;/code> is owned by a coroutine. A &lt;code>ReentrantLock&lt;/code> is owned by a thread. Holding one and then taking the other, in a code path that is allowed to change threads partway through, is the hazard, and it is the hazard the Room team named out loud. The commit behind the fix in 3.0.2, authored by Daniel Santiago Rivera on 7 August 2026, &lt;a href="https://android.googlesource.com/platform/frameworks/support/+/886ac5">says it plainly&lt;/a>:&lt;/p>
&lt;blockquote>
&lt;p>A @Transaction delegate in a DAO has user-code that might switch dispatchers and cause databases with a passthrough pool (AndroidSQLiteDriver) to deadlock due to a change of thread. There an optimization to not hop to the transaction thread for &amp;lsquo;internal&amp;rsquo; Room operations but that is only safe knowing no dispatcher change occurs, something that can&amp;rsquo;t be guaranteed with transaction delegates in DAOs.&lt;/p>
&lt;/blockquote>
&lt;p>The remedy in that commit is a context marker, and the comment it left in &lt;code>DBUtil.kt&lt;/code> is now the second line of &lt;code>performInTransactionSuspending&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-kotlin" data-lang="kotlin">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">public&lt;/span> &lt;span style="color:#ff79c6">suspend&lt;/span> &lt;span style="color:#ff79c6">fun&lt;/span> &amp;lt;&lt;span style="color:#50fa7b">R&lt;/span>&amp;gt; &lt;span style="color:#50fa7b">performInTransactionSuspending&lt;/span>(db: RoomDatabase, block: &lt;span style="color:#ff79c6">suspend&lt;/span> () &lt;span style="color:#ff79c6">-&amp;gt;&lt;/span> R): R =
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6272a4">// The external operation marker is used since this wraps user code that might change
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> &lt;span style="color:#6272a4">// dispatchers and on a passthrough pool with thread local connections (AndroidSQLiteDriver)
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> &lt;span style="color:#6272a4">// the confined transaction thread behavior must be used at the cost of some thread hops.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> withContext(RoomExternalOperationElement + db.getCoroutineContext(inTransaction = &lt;span style="color:#ff79c6">true&lt;/span>)) {
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>In the current source that &lt;code>withContext&lt;/code> sits at &lt;code>DBUtil.kt:102&lt;/code>, which is the exact frame my thread dump pointed at.&lt;/p>
&lt;p>The reason the symptom was an eternal splash rather than an error is the timeout column in the table above. &lt;code>PassthroughConnectionPool.useConnection&lt;/code> takes no timeout parameter and has nowhere to put one. Room&amp;rsquo;s own pool does, and &lt;code>useReaderConnection&lt;/code> documents the consequence: &amp;ldquo;If a caller has to wait too long to acquire a connection a &lt;code>SQLiteException&lt;/code> will be thrown due to a timeout.&amp;rdquo; Room 3.1.0-alpha01 made the value settable, and its &lt;code>setConnectionPoolTimeout&lt;/code> documentation puts the default at &lt;code>30.seconds&lt;/code>. Same class of bug, two completely different failure modes. One throws after half a minute and lands in your crash reporter. The other holds the only connection in the process, so every subsequent read queues behind it, the readiness flag never flips, and you get a splash screen that outlives your patience.&lt;/p>
&lt;h2 id="is-this-already-fixed-in-a-newer-room-3">Is this already fixed in a newer Room 3?&lt;/h2>
&lt;p>Partly, and the version history is worth reading before you blame your own code. All three of these entries live in the &lt;a href="https://developer.android.com/jetpack/androidx/releases/room3">Room 3 release notes&lt;/a>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Version&lt;/th>
&lt;th>Date&lt;/th>
&lt;th>Relevant entry&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>3.0.2&lt;/td>
&lt;td>26 Aug 2026&lt;/td>
&lt;td>&amp;ldquo;Fix a deadlock that can occur on @Transaction DAO wrapper suspend functions that would do a context switch and had the AndroidSQLiteDriver configured.&amp;rdquo; (&lt;code>b/543285472&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3.0.3&lt;/td>
&lt;td>9 Sep 2026&lt;/td>
&lt;td>&amp;ldquo;Fix a regression that would cause Room to throw an IllegalMonitorStateException when using @Transaction wrapper DAO functions.&amp;rdquo; (&lt;code>b/543285472&lt;/code>, &lt;code>b/553140228&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3.1.0-alpha01&lt;/td>
&lt;td>9 Sep 2026&lt;/td>
&lt;td>Adds &lt;code>setConnectionPoolTimeout(timeout: Duration)&lt;/code> to &lt;code>RoomDatabase.Builder&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>I was running 3.0.2 when this bit me, which is to say I was running the version whose notes claim to fix this exact configuration, and the &lt;code>DBUtil.kt:102&lt;/code> frame in my dump is the fix itself. Then 3.0.3 had to repair a regression introduced by that fix, under the same bug id. I draw one conclusion from that sequence, and it is not that Room is broken: a thread-confined connection behind a coroutine API is a hard problem that took several attempts to get right, and the configuration that needs those attempts is the passthrough pool. If you can avoid being on that code path at all, avoid it.&lt;/p>
&lt;p>Which is why the first thing I changed was one line in the builder:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-kotlin" data-lang="kotlin">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#50fa7b">Room&lt;/span>.databaseBuilder&amp;lt;AppDatabase&amp;gt;(context, DB_NAME)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .setDriver(BundledSQLiteDriver()) &lt;span style="color:#6272a4">// was AndroidSQLiteDriver()
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> .setJournalMode(&lt;span style="color:#50fa7b">JournalMode&lt;/span>.WRITE_AHEAD_LOGGING)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .build()
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The splash cleared on the next install. Upgrade to current Room as well, of course. But the driver choice is the one that moves you off the pool implementation that &amp;ldquo;doesn&amp;rsquo;t do any connection management&amp;rdquo;.&lt;/p>
&lt;h2 id="what-does-switching-to-bundledsqlitedriver-actually-cost">What does switching to BundledSQLiteDriver actually cost?&lt;/h2>
&lt;p>Native code in your package, and you can measure that from Google&amp;rsquo;s Maven repository without building anything. I pulled both artifacts at version 2.7.1 and read the archives:&lt;/p>
&lt;pre tabindex="0">&lt;code>$ curl -sO https://dl.google.com/android/maven2/androidx/sqlite/sqlite-framework-android/2.7.1/sqlite-framework-android-2.7.1.aar
$ curl -sO https://dl.google.com/android/maven2/androidx/sqlite/sqlite-bundled-android/2.7.1/sqlite-bundled-android-2.7.1.aar
$ stat -c &amp;#39;%10s %n&amp;#39; *.aar
2551019 sqlite-bundled-android-2.7.1.aar
60286 sqlite-framework-android-2.7.1.aar
&lt;/code>&lt;/pre>&lt;p>60,286 bytes against 2,551,019. The difference is four copies of a compiled SQLite:&lt;/p>
&lt;pre tabindex="0">&lt;code>$ unzip -l sqlite-bundled-android-2.7.1.aar &amp;#39;jni/*&amp;#39;
Length Date Time Name
--------- ---------- ----- ----
1281456 1980-02-01 00:00 jni/arm64-v8a/libsqliteJni.so
1254892 1980-02-01 00:00 jni/armeabi-v7a/libsqliteJni.so
1140764 1980-02-01 00:00 jni/x86/libsqliteJni.so
1189688 1980-02-01 00:00 jni/x86_64/libsqliteJni.so
--------- -------
4866800 9 files
&lt;/code>&lt;/pre>&lt;p>Deflated inside the archive each one costs a little under half that:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>ABI&lt;/th>
&lt;th>&lt;code>libsqliteJni.so&lt;/code> uncompressed&lt;/th>
&lt;th>stored in the AAR&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>arm64-v8a&lt;/code>&lt;/td>
&lt;td>1,281,456 B&lt;/td>
&lt;td>645,789 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>armeabi-v7a&lt;/code>&lt;/td>
&lt;td>1,254,892 B&lt;/td>
&lt;td>625,184 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>x86_64&lt;/code>&lt;/td>
&lt;td>1,189,688 B&lt;/td>
&lt;td>626,525 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>x86&lt;/code>&lt;/td>
&lt;td>1,140,764 B&lt;/td>
&lt;td>632,389 B&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A universal APK carries all four, roughly 2.5 MB compressed. An ABI split or an App Bundle delivers one, which is 620 to 650 KB compressed and about 1.2 MB unpacked. That is the honest number to put in a decision, rather than the &amp;ldquo;it adds several megabytes&amp;rdquo; figure I had been repeating. The four ABIs are not an accident either; they are the Android entries in the &lt;code>requiredNativeTargets&lt;/code> list in &lt;a href="https://github.com/androidx/androidx/blob/androidx-main/sqlite/sqlite-bundled/build.gradle">&lt;code>sqlite-bundled/build.gradle&lt;/code>&lt;/a>, where the Android targets are also the ones compiled with &lt;code>-Oz&lt;/code> for size while every other platform gets &lt;code>-O3&lt;/code>.&lt;/p>
&lt;p>You get something for those bytes: a SQLite version you control, identical across every device and every OS release, instead of whatever the vendor shipped. For an app that uses FTS5, JSON functions, or &lt;code>PRAGMA&lt;/code> behaviour that changed between SQLite releases, that is the actual reason to bundle, and the deadlock fix is a bonus.&lt;/p>
&lt;p>One caveat from the bundled driver&amp;rsquo;s own documentation: the amalgamation is compiled in &lt;a href="https://www.sqlite.org/threadsafe.html">multi-thread mode&lt;/a>, so its connections are not individually thread-safe. That is fine precisely because Room puts a real pool in front of them. It is not fine if you hand those connections around yourself.&lt;/p>
&lt;h2 id="why-did-the-driver-swap-silently-change-pragma-synchronous">Why did the driver swap silently change PRAGMA synchronous?&lt;/h2>
&lt;p>Because Room sets &lt;code>PRAGMA synchronous&lt;/code> itself, per connection, based on the journal mode, and it does so without telling you. From &lt;a href="https://github.com/androidx/androidx/blob/androidx-main/room3/room3-runtime/src/commonMain/kotlin/androidx/room3/RoomConnectionManager.kt">&lt;code>RoomConnectionManager.kt&lt;/code>&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-kotlin" data-lang="kotlin">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">private&lt;/span> &lt;span style="color:#ff79c6">suspend&lt;/span> &lt;span style="color:#ff79c6">fun&lt;/span> &lt;span style="color:#50fa7b">configureSynchronousFlag&lt;/span>(connection: SQLiteConnection) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6272a4">// Use NORMAL in WAL mode and FULL for non-WAL as recommended in
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> &lt;span style="color:#6272a4">// https://www.sqlite.org/pragma.html#pragma_synchronous
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> &lt;span style="color:#ff79c6">val&lt;/span> wal = configuration.journalMode &lt;span style="color:#ff79c6">==&lt;/span> WRITE_AHEAD_LOGGING
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span> (wal) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> connection.executeSQL(&lt;span style="color:#f1fa8c">&amp;#34;PRAGMA synchronous = NORMAL&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> } &lt;span style="color:#ff79c6">else&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> connection.executeSQL(&lt;span style="color:#f1fa8c">&amp;#34;PRAGMA synchronous = FULL&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>On &lt;code>AndroidSQLiteDriver&lt;/code> this was invisible to me, because I had set &lt;code>PRAGMA synchronous = FULL&lt;/code> in a &lt;code>RoomDatabase.Callback.onOpen&lt;/code> and there was only ever one connection for it to apply to. Once Room was running its own WAL pool, the setting survived on the first connection and nowhere else, and the asymmetry is explicit in the same file. The two configuration paths are deliberately different:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-kotlin" data-lang="kotlin">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">/**
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * Performs initial database connection configuration and opening procedure, such as running
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * migrations if necessary, validating schema and invoking configured callbacks if any.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">private&lt;/span> &lt;span style="color:#ff79c6">suspend&lt;/span> &lt;span style="color:#ff79c6">fun&lt;/span> &lt;span style="color:#50fa7b">configureDatabase&lt;/span>(connection: SQLiteConnection) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> configureBusyTimeout(connection)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> configureJournalMode(connection)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> configureSynchronousFlag(connection)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6272a4">// ... migrations, identity check ...
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> onOpen(connection) &lt;span style="color:#6272a4">// -&amp;gt; callbacks.forEach { it.onOpen(connection) }
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">/**
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * Performs non-initial database connection configuration, specifically executing any
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * per-connection PRAGMA.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">protected&lt;/span> &lt;span style="color:#ff79c6">suspend&lt;/span> &lt;span style="color:#ff79c6">fun&lt;/span> &lt;span style="color:#50fa7b">configurationConnection&lt;/span>(connection: SQLiteConnection) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> configureBusyTimeout(connection)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> configureSynchronousFlag(connection)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> openDelegate.onOpen(connection) &lt;span style="color:#6272a4">// note: user callbacks are NOT invoked here
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The public reference for &lt;a href="https://developer.android.com/reference/kotlin/androidx/room3/RoomDatabase.Callback">&lt;code>RoomDatabase.Callback.onOpen&lt;/code>&lt;/a> documents the behaviour in one sentence: &amp;ldquo;Called when the database has been opened.&amp;rdquo; Nothing there says once per database rather than once per connection, and with a single-connection pool the difference never shows up. Note also that the Room 3 signature is &lt;code>open suspend fun onOpen(connection: SQLiteConnection)&lt;/code>: you are handed a connection, which reads like a per-connection hook and is not one.&lt;/p>
&lt;p>Worth being precise about what the downgrade costs, because &amp;ldquo;Room turned off fsync&amp;rdquo; would be wrong. &lt;a href="https://www.sqlite.org/pragma.html#pragma_synchronous">SQLite&amp;rsquo;s own documentation&lt;/a> is unusually clear here:&lt;/p>
&lt;blockquote>
&lt;p>WAL mode is always consistent with synchronous=NORMAL, but WAL mode does lose durability. A transaction committed in WAL mode with synchronous=NORMAL might roll back following a power loss or system crash. Transactions are durable across application crashes regardless of the synchronous setting or journal mode. The synchronous=NORMAL setting provides the best balance between performance and safety for most applications running in WAL mode.&lt;/p>
&lt;/blockquote>
&lt;p>The database never corrupts, and an app crash loses nothing. What &lt;code>NORMAL&lt;/code> gives up is the last committed transaction across a power loss or a kernel panic. Room&amp;rsquo;s default is defensible, and the SQLite documentation Room links to recommends it. It was wrong for my case because the table in question is an outbox: a row written there is the only record that a user&amp;rsquo;s work exists before it reaches the server, and losing the most recent commit on a device whose battery died is exactly the case the outbox exists to survive. &lt;code>FULL&lt;/code> is &lt;code>2&lt;/code> in SQLite&amp;rsquo;s encoding, and SQLite calls it &amp;ldquo;atomic, consistent, isolated, and durable (ACID) in WAL mode&amp;rdquo;.&lt;/p>
&lt;p>Pick deliberately. If your WAL database is a cache or a materialised read model, leave Room&amp;rsquo;s &lt;code>NORMAL&lt;/code> alone.&lt;/p>
&lt;h2 id="how-do-you-catch-a-pragma-that-only-applies-to-some-connections">How do you catch a pragma that only applies to some connections?&lt;/h2>
&lt;p>With an instrumented test that reads the pragma back on a writer connection. I only found the regression because I had written one, and I had written it for a different reason: after the driver change I wanted proof that WAL was still on. Room exposes the connection directly for this:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-kotlin" data-lang="kotlin">&lt;span style="display:flex;">&lt;span>@Test
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">fun&lt;/span> &lt;span style="color:#50fa7b">journalModeIsWalAndSynchronousIsFullOnTheWriterConnection&lt;/span>() = runTest {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> db.useWriterConnection { connection &lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> assertThat(connection.readSingleText(&lt;span style="color:#f1fa8c">&amp;#34;PRAGMA journal_mode&amp;#34;&lt;/span>)).isEqualTo(&lt;span style="color:#f1fa8c">&amp;#34;wal&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> assertThat(connection.readSingleInt(&lt;span style="color:#f1fa8c">&amp;#34;PRAGMA synchronous&amp;#34;&lt;/span>)).isEqualTo(&lt;span style="color:#bd93f9">2&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The failure was two integers:&lt;/p>
&lt;pre tabindex="0">&lt;code>expected: 2
but was : 1
&lt;/code>&lt;/pre>&lt;p>&lt;code>2&lt;/code> is &lt;code>FULL&lt;/code>, &lt;code>1&lt;/code> is &lt;code>NORMAL&lt;/code>. A couple of dozen other instrumented database tests passed, because none of them cared about durability settings. This is the kind of assertion that looks like test-suite padding right up until the day it earns its place. If you configure pragmas on an Android database, assert them on a connection rather than trusting the callback that set them.&lt;/p>
&lt;p>&lt;code>useWriterConnection&lt;/code> and &lt;code>useReaderConnection&lt;/code> are suspending functions on &lt;code>RoomDatabase&lt;/code> and are the honest way to ask; a raw query through a DAO may well land on a different connection than the one you mean to inspect.&lt;/p>
&lt;h2 id="how-do-you-force-a-pragma-on-every-connection-room-opens">How do you force a pragma on every connection Room opens?&lt;/h2>
&lt;p>Wrap the driver, not the database. Room calls &lt;code>SQLiteDriver.open()&lt;/code> for each connection it creates, which is the only hook that sees all of them. Intercepting the pragma statement itself is the smallest change that works:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-kotlin" data-lang="kotlin">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">class&lt;/span> &lt;span style="color:#50fa7b">FullSyncSQLiteDriver&lt;/span> : SQLiteDriver {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">private&lt;/span> &lt;span style="color:#ff79c6">val&lt;/span> delegate = BundledSQLiteDriver()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">override&lt;/span> &lt;span style="color:#ff79c6">fun&lt;/span> &lt;span style="color:#50fa7b">open&lt;/span>(fileName: String): SQLiteConnection =
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> FullSyncConnection(&lt;span style="color:#ff79c6">delegate&lt;/span>.&lt;span style="color:#ff79c6">open&lt;/span>(fileName))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">private&lt;/span> &lt;span style="color:#ff79c6">class&lt;/span> &lt;span style="color:#50fa7b">FullSyncConnection&lt;/span>(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">private&lt;/span> &lt;span style="color:#ff79c6">val&lt;/span> delegate: SQLiteConnection
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ) : SQLiteConnection {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">override&lt;/span> &lt;span style="color:#ff79c6">fun&lt;/span> &lt;span style="color:#50fa7b">prepare&lt;/span>(sql: String): SQLiteStatement =
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">delegate&lt;/span>.prepare(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span> (isSynchronousPragma(sql)) &lt;span style="color:#f1fa8c">&amp;#34;PRAGMA synchronous = FULL&amp;#34;&lt;/span> &lt;span style="color:#ff79c6">else&lt;/span> sql
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> )
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6272a4">// Required. The interface default throws, and Room calls this before every
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> &lt;span style="color:#6272a4">// write transaction.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> &lt;span style="color:#ff79c6">override&lt;/span> &lt;span style="color:#ff79c6">fun&lt;/span> &lt;span style="color:#50fa7b">inTransaction&lt;/span>(): Boolean = &lt;span style="color:#ff79c6">delegate&lt;/span>.inTransaction()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">override&lt;/span> &lt;span style="color:#ff79c6">fun&lt;/span> &lt;span style="color:#50fa7b">close&lt;/span>() = &lt;span style="color:#ff79c6">delegate&lt;/span>.close()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">private&lt;/span> &lt;span style="color:#ff79c6">fun&lt;/span> &lt;span style="color:#50fa7b">isSynchronousPragma&lt;/span>(sql: String): Boolean =
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> sql.trim()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .replace(Regex(&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>&lt;span style="color:#f1fa8c">\\&lt;/span>&lt;span style="color:#f1fa8c">s+&amp;#34;&lt;/span>), &lt;span style="color:#f1fa8c">&amp;#34; &amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> .startsWith(&lt;span style="color:#f1fa8c">&amp;#34;PRAGMA synchronous =&amp;#34;&lt;/span>, ignoreCase = &lt;span style="color:#ff79c6">true&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The &lt;code>inTransaction()&lt;/code> override is the part I got wrong first, and my initial explanation of why was also wrong. I assumed the interface&amp;rsquo;s default implementation answered from the wrapper&amp;rsquo;s own state and therefore returned a stale &lt;code>false&lt;/code>. It does not answer at all. From &lt;a href="https://github.com/androidx/androidx/blob/androidx-main/sqlite/sqlite/src/nonWebMain/kotlin/androidx/sqlite/SQLiteConnection.nonWeb.kt">&lt;code>SQLiteConnection.nonWeb.kt&lt;/code>&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-kotlin" data-lang="kotlin">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">public&lt;/span> &lt;span style="color:#ff79c6">actual&lt;/span> &lt;span style="color:#ff79c6">interface&lt;/span> &lt;span style="color:#50fa7b">SQLiteConnection&lt;/span> : AutoCloseable {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6272a4">/** Returns true if the connection has an active transaction, false otherwise. */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">public&lt;/span> &lt;span style="color:#ff79c6">actual&lt;/span> &lt;span style="color:#ff79c6">fun&lt;/span> &lt;span style="color:#50fa7b">inTransaction&lt;/span>(): Boolean {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">throw&lt;/span> NotImplementedError(&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>&lt;span style="color:#f1fa8c">$this&lt;/span>&lt;span style="color:#f1fa8c"> does not implement inTransaction().&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Without the delegation, every DAO function that writes inside a transaction fails, because &lt;code>internalPerform&lt;/code> calls &lt;code>transactor.inTransaction()&lt;/code> on the way in. In my case that was every test in one DAO class at once, which at least made the cause obvious. The reason the omission compiles cleanly is that both of these are &lt;em>default&lt;/em> interface members, and they have been since the version I was on. The frozen signature file &lt;a href="https://github.com/androidx/androidx/blob/androidx-main/sqlite/sqlite/api/2.7.0-rc01.txt">&lt;code>sqlite/api/2.7.0-rc01.txt&lt;/code>&lt;/a> records it:&lt;/p>
&lt;pre tabindex="0">&lt;code>public interface SQLiteConnection extends java.lang.AutoCloseable {
method public default boolean inTransaction();
public interface SQLiteDriver {
method @InaccessibleFromKotlin public default boolean hasConnectionPool();
property public default boolean hasConnectionPool;
&lt;/code>&lt;/pre>&lt;p>A Kotlin class implementing &lt;code>SQLiteConnection&lt;/code> compiles without &lt;code>inTransaction()&lt;/code> and crashes the first time Room needs it. Treat every default member of a driver or connection interface you wrap as something you must forward on purpose.&lt;/p>
&lt;h2 id="what-i-would-check-first-next-time">What I would check first next time&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>No crash, no ANR, process alive:&lt;/strong> stop reading code, send SIGQUIT, read &lt;code>/data/anr/trace_00&lt;/code>.&lt;/li>
&lt;li>&lt;strong>A Room frame in a blocked stack:&lt;/strong> check which driver is in the builder before anything else. &lt;code>PassthroughConnectionPool&lt;/code> in the trace means one connection for the whole process.&lt;/li>
&lt;li>&lt;strong>After changing the driver:&lt;/strong> re-assert every pragma you rely on, on a writer connection, not in the callback that sets it.&lt;/li>
&lt;li>&lt;strong>After wrapping a driver or connection:&lt;/strong> list the interface&amp;rsquo;s default members and forward all of them.&lt;/li>
&lt;li>&lt;strong>Before blaming your code:&lt;/strong> read the release notes for the exact library version you are on. The answer was in a one-line entry about &lt;code>@Transaction&lt;/code> DAO wrapper suspend functions, and it took me longer to find than the thread dump did.&lt;/li>
&lt;/ol>
&lt;h2 id="versions-this-was-reproduced-and-fixed-on">Versions this was reproduced and fixed on&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Version&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>androidx.room3:room3-runtime&lt;/code>&lt;/td>
&lt;td>3.0.2 (current stable at time of writing: 3.0.3)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>androidx.sqlite:sqlite&lt;/code>&lt;/td>
&lt;td>2.7.0 (current: 2.7.1)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kotlin&lt;/td>
&lt;td>2.4.10&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>minSdk&lt;/code> / &lt;code>compileSdk&lt;/code>&lt;/td>
&lt;td>26 / 37&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Emulator&lt;/td>
&lt;td>Android 16&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table></content:encoded></item><item><title>How to never pay a generative API twice: a durable task table, not a retrying job queue</title><link>https://bitpage.me/backend/pay-generative-api-once-durable-task-table/</link><pubDate>Sat, 26 Sep 2026 07:40:00 +0000</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.me/backend/pay-generative-api-once-durable-task-table/</guid><description>The instant you might have been charged is not the provider's response, it is the moment before the request leaves your process. Write it to a row before the HTTP call and a dead worker becomes recoverable without guessing: nothing written means retry is safe, a saved request id means poll, and the gap between them needs a third terminal state.</description><content:encoded>&lt;p>&lt;strong>Short answer:&lt;/strong> the moment you might have been charged is not the provider&amp;rsquo;s response, it is the instant before the request leaves your process. Write that instant to a database row before the HTTP call, and a worker that dies mid-request becomes recoverable without guessing: nothing on the row means nobody paid and the task can go back to the queue; a saved request id means poll and never resubmit; the boundary written with no id means a third terminal state that no automation is allowed to retry.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Fix:&lt;/strong> own task table with a lease, and a &lt;code>submit_started_at&lt;/code> column written &lt;em>before&lt;/em> the paid call. A queue retries by design; pg-boss says so in its own docs: &amp;ldquo;the job can run twice and handlers should be idempotent.&amp;rdquo;&lt;/li>
&lt;li>&lt;strong>Alternative:&lt;/strong> a provider-side idempotency key, if your provider has one. Stripe does. The OpenAI and Anthropic clients ship &lt;code>maxRetries: 2&lt;/code> and send no idempotency header at all.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> treating an expired deadline as a failure. A poll that ran out of patience is &amp;ldquo;I don&amp;rsquo;t know&amp;rdquo;, and the difference costs money every time you get it wrong.&lt;/li>
&lt;/ul>
&lt;h2 id="why-a-job-queue-is-the-wrong-unit-of-recovery-for-a-paid-call">Why a job queue is the wrong unit of recovery for a paid call&lt;/h2>
&lt;p>Because retrying is the entire point of a job queue, and for a paid POST a retry is a second charge. I started by running generation steps as ordinary jobs on the queue the rest of the system already used, &lt;a href="https://github.com/timgit/pg-boss">pg-boss&lt;/a>, because that is where every other background task lived. That lasted until I read the queue&amp;rsquo;s own documentation on what happens when a worker looks dead:&lt;/p>
&lt;blockquote>
&lt;p>A worker that only looked dead (a network partition, a stalled event loop) keeps running its handler after its job is failed this way, so the job can run twice and handlers should be idempotent.&lt;/p>
&lt;/blockquote>
&lt;p>That is &lt;a href="https://github.com/timgit/pg-boss/blob/master/docs/api/queues.md">the pg-boss queue reference&lt;/a>, and it is being honest rather than apologetic. Every durable queue has the same sentence somewhere, because a queue cannot tell a dead worker from a slow one. The contract it offers is at-least-once, and it tells you to make the handler idempotent. For sending an email, fine. For a handler whose first line spends money at an external provider, &amp;ldquo;make it idempotent&amp;rdquo; is the hard part being handed back to you.&lt;/p>
&lt;p>The defaults make it concrete. &lt;code>retryLimit&lt;/code> is 2, so a failing job runs three times. &lt;code>expireInSeconds&lt;/code> defaults to 15 minutes, after which an active job is retried or failed regardless of what the worker is doing. Image generation lands in tens of seconds and video generation in minutes, so a long clip can sail past the expiration while it is perfectly healthy, get failed by the monitor, and get retried into a second charge. You can raise the expiration to 24 hours, the documented maximum, but then a genuinely dead worker sits undetected for a day.&lt;/p>
&lt;p>There is a second problem that is not about money. A job that spends twenty minutes polling a remote id occupies a worker slot for twenty minutes. Concurrency on the queue is shared with everything else in the system, and a handful of slow generations will starve the short jobs that the queue exists for.&lt;/p>
&lt;p>The unit of recovery had to stop being a job and become a row.&lt;/p>
&lt;h2 id="what-rfc-9110-actually-says-about-retrying-a-post">What RFC 9110 actually says about retrying a POST&lt;/h2>
&lt;p>It says do not, unless you can prove one of two things, and the second one is the design brief for the whole task table. &lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2">RFC 9110, section 9.2.2&lt;/a>:&lt;/p>
&lt;blockquote>
&lt;p>A client SHOULD NOT automatically retry a request with a non-idempotent method unless it has some means to know that the request semantics are actually idempotent, regardless of the method, or some means to detect that the original request was never applied.&lt;/p>
&lt;/blockquote>
&lt;p>Two escape hatches. The first one, knowing the semantics are idempotent, is what a provider gives you with an idempotency key. Most generative APIs do not give you one, which I will get to. The second, &amp;ldquo;some means to detect that the original request was never applied,&amp;rdquo; is not a property of the protocol at all. It is a property of what you wrote down locally before you made the call.&lt;/p>
&lt;p>The RFC also has a line about clients that skip both:&lt;/p>
&lt;blockquote>
&lt;p>Some clients take a riskier approach and attempt to guess when an automatic retry is possible. For example, a client might automatically retry a POST request if the underlying transport connection closed before any part of a response is received, particularly if an idle persistent connection was used.&lt;/p>
&lt;/blockquote>
&lt;p>That paragraph describes the official clients of the largest paid APIs in the world, which is the subject of the next section.&lt;/p>
&lt;h2 id="do-the-generative-apis-give-you-an-idempotency-key">Do the generative APIs give you an idempotency key?&lt;/h2>
&lt;p>Mostly no, and the clients retry your POSTs anyway. I went looking for this expecting a boring answer and got a much sharper one by reading the SDK sources instead of the marketing pages.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>API&lt;/th>
&lt;th>Idempotency key on a POST&lt;/th>
&lt;th>Official client retries a POST by default&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Stripe&lt;/td>
&lt;td>&lt;code>Idempotency-Key&lt;/code>, first result stored and replayed&lt;/td>
&lt;td>not examined&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OpenAI&lt;/td>
&lt;td>none sent by &lt;code>openai-node&lt;/code>, see below&lt;/td>
&lt;td>yes, &lt;code>maxRetries: 2&lt;/code> on 408, 409, 429, ≥500, timeouts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Anthropic&lt;/td>
&lt;td>no occurrence of the word in &lt;code>anthropic-sdk-typescript&lt;/code>&lt;/td>
&lt;td>yes, &lt;code>maxRetries: 2&lt;/code> on 408, 409, 429, ≥500, timeouts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Replicate&lt;/td>
&lt;td>no occurrence in the &lt;a href="https://api.replicate.com/openapi.json">published OpenAPI document&lt;/a>&lt;/td>
&lt;td>not examined&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Stripe is the benchmark for how this is supposed to work, and its wording is worth quoting because it names the property that matters:&lt;/p>
&lt;blockquote>
&lt;p>Stripe&amp;rsquo;s idempotency works by saving the resulting status code and body of the first request made for any given idempotency key, regardless of whether it succeeds or fails. Subsequent requests with the same key return the same result, including 500 errors.&lt;/p>
&lt;/blockquote>
&lt;p>That is &lt;a href="https://docs.stripe.com/api/idempotent_requests">the Stripe API reference on idempotent requests&lt;/a>. Note the retention rule in the same page: keys can be pruned once they are at least 24 hours old, and a key reused after pruning generates a new request. Even the gold standard has an expiry after which your safety net is gone, which is a good reminder that the client side still has to keep its own record.&lt;/p>
&lt;p>The OpenAI side is where it gets strange. &lt;a href="https://github.com/openai/openai-node/blob/master/src/client.ts">The OpenAI Node client&lt;/a> carries the full apparatus for idempotency keys. It declares the field, it has a key generator whose default value announces where the machinery came from, and it builds the header:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-ts" data-lang="ts">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">protected&lt;/span> idempotencyHeader?: &lt;span style="color:#8be9fd">string&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">protected&lt;/span> defaultIdempotencyKey()&lt;span style="color:#ff79c6">:&lt;/span> &lt;span style="color:#8be9fd">string&lt;/span> {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">return&lt;/span> &lt;span style="color:#f1fa8c">`stainless-node-retry-&lt;/span>&lt;span style="color:#f1fa8c">${&lt;/span>uuid4()&lt;span style="color:#f1fa8c">}&lt;/span>&lt;span style="color:#f1fa8c">`&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">// ...later, while assembling headers:
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">let&lt;/span> idempotencyHeaders: &lt;span style="color:#8be9fd">HeadersLike&lt;/span> &lt;span style="color:#ff79c6">=&lt;/span> {};
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">if&lt;/span> (&lt;span style="color:#ff79c6">this&lt;/span>.idempotencyHeader &lt;span style="color:#ff79c6">&amp;amp;&amp;amp;&lt;/span> method &lt;span style="color:#ff79c6">!==&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;get&amp;#39;&lt;/span>) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span> (&lt;span style="color:#ff79c6">!&lt;/span>options.idempotencyKey) options.idempotencyKey &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#ff79c6">this&lt;/span>.defaultIdempotencyKey();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> idempotencyHeaders[&lt;span style="color:#ff79c6">this&lt;/span>.idempotencyHeader] &lt;span style="color:#ff79c6">=&lt;/span> options.idempotencyKey;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>idempotencyHeader&lt;/code> is declared and never assigned anywhere in the file. The condition is therefore always false and the header is never sent. &lt;code>RequestOptions&lt;/code> even exposes &lt;code>idempotencyKey&lt;/code> with the comment &amp;ldquo;A unique key for this request to enable idempotency&amp;rdquo;, and setting it accomplishes nothing, because there is no header name to put it in. I spent a while convinced I had missed an assignment in a subclass. There is none.&lt;/p>
&lt;p>Meanwhile the retry predicate in the same file does not look at the method at all:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-ts" data-lang="ts">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">private&lt;/span> &lt;span style="color:#ff79c6">async&lt;/span> shouldRetry(response: &lt;span style="color:#8be9fd">Response&lt;/span>)&lt;span style="color:#ff79c6">:&lt;/span> Promise&amp;lt;&lt;span style="color:#ff79c6">boolean&lt;/span>&amp;gt; {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">const&lt;/span> shouldRetryHeader &lt;span style="color:#ff79c6">=&lt;/span> response.headers.&lt;span style="color:#ff79c6">get&lt;/span>(&lt;span style="color:#f1fa8c">&amp;#39;x-should-retry&amp;#39;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span> (shouldRetryHeader &lt;span style="color:#ff79c6">===&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;true&amp;#39;&lt;/span>) &lt;span style="color:#ff79c6">return&lt;/span> &lt;span style="color:#ff79c6">true&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span> (shouldRetryHeader &lt;span style="color:#ff79c6">===&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;false&amp;#39;&lt;/span>) &lt;span style="color:#ff79c6">return&lt;/span> &lt;span style="color:#ff79c6">false&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span> (response.status &lt;span style="color:#ff79c6">===&lt;/span> &lt;span style="color:#bd93f9">408&lt;/span>) &lt;span style="color:#ff79c6">return&lt;/span> &lt;span style="color:#ff79c6">true&lt;/span>; &lt;span style="color:#6272a4">// request timeouts
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> &lt;span style="color:#ff79c6">if&lt;/span> (response.status &lt;span style="color:#ff79c6">===&lt;/span> &lt;span style="color:#bd93f9">409&lt;/span>) &lt;span style="color:#ff79c6">return&lt;/span> &lt;span style="color:#ff79c6">true&lt;/span>; &lt;span style="color:#6272a4">// lock timeouts
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> &lt;span style="color:#ff79c6">if&lt;/span> (response.status &lt;span style="color:#ff79c6">===&lt;/span> &lt;span style="color:#bd93f9">429&lt;/span>) &lt;span style="color:#ff79c6">return&lt;/span> &lt;span style="color:#ff79c6">true&lt;/span>; &lt;span style="color:#6272a4">// rate limits
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> &lt;span style="color:#ff79c6">if&lt;/span> (response.status &lt;span style="color:#ff79c6">&amp;gt;=&lt;/span> &lt;span style="color:#bd93f9">500&lt;/span>) &lt;span style="color:#ff79c6">return&lt;/span> &lt;span style="color:#ff79c6">true&lt;/span>; &lt;span style="color:#6272a4">// internal errors
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> &lt;span style="color:#ff79c6">return&lt;/span> &lt;span style="color:#ff79c6">false&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>DEFAULT_TIMEOUT&lt;/code> in that client is &lt;code>600000&lt;/code>, ten minutes, and a timeout goes down the same retry path. &lt;a href="https://github.com/anthropics/anthropic-sdk-typescript/blob/main/src/client.ts">The Anthropic TypeScript client&lt;/a> has a byte-for-byte equivalent predicate, the same &lt;code>maxRetries: 2&lt;/code> and the same ten-minute default, and zero occurrences of the string &lt;code>idempot&lt;/code> in the entire file.&lt;/p>
&lt;p>The default behaviour of both clients, then: send a paid POST, and if the server returns 500 or the socket goes quiet for ten minutes, send it again, up to two more times, with no way for the server to recognise it as the same request. Whether the first attempt was billed is between you and the provider&amp;rsquo;s meter. The client has no way to ask, and neither do you.&lt;/p>
&lt;p>This is not a scandal. It is the reasonable default for an API where most calls are cheap and a dropped connection is more likely than a silently successful one. It stops being reasonable the moment a single call costs real money and the user can see the balance.&lt;/p>
&lt;h2 id="the-boundary-is-before-the-request-not-after-the-response">The boundary is before the request, not after the response&lt;/h2>
&lt;p>The instant that matters is the last moment before the send, not the first moment after the answer. This is the one idea the whole design rests on, and it took me an embarrassing amount of staring at a state machine to see it.&lt;/p>
&lt;p>I had been drawing the boundary in the obvious place: the task is safe until the provider answers, and becomes committed when it answers. That is wrong, and the reason is that the answer is exactly the thing you do not have when the worker dies. A crash between &amp;ldquo;socket write completed&amp;rdquo; and &amp;ldquo;response parsed&amp;rdquo; leaves no evidence at all on the client side, and it is the most likely crash window there is, because that window is where all the waiting happens.&lt;/p>
&lt;p>Flip it. The task is safe until you &lt;em>begin&lt;/em> the request, and becomes unknown the instant you do. That boundary is knowable in advance, so it can be written down in advance:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-ts" data-lang="ts">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">async&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">function&lt;/span> runImageTask(task: &lt;span style="color:#8be9fd">Task&lt;/span>, ports: &lt;span style="color:#8be9fd">Ports&lt;/span>)&lt;span style="color:#ff79c6">:&lt;/span> Promise&amp;lt;&lt;span style="color:#ff79c6">void&lt;/span>&amp;gt; {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span> (task.status &lt;span style="color:#ff79c6">===&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;SUBMITTED&amp;#34;&lt;/span> &lt;span style="color:#ff79c6">&amp;amp;&amp;amp;&lt;/span> task.remoteRequestId) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">return&lt;/span> pollUntilDone(task, ports); &lt;span style="color:#6272a4">// already paid for, never resubmit
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">await&lt;/span> ports.repo.markSubmitStarted(task.id); &lt;span style="color:#6272a4">// the boundary
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> &lt;span style="color:#ff79c6">const&lt;/span> { requestId } &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#ff79c6">await&lt;/span> ports.provider.submit(task.input);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">await&lt;/span> ports.repo.recordSubmitted(task.id, requestId); &lt;span style="color:#6272a4">// now it is addressable
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">return&lt;/span> pollUntilDone(task, ports);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Three writes, and every crash window between them maps to exactly one recovery action. &lt;code>markSubmitStarted&lt;/code> is a separate round trip to the database before a call that is about to take thirty seconds, so the cost of the extra write is not worth discussing.&lt;/p>
&lt;p>The one rule this imposes on the rest of the code is that nothing may sit between &lt;code>markSubmitStarted&lt;/code> and the actual send. No input validation, no prompt assembly, no lazy credential fetch. Anything that can fail there produces a task parked in the unknown state for no reason, and parking a task costs a human&amp;rsquo;s attention later. Build the request fully, then mark, then send.&lt;/p>
&lt;h2 id="three-recovery-branches-after-an-expired-lease">Three recovery branches after an expired lease&lt;/h2>
&lt;p>A supervisor picking up a task whose lease has expired has exactly three cases, and the row tells it which one without any guessing:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>What is on the row&lt;/th>
&lt;th>What happened&lt;/th>
&lt;th>Recovery&lt;/th>
&lt;th>Money&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>status = RUNNING&lt;/code>, no &lt;code>submit_started_at&lt;/code>&lt;/td>
&lt;td>Died before the request went out&lt;/td>
&lt;td>Back to &lt;code>PENDING&lt;/code>&lt;/td>
&lt;td>Nothing spent, safe to redo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>status = SUBMITTED&lt;/code>, &lt;code>remote_request_id&lt;/code> set&lt;/td>
&lt;td>Died after the provider acknowledged&lt;/td>
&lt;td>Poll the saved id, never submit&lt;/td>
&lt;td>Already spent, result is retrievable&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>status = RUNNING&lt;/code>, &lt;code>submit_started_at&lt;/code> set, no id&lt;/td>
&lt;td>Died inside the request&lt;/td>
&lt;td>&lt;code>OUTCOME_UNKNOWN&lt;/code>, stop&lt;/td>
&lt;td>Might be spent, nobody can tell&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>In code it is short enough to fit in a screenshot, which is the point:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-ts" data-lang="ts">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">type&lt;/span> Recovery &lt;span style="color:#ff79c6">=&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">|&lt;/span> { action&lt;span style="color:#ff79c6">:&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;requeue&amp;#34;&lt;/span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">|&lt;/span> { action&lt;span style="color:#ff79c6">:&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;poll&amp;#34;&lt;/span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">|&lt;/span> { action&lt;span style="color:#ff79c6">:&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;park&amp;#34;&lt;/span>; status&lt;span style="color:#ff79c6">:&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;OUTCOME_UNKNOWN&amp;#34;&lt;/span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd;font-style:italic">function&lt;/span> recoveryFor(task: &lt;span style="color:#8be9fd">Task&lt;/span>)&lt;span style="color:#ff79c6">:&lt;/span> Recovery {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span> (task.status &lt;span style="color:#ff79c6">===&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;SUBMITTED&amp;#34;&lt;/span> &lt;span style="color:#ff79c6">&amp;amp;&amp;amp;&lt;/span> task.remoteRequestId) &lt;span style="color:#ff79c6">return&lt;/span> { action&lt;span style="color:#ff79c6">:&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;poll&amp;#34;&lt;/span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span> (task.submitStartedAt &lt;span style="color:#ff79c6">===&lt;/span> &lt;span style="color:#ff79c6">null&lt;/span>) &lt;span style="color:#ff79c6">return&lt;/span> { action&lt;span style="color:#ff79c6">:&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;requeue&amp;#34;&lt;/span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">return&lt;/span> { action&lt;span style="color:#ff79c6">:&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;park&amp;#34;&lt;/span>, status&lt;span style="color:#ff79c6">:&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;OUTCOME_UNKNOWN&amp;#34;&lt;/span> };
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Order matters in that function and it is not the order I wrote first. My first version checked &lt;code>submitStartedAt&lt;/code> before the request id, which sends an already-submitted task back through the submit path whenever both columns are set, which is always for a healthy submitted task. The request id is the strongest evidence on the row, so it gets tested first.&lt;/p>
&lt;h2 id="why-outcome_unknown-has-to-be-terminal">Why OUTCOME_UNKNOWN has to be terminal&lt;/h2>
&lt;p>Because every automatic way out of it is a bet placed with someone else&amp;rsquo;s money. This was the design decision I argued with myself about longest, since a state that requires a human is an operational cost forever.&lt;/p>
&lt;p>The two tempting exits both fail. Retrying assumes the request never landed, and if it did land you have paid twice and the user sees one result. Failing assumes the request never landed too, just with a nicer label: the task disappears from the pipeline, the charge does not disappear from the invoice, and the reconciliation happens weeks later when someone asks why the numbers do not match. Both are the same guess wearing different clothes.&lt;/p>
&lt;p>The state is therefore terminal, the UI says the outcome could not be determined and suggests checking the provider&amp;rsquo;s dashboard, and a human presses the button. In exchange, the system never spends money on its own initiative. For a paid API that is the correct side of the error to be on, and the volume is low enough that it stays correct: the branch only fires when a process dies inside the send window, which is a few seconds out of a generation that takes minutes.&lt;/p>
&lt;p>The same logic applies to deadlines, and this is where I got it wrong first. Put a cap on polling, because a remote id that never resolves cannot hold a slot forever. My original code marked the task &lt;code>FAILED&lt;/code> when the cap was reached. That is the same lie as before: the provider might still be working, or might have finished and billed while my poll loop gave up. An exhausted deadline is not a failure, it is an unknown outcome that happens to be slow. It goes to the same terminal state, with a different reason string.&lt;/p>
&lt;h2 id="claiming-work-with-for-update-skip-locked">Claiming work with FOR UPDATE SKIP LOCKED&lt;/h2>
&lt;p>The claim is a single statement, and PostgreSQL documents this exact use case. From &lt;a href="https://www.postgresql.org/docs/17/sql-select.html">the SELECT reference&lt;/a>:&lt;/p>
&lt;blockquote>
&lt;p>With &lt;code>SKIP LOCKED&lt;/code>, any selected rows that cannot be immediately locked are skipped. Skipping locked rows provides an inconsistent view of the data, so this is not suitable for general purpose work, but can be used to avoid lock contention with multiple consumers accessing a queue-like table.&lt;/p>
&lt;/blockquote>
&lt;p>&amp;ldquo;Inconsistent view of the data&amp;rdquo; is the point, not a caveat. Two supervisors claiming work must not see the same row, and neither needs an accurate count of what is pending.&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">update&lt;/span> generation_task t
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">set&lt;/span> lease_owner &lt;span style="color:#ff79c6">=&lt;/span> $&lt;span style="color:#bd93f9">1&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> lease_expires_at &lt;span style="color:#ff79c6">=&lt;/span> now() &lt;span style="color:#ff79c6">+&lt;/span> $&lt;span style="color:#bd93f9">2&lt;/span>::&lt;span style="color:#8be9fd;font-style:italic">interval&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> status &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#ff79c6">case&lt;/span> &lt;span style="color:#ff79c6">when&lt;/span> t.status &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;PENDING&amp;#39;&lt;/span> &lt;span style="color:#ff79c6">then&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;RUNNING&amp;#39;&lt;/span> &lt;span style="color:#ff79c6">else&lt;/span> t.status &lt;span style="color:#ff79c6">end&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">where&lt;/span> t.id &lt;span style="color:#ff79c6">in&lt;/span> (
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">select&lt;/span> id
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">from&lt;/span> generation_task
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">where&lt;/span> kind &lt;span style="color:#ff79c6">=&lt;/span> $&lt;span style="color:#bd93f9">3&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">and&lt;/span> (status &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;PENDING&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">or&lt;/span> (status &lt;span style="color:#ff79c6">in&lt;/span> (&lt;span style="color:#f1fa8c">&amp;#39;RUNNING&amp;#39;&lt;/span>, &lt;span style="color:#f1fa8c">&amp;#39;SUBMITTED&amp;#39;&lt;/span>) &lt;span style="color:#ff79c6">and&lt;/span> lease_expires_at &lt;span style="color:#ff79c6">&amp;lt;&lt;/span> now()))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">order&lt;/span> &lt;span style="color:#ff79c6">by&lt;/span> created_at
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">limit&lt;/span> $&lt;span style="color:#bd93f9">4&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">for&lt;/span> &lt;span style="color:#ff79c6">update&lt;/span> skip locked
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> )
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>returning t.&lt;span style="color:#ff79c6">*&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The &lt;code>case&lt;/code> matters, and this is the part I got wrong. An earlier version of this statement set &lt;code>status = 'RUNNING'&lt;/code> unconditionally, which erases the one signal that tells the executor to poll instead of submit. I rebuilt both versions on a scratch PostgreSQL 17.11 database to be sure I was not misremembering, and the buggy claim does exactly what you would fear:&lt;/p>
&lt;pre tabindex="0">&lt;code> status | remote_request_id
---------+-------------------
RUNNING | req-abc
&lt;/code>&lt;/pre>&lt;p>The &lt;code>remote_request_id&lt;/code> survives, the status does not. A recovered task that had already been paid for comes back looking like a fresh one. The guard at the top of &lt;code>runImageTask&lt;/code> still catches it, because that guard checks the id as well, but at that point the query had quietly become the only thing standing between a restart and a double charge, and I would rather two things stood there.&lt;/p>
&lt;p>On the same scratch database, &lt;code>SKIP LOCKED&lt;/code> behaves as advertised: with three eligible rows and one of them held by a concurrent &lt;code>SELECT ... FOR UPDATE&lt;/code> in another session, the claim returned 2 rows immediately instead of blocking on the third. That is the whole reason the clause exists, and it is worth confirming with your own eyes once, because the alternative failure mode, a supervisor tick that silently waits on a lock, looks identical to a supervisor tick that has nothing to do.&lt;/p>
&lt;p>Concurrency is per kind, not global, because the kinds have nothing in common. Take four concurrent image slots, three for clips, two for speech and exactly one for the final local render: the remote ones are limited by the provider&amp;rsquo;s rate limits and the local one is limited by CPU. Those particular numbers are arbitrary and yours will differ. The structure is not arbitrary: one global limit for all four would either starve the cheap fast work or oversubscribe the machine.&lt;/p>
&lt;h2 id="heartbeat-because-not-every-step-polls">Heartbeat, because not every step polls&lt;/h2>
&lt;p>A lease needs its own renewal, separate from whatever the work is doing. I learned this from a step that had no polling at all.&lt;/p>
&lt;p>Remote generation renews the lease for free as a side effect: the poll loop runs every few seconds, and touching the row at the same time costs nothing. Local work has no such loop. The final render is one long call into ffmpeg that returns when it returns, and speech synthesis for a short passage is a single request that either answers or does not. Both of them held a lease that nobody was extending, so the supervisor watched the lease expire on a task that was working perfectly, decided the worker was dead, and started recovery on a row that was still being written to.&lt;/p>
&lt;p>The fix is a timer independent of the work: renew at one third of the lease duration, so two consecutive misses still leave a margin before expiry. The ratio is what matters, not the absolute value. pg-boss reaches the same conclusion from the other direction and documents it as a separate mechanism from expiration, with a floor of 10 seconds on &lt;code>heartbeatSeconds&lt;/code> and a monitor that only notices staleness on its own interval, 60 seconds by default. Detection time is the sum of the two, which is worth internalising before you tune the lease down to something clever.&lt;/p>
&lt;h2 id="pinning-the-order-with-a-test">Pinning the order with a test&lt;/h2>
&lt;p>The order of those three writes is load-bearing and invisible, so it gets asserted directly rather than implied by behaviour. The executor takes its ports as an interface, the test passes recording fakes, and the assertion is on the call log:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-ts" data-lang="ts">&lt;span style="display:flex;">&lt;span>expect(calls).toEqual([
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#f1fa8c">&amp;#34;markSubmitStarted&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#f1fa8c">&amp;#34;submitImage&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#f1fa8c">&amp;#34;recordSubmitted&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#f1fa8c">&amp;#34;poll&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#f1fa8c">&amp;#34;complete&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>]);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Then the recovery case, which is the one that actually costs money if it regresses:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-ts" data-lang="ts">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">// a task restored in SUBMITTED state with a saved request id
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span>expect(calls).not.toContain(&lt;span style="color:#f1fa8c">&amp;#34;submitImage&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>expect(calls).toEqual([&lt;span style="color:#f1fa8c">&amp;#34;poll&amp;#34;&lt;/span>, &lt;span style="color:#f1fa8c">&amp;#34;complete&amp;#34;&lt;/span>]);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>A test that asserts on a sequence of call names is usually a smell, because it pins implementation detail rather than behaviour. Here the sequence &lt;em>is&lt;/em> the behaviour. Any refactor that moves &lt;code>markSubmitStarted&lt;/code> after &lt;code>submitImage&lt;/code> still passes every functional test, produces identical output on the happy path, and reintroduces the exact bug the design exists to prevent. The only way to catch it is to say out loud that the write comes first.&lt;/p>
&lt;h2 id="the-price-in-the-docs-is-not-the-price-on-the-invoice">The price in the docs is not the price on the invoice&lt;/h2>
&lt;p>Store two numbers per task, estimated and actual, because the first one will be wrong. I built the cost display off a model catalogue populated from provider documentation, showed users an estimate before they pressed the button, and felt good about it. The first real run billed noticeably more than the catalogue said. Take a step documented at four cents and invoiced at six: that is half again as much, multiplied by every step in a job. An estimate that far off is worse than no estimate, because people plan around it.&lt;/p>
&lt;p>The catalogue was not lying, it was quoting a headline number that did not include what the actual call resolved to. The correction is structural rather than a better catalogue: &lt;code>cost_estimated&lt;/code> comes from the provider&amp;rsquo;s quote endpoint at submit time, &lt;code>cost_actual&lt;/code> comes from the billing figure that arrives with the result, and the user-facing total is the sum of whichever is available per task. When the two disagree the actual value wins and the catalogue entry gets fixed. Documentation prices are for capacity planning. Billing figures are for arithmetic.&lt;/p>
&lt;h2 id="bottom-line">Bottom line&lt;/h2>
&lt;p>If a background task spends money at an external provider, the queue&amp;rsquo;s retry is no longer a reliability feature and the row, not the job, has to be the unit of recovery. Write the &amp;ldquo;I am about to spend money&amp;rdquo; boundary to the database before the request goes out, save the provider&amp;rsquo;s request id the moment it exists, and accept a third terminal state for the narrow window in between, where neither retrying nor failing is honest. The RFC has been describing this for years under &amp;ldquo;some means to detect that the original request was never applied&amp;rdquo;, and the means is a column.&lt;/p>
&lt;p>Primary sources worth reading in full: &lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2">RFC 9110 §9.2.2 on idempotent methods&lt;/a>, &lt;a href="https://www.postgresql.org/docs/17/sql-select.html">the PostgreSQL SELECT reference on SKIP LOCKED&lt;/a>, &lt;a href="https://github.com/timgit/pg-boss/blob/master/docs/api/queues.md">the pg-boss queue documentation on heartbeats versus expiration&lt;/a>, &lt;a href="https://docs.stripe.com/api/idempotent_requests">Stripe on idempotent requests&lt;/a>, and the retry predicates in &lt;a href="https://github.com/openai/openai-node/blob/master/src/client.ts">openai-node&lt;/a> and &lt;a href="https://github.com/anthropics/anthropic-sdk-typescript/blob/main/src/client.ts">anthropic-sdk-typescript&lt;/a>, which are worth twenty minutes of anyone&amp;rsquo;s time who is sending paid POSTs through them.&lt;/p></content:encoded></item><item><title>curl error 60: the message text fingerprints the TLS backend, not your certificate</title><link>https://bitpage.me/security/curl-error-60-tls-backend-fingerprint/</link><pubDate>Mon, 21 Sep 2026 05:00:00 +0000</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.me/security/curl-error-60-tls-backend-fingerprint/</guid><description>The same untrusted root produces six different sentences depending on which TLS library curl was linked against, and on wolfSSL it is not even error 60 but 77. Read the wording as a build identifier, run curl -V first, and only then go looking at the trust store.</description><content:encoded>&lt;p>&lt;strong>Short answer:&lt;/strong> &lt;code>curl: (60)&lt;/code> means one thing only: the peer&amp;rsquo;s certificate did not verify. Everything after the colon is written by whichever TLS library curl was compiled against, so &lt;code>Peer certificate cannot be authenticated with known CA certificates&lt;/code>, &lt;code>SSL certificate problem: self-signed certificate in certificate chain&lt;/code> and &lt;code>SSL certificate verification failed: certificate signer not trusted&lt;/code> can all be the same broken trust store on three different boxes. Run &lt;code>curl -V&lt;/code> before you search for the message, because the message identifies the build and not the fault.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Fix:&lt;/strong> read &lt;code>curl -V&lt;/code> first. The backend name in that line decides how every TLS failure will be phrased, and searching the phrase without it sends you to answers written for a different library.&lt;/li>
&lt;li>&lt;strong>Detail:&lt;/strong> error 60 is &lt;code>CURLE_PEER_FAILED_VERIFICATION&lt;/code> in &lt;code>curl.h&lt;/code>, and the old name &lt;code>CURLE_SSL_CACERT&lt;/code> is now a &lt;code>#define&lt;/code> onto it. The two wordings you find online are the same number in different eras of the same file.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> the number is not stable either. wolfSSL returns &lt;code>CURLE_SSL_CACERT_BADFILE&lt;/code>, error &lt;strong>77&lt;/strong>, for a missing signer, where OpenSSL returns 60.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> refreshing a CA bundle removes roots as well as adding them. Between the March 2018 and August 2026 Mozilla extracts published by curl, 80 roots left and 68 arrived, and only 53 of the original 133 survived.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> if the application sets &lt;code>CURLOPT_SSL_VERIFYPEER&lt;/code> to 0, none of this appears in its logs, because the CA certificates are never loaded at all.&lt;/li>
&lt;/ul>
&lt;h2 id="what-curl-error-60-actually-means">What curl error 60 actually means&lt;/h2>
&lt;p>Error 60 is a single enum value with a single meaning, and it carries no detail. In &lt;a href="https://github.com/curl/curl/blob/master/include/curl/curl.h">&lt;code>include/curl/curl.h&lt;/code>&lt;/a> it reads:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span> CURLE_PEER_FAILED_VERIFICATION, &lt;span style="color:#6272a4">/* 60 - peer&amp;#39;s certificate or fingerprint
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> was not verified fine */&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Further down the same header there is a compatibility line that explains half the confusion in every search result you will ever read on this:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">#define CURLE_SSL_CACERT CURLE_PEER_FAILED_VERIFICATION
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>CURLE_SSL_CACERT&lt;/code> used to be its own code. It is now an alias. So when one Stack Overflow answer talks about &lt;code>CURLE_SSL_CACERT&lt;/code> and another talks about &lt;code>CURLE_PEER_FAILED_VERIFICATION&lt;/code>, they are discussing the same integer, seen from different decades. Nothing about the actual chain is encoded in it.&lt;/p>
&lt;h2 id="the-same-failure-six-different-sentences">The same failure, six different sentences&lt;/h2>
&lt;p>One untrusted root produces a different sentence in every backend, because each backend writes its own &lt;code>failf()&lt;/code> string. These are lifted from &lt;code>lib/vtls/&lt;/code> in curl&amp;rsquo;s tree:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Backend&lt;/th>
&lt;th>What curl prints&lt;/th>
&lt;th>Exit code&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>OpenSSL 3.x&lt;/td>
&lt;td>&lt;code>SSL certificate problem: self-signed certificate in certificate chain&lt;/code>&lt;/td>
&lt;td>60&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OpenSSL 1.1.1&lt;/td>
&lt;td>&lt;code>SSL certificate problem: self signed certificate in certificate chain&lt;/code>&lt;/td>
&lt;td>60&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GnuTLS&lt;/td>
&lt;td>&lt;code>SSL certificate verification failed: certificate signer not trusted. (CAfile: ... CRLfile: ...)&lt;/code>&lt;/td>
&lt;td>60&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>mbedTLS&lt;/td>
&lt;td>&lt;code>peer certificate could not be verified&lt;/code>&lt;/td>
&lt;td>60&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>wolfSSL&lt;/td>
&lt;td>&lt;code> CA signer not available for verification&lt;/code>&lt;/td>
&lt;td>&lt;strong>77&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Schannel&lt;/td>
&lt;td>&lt;code>schannel: SNI or certificate check failed: ...&lt;/code>&lt;/td>
&lt;td>60&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NSS, curl 7.61.1 and older&lt;/td>
&lt;td>&lt;code>Peer certificate cannot be authenticated with known CA certificates&lt;/code>&lt;/td>
&lt;td>60&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three things in that table deserve a second look.&lt;/p>
&lt;p>The wolfSSL row is not a typo. In &lt;a href="https://github.com/curl/curl/blob/master/lib/vtls/wolfssl.c">&lt;code>lib/vtls/wolfssl.c&lt;/code>&lt;/a>, the branch for &lt;code>ASN_NO_SIGNER_E&lt;/code> does this:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">else&lt;/span> &lt;span style="color:#50fa7b">if&lt;/span>(detail &lt;span style="color:#ff79c6">==&lt;/span> ASN_NO_SIGNER_E) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span>(conn_config&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>verifypeer) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">failf&lt;/span>(data, &lt;span style="color:#f1fa8c">&amp;#34; CA signer not available for verification&amp;#34;&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">return&lt;/span> CURLE_SSL_CACERT_BADFILE;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>CURLE_SSL_CACERT_BADFILE&lt;/code> is 77, documented in the header as &amp;ldquo;could not load CACERT file, missing or wrong format&amp;rdquo;. On a wolfSSL build, a trust store that loads perfectly but lacks the right root reports itself as a problem with the file. If your monitoring alerts on curl exit codes, that single branch is enough to make a fleet look like it has two unrelated incidents.&lt;/p>
&lt;p>The GnuTLS row is the only one that tells you where it looked. It prints &lt;code>CAfile&lt;/code> and &lt;code>CRLfile&lt;/code> in the message itself. On OpenSSL you have to ask for that separately with &lt;code>-v&lt;/code>, and most people never do, which is how you end up with an hour of arguing about a certificate when the actual question was which file curl opened.&lt;/p>
&lt;p>The NSS row is the odd one out for a different reason, covered below: that sentence is not written by a backend at all.&lt;/p>
&lt;h2 id="where-the-openssl-wording-comes-from-and-why-it-changed">Where the OpenSSL wording comes from, and why it changed&lt;/h2>
&lt;p>curl does not write the OpenSSL message. It writes a prefix and then asks OpenSSL to fill in the rest. From &lt;a href="https://github.com/curl/curl/blob/master/lib/vtls/openssl.c">&lt;code>lib/vtls/openssl.c&lt;/code>&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span> lerr &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#50fa7b">SSL_get_verify_result&lt;/span>(octx&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>ssl);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span>(lerr &lt;span style="color:#ff79c6">!=&lt;/span> X509_V_OK) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> ssl_config&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>certverifyresult &lt;span style="color:#ff79c6">=&lt;/span> lerr;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">failf&lt;/span>(data, &lt;span style="color:#f1fa8c">&amp;#34;SSL certificate problem: %s&amp;#34;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">X509_verify_cert_error_string&lt;/span>(lerr));
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>That &lt;code>%s&lt;/code> comes straight out of OpenSSL&amp;rsquo;s &lt;a href="https://github.com/openssl/openssl/blob/master/crypto/x509/x509_txt.c">&lt;code>crypto/x509/x509_txt.c&lt;/code>&lt;/a>, which is a plain switch over the &lt;code>X509_V_ERR_*&lt;/code> codes. Which means the tail of the message follows OpenSSL&amp;rsquo;s source tree, not curl&amp;rsquo;s. In the 1.1.1 branch the entry reads:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">case&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">X509_V_ERR_SELF_SIGNED_CERT_IN_CHAIN&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">return&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;self signed certificate in certificate chain&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>In current OpenSSL the same case returns &lt;code>&amp;quot;self-signed certificate in certificate chain&amp;quot;&lt;/code>. One hyphen. The &lt;code>X509_V_ERR_*&lt;/code> code is identical, the condition is identical, the string is different. Search engines treat those as two different phrases, so quoting the message verbatim silently filters your results by OpenSSL major version, and the filter is invisible because nothing in either sentence mentions a version.&lt;/p>
&lt;p>The neighbouring entry in that file is the one worth memorising, because it is what &lt;code>openssl verify&lt;/code> gives you when a chain cannot be completed:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">case&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">return&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;unable to get local issuer certificate&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="where-the-nss-wording-came-from-and-why-you-will-not-see-it-again">Where the NSS wording came from, and why you will not see it again&lt;/h2>
&lt;p>&lt;code>Peer certificate cannot be authenticated with known CA certificates&lt;/code> is not an NSS string at all, and it is not written by any backend. It is curl&amp;rsquo;s generic fallback text, and you get it precisely because the NSS backend declined to describe the failure.&lt;/p>
&lt;p>Look at what the backend does in the 7.19.7 tree, in &lt;a href="https://github.com/curl/curl/blob/curl-7_19_7/lib/nss.c">&lt;code>lib/nss.c&lt;/code>&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">9
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span>(&lt;span style="color:#50fa7b">SSL_ForceHandshakeWithTimeout&lt;/span>(connssl&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>handle,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">PR_SecondsToInterval&lt;/span>(HANDSHAKE_TIMEOUT))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">!=&lt;/span> SECSuccess) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span>(conn&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>data&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>set.ssl.certverifyresult &lt;span style="color:#ff79c6">==&lt;/span> SSL_ERROR_BAD_CERT_DOMAIN)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> curlerr &lt;span style="color:#ff79c6">=&lt;/span> CURLE_PEER_FAILED_VERIFICATION;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">else&lt;/span> &lt;span style="color:#50fa7b">if&lt;/span>(conn&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>data&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>set.ssl.certverifyresult&lt;span style="color:#ff79c6">!=&lt;/span>&lt;span style="color:#bd93f9">0&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> curlerr &lt;span style="color:#ff79c6">=&lt;/span> CURLE_SSL_CACERT;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">goto&lt;/span> error;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>No &lt;code>failf()&lt;/code>. It picks an error code and jumps. The label it jumps to hides the one useful fact behind the verbose flag:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd;font-style:italic">error&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> err &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#50fa7b">PR_GetError&lt;/span>();
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">infof&lt;/span>(data, &lt;span style="color:#f1fa8c">&amp;#34;NSS error %d&lt;/span>&lt;span style="color:#f1fa8c">\n&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>, err);
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>infof&lt;/code> only prints under &lt;code>-v&lt;/code>. So with no error buffer set, the curl tool falls back to &lt;code>curl_easy_strerror()&lt;/code>, which in &lt;a href="https://github.com/curl/curl/blob/curl-7_19_7/lib/strerror.c">&lt;code>lib/strerror.c&lt;/code>&lt;/a> of that era said:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">case&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">CURLE_SSL_CACERT&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">return&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;Peer certificate cannot be authenticated with known CA certificates&amp;#34;&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>That sentence is therefore a description of an enum constant, printed by a tool that was never told what went wrong. The actual reason was sitting one &lt;code>-v&lt;/code> away as a bare negative integer.&lt;/p>
&lt;p>Those integers come from NSS&amp;rsquo;s &lt;a href="https://github.com/mozilla/nss/blob/master/lib/util/SECerrs.h">&lt;code>lib/util/SECerrs.h&lt;/code>&lt;/a>, where every entry is an offset from &lt;code>SEC_ERROR_BASE&lt;/code>, defined as &lt;code>-0x2000&lt;/code> in &lt;code>secerr.h&lt;/code>. The one you usually want is&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#50fa7b">ER3&lt;/span>(SEC_ERROR_UNKNOWN_ISSUER, SEC_ERROR_BASE &lt;span style="color:#ff79c6">+&lt;/span> &lt;span style="color:#bd93f9">13&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#f1fa8c">&amp;#34;Peer&amp;#39;s Certificate issuer is not recognized.&amp;#34;&lt;/span>)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>which arrives on the wire as &lt;code>NSS error -8179&lt;/code>. The one that derails people is &lt;code>SEC_ERROR_UNRECOGNIZED_OID&lt;/code> at &lt;code>SEC_ERROR_BASE + 143&lt;/code>, or &lt;code>-8049&lt;/code>, phrased &lt;code>&amp;quot;Unrecognized Object Identifier.&amp;quot;&lt;/code> It reads like a parsing bug and usually means an old NSS met an algorithm identifier that postdates it. Neither sentence resembles the curl wording they end up wrapped in, which is the entire problem.&lt;/p>
&lt;p>The wording has a hard version boundary. &lt;code>CURLE_SSL_CACERT&lt;/code> stopped being a distinct code in curl 7.62.0, released 31 October 2018, and the header still says so in a comment above the alias:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">/* added in 7.62.0 */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">#define CURLE_SSL_CACERT CURLE_PEER_FAILED_VERIFICATION
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The &lt;code>strerror.c&lt;/code> case went with it. Current curl has only &lt;code>CURLE_PEER_FAILED_VERIFICATION: &amp;quot;SSL peer certificate or SSH remote key was not OK&amp;quot;&lt;/code>. So a box printing the old sentence is running curl 7.61.1 or older, full stop, and that is a much more useful thing to learn from an error message than anything it says about certificates. curl 7.19.7, the build that shipped in enterprise Linux images of that generation, was released on 4 November 2009 and carries 72 published vulnerabilities in &lt;a href="https://curl.se/docs/releases.html">curl&amp;rsquo;s own release table&lt;/a>.&lt;/p>
&lt;p>NSS itself is gone from curl too. The &lt;a href="https://github.com/curl/curl/blob/master/docs/DEPRECATE.md">deprecation list&lt;/a> records it under past removals:&lt;/p>
&lt;pre tabindex="0">&lt;code>- NSS (removed in 8.3.0)
&lt;/code>&lt;/pre>&lt;p>and &lt;a href="https://curl.se/ch/8.3.0.html">the 8.3.0 changelog&lt;/a>, dated 13 September 2023, carries the one-liner &lt;code>nss: remove support for this TLS library&lt;/code>. Two lines further down the same list sits &lt;code>OpenSSL 1.1.1 and older (removed in 8.18.0)&lt;/code>, which means the unhyphenated &lt;code>self signed&lt;/code> wording has already stopped being reachable on any curl built after January 2026. Give it a few years and that phrase becomes a version fingerprint exactly the way the NSS one is now.&lt;/p>
&lt;h2 id="why-openssl-s_client-said-the-certificate-was-fine">Why openssl s_client said the certificate was fine&lt;/h2>
&lt;p>Because &lt;code>openssl s_client&lt;/code> and curl were not reading the same store. That is the whole trick, and it is documented rather than folklore. curl&amp;rsquo;s &lt;a href="https://curl.se/docs/ssl-compared.html">SSL library comparison&lt;/a> puts it in the glossary:&lt;/p>
&lt;blockquote>
&lt;p>For engines that use a database and do not also support files, the &lt;code>CURLOPT_CAINFO&lt;/code> option is ignored.&lt;/p>
&lt;/blockquote>
&lt;p>A curl linked against a database-backed library looks in that database. &lt;code>openssl s_client&lt;/code> looks at &lt;code>OPENSSLDIR&lt;/code> or whatever you pass to &lt;code>-CAfile&lt;/code>. Populate one and not the other and you get exactly the standoff I walked into: &lt;code>Verify return code: 0 (ok)&lt;/code> from &lt;code>s_client&lt;/code>, error 60 from curl, same host, same second, same machine.&lt;/p>
&lt;p>Even on a modern box with both tools on OpenSSL, the defaults differ in shape. On the machine I am writing this on:&lt;/p>
&lt;pre tabindex="0">&lt;code>$ curl -V
curl 8.14.1 (x86_64-pc-linux-gnu) libcurl/8.14.1 OpenSSL/3.5.7 ...
$ curl -sSv https://untrusted-root.badssl.com/ -o /dev/null 2&amp;gt;&amp;amp;1 | grep -i ca
* CAfile: /etc/ssl/certs/ca-certificates.crt
* CApath: /etc/ssl/certs
&lt;/code>&lt;/pre>&lt;p>One is a single concatenated file of 224,449 bytes, the other a directory holding 301 hash-named symlinks. Both are generated from the same package, and they do drift apart the moment somebody edits one of them by hand.&lt;/p>
&lt;p>The reproduction, for what it is worth, is two lines:&lt;/p>
&lt;pre tabindex="0">&lt;code>$ curl -sS https://untrusted-root.badssl.com/ -o /dev/null
curl: (60) SSL certificate problem: self-signed certificate in certificate chain
$ echo | openssl s_client -connect untrusted-root.badssl.com:443 \
-servername untrusted-root.badssl.com 2&amp;gt;/dev/null | grep Verify
Verify return code: 19 (self-signed certificate in certificate chain)
&lt;/code>&lt;/pre>&lt;p>Note what each tool chose to show. OpenSSL gives you the X.509 reason code, 19. curl gives you 60, which is the same for every reason there is, and pushes the actual reason into prose. If you only ever copy one habit out of this article, make it running &lt;code>s_client&lt;/code> alongside curl so you get the number.&lt;/p>
&lt;h2 id="the-first-command-is-curl--v">The first command is curl -V&lt;/h2>
&lt;p>&lt;code>curl -V&lt;/code> answers the question the error message is pretending to answer. It names the backend, the version, and therefore the entire vocabulary of failures you are about to see:&lt;/p>
&lt;pre tabindex="0">&lt;code>curl 7.19.7 (x86_64-...) libcurl/7.19.7 NSS/3.27.1 zlib/1.2.3
&lt;/code>&lt;/pre>&lt;p>Two seconds of reading and the rest of the incident is bounded. That line says the box will phrase trust failures in curl&amp;rsquo;s own pre-7.62.0 vocabulary, that it consults an NSS database rather than a PEM file for its roots, and that &lt;code>CURLOPT_CAINFO&lt;/code> may therefore be ignored outright. It also sets an upper bound on the protocol side of the conversation, which matters when a partner turns off older TLS: for the OpenSSL half of the world, TLS 1.3 did not exist before &lt;a href="https://openssl-library.org/post/2018-09-11-release111/">OpenSSL 1.1.1 on 11 September 2018&lt;/a>, so any &lt;code>libcurl/x OpenSSL/1.0.x&lt;/code> line is a box that cannot negotiate it no matter what you put in the trust store.&lt;/p>
&lt;p>I did not run &lt;code>curl -V&lt;/code> first. I spent a while comparing the certificate chain the browser showed against the one &lt;code>s_client&lt;/code> printed, found them identical, and concluded the server was fine. Which it was. The wasted time came from treating the error text as a description of the situation rather than as a property of the binary printing it.&lt;/p>
&lt;h2 id="how-stale-is-a-stale-trust-store-2018-against-2026">How stale is a stale trust store: 2018 against 2026&lt;/h2>
&lt;p>Stale is not a feeling, so I measured it. curl publishes dated Mozilla extracts at &lt;a href="https://curl.se/docs/caextract.html">curl.se/docs/caextract.html&lt;/a>, which makes this a two-command experiment anyone can repeat:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>curl -sO https://curl.se/ca/cacert-2018-03-07.pem
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>curl -sO https://curl.se/ca/cacert.pem
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>grep -c &lt;span style="color:#f1fa8c">&amp;#39;BEGIN CERTIFICATE&amp;#39;&lt;/span> cacert-2018-03-07.pem cacert.pem
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Counting is not enough, because a root can be replaced by a re-issued root under the same common name. So compare SHA-256 fingerprints:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">import&lt;/span> re&lt;span style="color:#ff79c6">,&lt;/span> subprocess
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">def&lt;/span> &lt;span style="color:#50fa7b">fingerprints&lt;/span>(path):
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> blocks &lt;span style="color:#ff79c6">=&lt;/span> re&lt;span style="color:#ff79c6">.&lt;/span>findall(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#f1fa8c">r&lt;/span>&lt;span style="color:#f1fa8c">&amp;#39;-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#8be9fd;font-style:italic">open&lt;/span>(path)&lt;span style="color:#ff79c6">.&lt;/span>read(), re&lt;span style="color:#ff79c6">.&lt;/span>S)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> out &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">set&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">for&lt;/span> b &lt;span style="color:#ff79c6">in&lt;/span> blocks:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> p &lt;span style="color:#ff79c6">=&lt;/span> subprocess&lt;span style="color:#ff79c6">.&lt;/span>run(
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> [&lt;span style="color:#f1fa8c">&amp;#39;openssl&amp;#39;&lt;/span>, &lt;span style="color:#f1fa8c">&amp;#39;x509&amp;#39;&lt;/span>, &lt;span style="color:#f1fa8c">&amp;#39;-noout&amp;#39;&lt;/span>, &lt;span style="color:#f1fa8c">&amp;#39;-fingerprint&amp;#39;&lt;/span>, &lt;span style="color:#f1fa8c">&amp;#39;-sha256&amp;#39;&lt;/span>],
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#8be9fd;font-style:italic">input&lt;/span>&lt;span style="color:#ff79c6">=&lt;/span>b&lt;span style="color:#ff79c6">.&lt;/span>encode(), capture_output&lt;span style="color:#ff79c6">=&lt;/span>&lt;span style="color:#ff79c6">True&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> out&lt;span style="color:#ff79c6">.&lt;/span>add(p&lt;span style="color:#ff79c6">.&lt;/span>stdout&lt;span style="color:#ff79c6">.&lt;/span>decode()&lt;span style="color:#ff79c6">.&lt;/span>strip())
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">return&lt;/span> out
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>old &lt;span style="color:#ff79c6">=&lt;/span> fingerprints(&lt;span style="color:#f1fa8c">&amp;#39;cacert-2018-03-07.pem&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>new &lt;span style="color:#ff79c6">=&lt;/span> fingerprints(&lt;span style="color:#f1fa8c">&amp;#39;cacert.pem&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd;font-style:italic">print&lt;/span>(&lt;span style="color:#8be9fd;font-style:italic">len&lt;/span>(old), &lt;span style="color:#8be9fd;font-style:italic">len&lt;/span>(new), &lt;span style="color:#8be9fd;font-style:italic">len&lt;/span>(old &lt;span style="color:#ff79c6">&amp;amp;&lt;/span> new), &lt;span style="color:#8be9fd;font-style:italic">len&lt;/span>(old &lt;span style="color:#ff79c6">-&lt;/span> new), &lt;span style="color:#8be9fd;font-style:italic">len&lt;/span>(new &lt;span style="color:#ff79c6">-&lt;/span> old))
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The two bundles I pulled while writing this carry these headers:&lt;/p>
&lt;pre tabindex="0">&lt;code>## Certificate data from Mozilla as of: Wed Mar 7 04:12:06 2018 GMT
## Certificate data from Mozilla as of: Thu Aug 13 03:12:01 2026 GMT
&lt;/code>&lt;/pre>&lt;p>And the result:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Measure&lt;/th>
&lt;th>March 2018 extract&lt;/th>
&lt;th>August 2026 extract&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Roots in the file&lt;/td>
&lt;td>133&lt;/td>
&lt;td>121&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>File size&lt;/td>
&lt;td>215,556 bytes&lt;/td>
&lt;td>188,900 bytes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fingerprints present in both&lt;/td>
&lt;td>53&lt;/td>
&lt;td>53&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fingerprints unique to this file&lt;/td>
&lt;td>80&lt;/td>
&lt;td>68&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Eight and a half years, and the headline count moved by twelve. Underneath, 148 of the 201 distinct roots changed sides. &lt;code>DST Root CA X3&lt;/code> is in the 2018 file and not the 2026 one. &lt;code>ISRG Root X2&lt;/code>, &lt;code>GTS Root R1&lt;/code>, &lt;code>GTS Root R3&lt;/code>, &lt;code>GTS Root R4&lt;/code>, &lt;code>GlobalSign Root CA - R6&lt;/code> and &lt;code>Sectigo Public Server Authentication Root R46&lt;/code> are in the 2026 file and not the 2018 one. If you have ever diffed a trust store by line count and concluded nothing much happened, that table is why the method does not work.&lt;/p>
&lt;h2 id="cross-signing-is-why-a-frozen-bundle-keeps-working">Cross-signing is why a frozen bundle keeps working&lt;/h2>
&lt;p>Here is the part that makes a stale trust store so hard to spot: it keeps working almost everywhere. I pointed the 2018 bundle at a spread of large sites in 2026 and every one of them verified:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">for&lt;/span> h in www.google.com github.com www.cloudflare.com letsencrypt.org &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> www.microsoft.com www.gov.uk www.sectigo.com; &lt;span style="color:#ff79c6">do&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> curl -sS --cacert cacert-2018-03-07.pem &lt;span style="color:#f1fa8c">&amp;#34;https://&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$h&lt;/span>&lt;span style="color:#f1fa8c">/&amp;#34;&lt;/span> -o /dev/null
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#8be9fd;font-style:italic">echo&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$h&lt;/span>&lt;span style="color:#f1fa8c"> -&amp;gt; &lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$?&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">done&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Seven hosts, seven zeroes. A bundle whose root data predates the pandemic still validates the mainstream web, because CAs cross-sign their new roots against their old ones for exactly this reason. The new hierarchy ships a chain that terminates at something your ancient store already trusts.&lt;/p>
&lt;p>That is also why the failure mode looks arbitrary when it finally arrives. It does not arrive when the roots change. It arrives when one particular operator retires one particular cross-signature, and then a single endpoint out of many starts refusing while everything else on the box is fine. Take eleven outbound integrations on a host and two of them failing: that is not a network problem and it does not look like a trust problem either. It looks like those two partners broke something.&lt;/p>
&lt;p>My first theory was in fact the opposite of the truth. I assumed the two failing endpoints had moved to a new root hierarchy that my store did not know, and that the cross-signed path was the thing missing. The test for that is &lt;code>openssl verify&lt;/code> with the intermediates supplied by hand. The &lt;a href="https://docs.openssl.org/master/man1/openssl-verify/">manual&lt;/a> is precise about the flag:&lt;/p>
&lt;blockquote>
&lt;p>&lt;code>-untrusted filename | uri&lt;/code>
A file or URI of untrusted certificates to use for chain building.&lt;/p>
&lt;/blockquote>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>openssl verify -CAfile old_root.pem &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> -untrusted inter1.pem -untrusted inter2.pem leaf.pem
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>That returned OK, which killed the theory. The control run, the same command with the cross-certificate left out, gave:&lt;/p>
&lt;pre tabindex="0">&lt;code>error 20 at 1 depth lookup: unable to get local issuer certificate
&lt;/code>&lt;/pre>&lt;p>Error 20 is &lt;code>X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY&lt;/code>, the string quoted earlier from &lt;code>x509_txt.c&lt;/code>. Two runs, a positive and a negative, and a theory I had spent half an hour on was gone. Do the negative control. Without it, &lt;code>verify&lt;/code> returning OK proves nothing except that you handed it enough certificates.&lt;/p>
&lt;h2 id="updating-a-bundle-removes-roots-too">Updating a bundle removes roots too&lt;/h2>
&lt;p>&lt;code>update-ca-trust&lt;/code> and &lt;code>apt install ca-certificates&lt;/code> are not additive operations, and the 80-roots-removed column above is the size of the risk. Replacing a trust store on a machine that talks to eleven partners can fix two endpoints and break a third, and the third failure will surface hours later when some cron job runs.&lt;/p>
&lt;p>Before swapping anything, test the candidate store side by side against the old one:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">while&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">read&lt;/span> -r host; &lt;span style="color:#ff79c6">do&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#8be9fd;font-style:italic">old&lt;/span>&lt;span style="color:#ff79c6">=&lt;/span>&lt;span style="color:#ff79c6">$(&lt;/span>curl -sS -m &lt;span style="color:#bd93f9">10&lt;/span> --cacert /etc/pki/tls/certs/ca-bundle.crt &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;https://&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$host&lt;/span>&lt;span style="color:#f1fa8c">/&amp;#34;&lt;/span> -o /dev/null; &lt;span style="color:#8be9fd;font-style:italic">echo&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">$?&lt;/span>&lt;span style="color:#ff79c6">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#8be9fd;font-style:italic">new&lt;/span>&lt;span style="color:#ff79c6">=&lt;/span>&lt;span style="color:#ff79c6">$(&lt;/span>curl -sS -m &lt;span style="color:#bd93f9">10&lt;/span> --cacert /tmp/candidate-bundle.pem &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;https://&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$host&lt;/span>&lt;span style="color:#f1fa8c">/&amp;#34;&lt;/span> -o /dev/null; &lt;span style="color:#8be9fd;font-style:italic">echo&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">$?&lt;/span>&lt;span style="color:#ff79c6">)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#8be9fd;font-style:italic">printf&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;%-40s old=%s new=%s\n&amp;#39;&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$host&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$old&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span> &lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>&lt;span style="color:#8be9fd;font-style:italic">$new&lt;/span>&lt;span style="color:#f1fa8c">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">done&lt;/span> &amp;lt; endpoints.txt
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>You want the &lt;code>new&lt;/code> column to be a superset of the successes in the &lt;code>old&lt;/code> column. A host that went from 0 to 60 is a regression, and finding it in a loop that takes a minute is better than finding it in a support ticket.&lt;/p>
&lt;p>Build &lt;code>endpoints.txt&lt;/code> from whatever the application actually dials, not from what the documentation says it dials. Grep the config, grep the code, grep an hour of egress logs. On the box in front of me the config listed fewer hosts than the code did, which is the normal state of affairs: somebody hardcoded a status endpoint years ago and nobody wrote it down.&lt;/p>
&lt;h2 id="the-root-that-no-public-bundle-will-ever-ship">The root that no public bundle will ever ship&lt;/h2>
&lt;p>Refreshing &lt;code>ca-certificates&lt;/code> fixes the roots Mozilla ships, and does nothing at all for the roots it does not. Those are more common than the conversation around trust stores suggests:&lt;/p>
&lt;ul>
&lt;li>an internal corporate CA, for services that never had a public certificate&lt;/li>
&lt;li>a partner&amp;rsquo;s private PKI, where the integration contract specifies their root&lt;/li>
&lt;li>a TLS-inspecting proxy sitting between you and the internet, minting certificates from its own CA&lt;/li>
&lt;li>a national or sector CA that is outside the browser root programs by design&lt;/li>
&lt;/ul>
&lt;p>None of these will ever arrive through a package update, no matter how many times you run the update. If one of your failing endpoints is signed by such a root, the bundle refresh will not touch it, and you will conclude the refresh did not work. It worked. It was aimed at a different layer.&lt;/p>
&lt;p>This is worth separating in your head before you start, because the two layers have different fixes and different blast radii. Refreshing the public set is a bulk operation with regression risk. Adding one private anchor is surgical and affects exactly the chains that terminate at it.&lt;/p>
&lt;h2 id="where-to-put-an-anchor-so-the-legacy-path-sees-it">Where to put an anchor so the legacy path sees it&lt;/h2>
&lt;p>Put the PEM in the anchors directory and run the extract, then check that the file your application actually reads is a symlink. The &lt;a href="https://www.mankier.com/8/update-ca-trust">&lt;code>update-ca-trust(8)&lt;/code>&lt;/a> manual gives the first half:&lt;/p>
&lt;blockquote>
&lt;p>QUICK HELP 1: To add a certificate in the simple PEM or DER file formats to the list of CAs trusted on the system:
add it as a new file to directory &lt;code>/etc/pki/ca-trust/source/anchors/&lt;/code>
run &lt;code>update-ca-trust extract&lt;/code>&lt;/p>
&lt;/blockquote>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>cp partner-root.pem /etc/pki/ca-trust/source/anchors/
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>update-ca-trust extract
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The second half is the trap, and the same manual spells it out under &amp;ldquo;Legacy Files&amp;rdquo;:&lt;/p>
&lt;blockquote>
&lt;p>The following file paths were used in legacy versions of the utility and have since been replaced. Scripts and configurations referencing these old paths should be updated.&lt;/p>
&lt;p>&lt;code>/etc/pki/tls/certs/ca-bundle.crt&lt;/code>
This file has been replaced by &lt;code>/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem&lt;/code>.&lt;/p>
&lt;/blockquote>
&lt;p>The mechanism that keeps old software working is described a few paragraphs earlier: &amp;ldquo;the classic filenames have been changed to symbolic links&amp;rdquo;. On a healthy system &lt;code>/etc/pki/tls/certs/ca-bundle.crt&lt;/code> points into &lt;code>/etc/pki/ca-trust/extracted/&lt;/code>, so the extract updates it for free. On a system where someone once fixed a certificate problem by writing a bundle straight to that path, it is a regular file, the symlink is gone, and &lt;code>update-ca-trust extract&lt;/code> now writes to a location nothing reads.&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>ls -l /etc/pki/tls/certs/ca-bundle.crt
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>One &lt;code>l&lt;/code> at the start of that output or the absence of it decides whether your last twenty minutes accomplished anything. Check it before the extract and after.&lt;/p>
&lt;p>The same manual carries the other half of the warning, about the extracted directory:&lt;/p>
&lt;blockquote>
&lt;p>Please never manually edit the files stored in this directory, because your changes will be lost and the files automatically overwritten, each time the &lt;code>update-ca-trust extract&lt;/code> command gets executed.&lt;/p>
&lt;/blockquote>
&lt;p>Which is, of course, exactly how the symlink got replaced by a regular file in the first place. Somebody hit a trust error, wrote the bundle where the error said to write it, and moved on.&lt;/p>
&lt;h2 id="the-application-logged-none-of-this">The application logged none of this&lt;/h2>
&lt;p>The reason nobody noticed for so long is that the application had certificate verification turned off, so it never produced the error in the first place. The two options were sitting in the HTTP client&amp;rsquo;s setup:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-php" data-lang="php">&lt;span style="display:flex;">&lt;span>CURLOPT_SSL_VERIFYPEER &lt;span style="color:#ff79c6">=&amp;gt;&lt;/span> &lt;span style="color:#ff79c6">false&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>CURLOPT_SSL_VERIFYHOST &lt;span style="color:#ff79c6">=&amp;gt;&lt;/span> &lt;span style="color:#ff79c6">false&lt;/span>,
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The &lt;a href="https://curl.se/libcurl/c/CURLOPT_SSL_VERIFYPEER.html">documentation for &lt;code>CURLOPT_SSL_VERIFYPEER&lt;/code>&lt;/a> states what that does to the diagnostics, not just to the security:&lt;/p>
&lt;blockquote>
&lt;p>When this option is disabled (set to zero), the CA certificates are not loaded and the peer certificate verification is skipped.&lt;/p>
&lt;/blockquote>
&lt;p>The store is not consulted. There is nothing to fail, so nothing is logged, so the trust store can rot for years without producing a single line anywhere. Every failure that got reported came from somewhere else: operational &lt;code>curl&lt;/code> calls typed by hand, a cron script that used the system defaults, one integration whose client had been written by someone who left verification on. That is why a couple of endpoints looked broken and the rest looked healthy. The rest were not healthy. They were unchecked.&lt;/p>
&lt;p>Sitting right next to those two lines was a third:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-php" data-lang="php">&lt;span style="display:flex;">&lt;span>CURLOPT_CAINFO &lt;span style="color:#ff79c6">=&amp;gt;&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">$this&lt;/span>&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>&lt;span style="color:#50fa7b">basePath&lt;/span> &lt;span style="color:#ff79c6">.&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;/cert/ca-bundle.crt&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>behind an &lt;code>if ($this-&amp;gt;useLocalCerts)&lt;/code> flag that was false. The directory it pointed at did not exist. It had not existed for years and nothing had ever complained, which the docs also explain, in &lt;a href="https://curl.se/libcurl/c/CURLOPT_CAINFO.html">&lt;code>CURLOPT_CAINFO&lt;/code>&lt;/a>:&lt;/p>
&lt;blockquote>
&lt;p>If &lt;code>CURLOPT_SSL_VERIFYPEER&lt;/code> is zero and you avoid verifying the server&amp;rsquo;s certificate, &lt;code>CURLOPT_CAINFO&lt;/code> need not even indicate an accessible file.&lt;/p>
&lt;/blockquote>
&lt;p>A dead path guarded by a false flag feeding an option that is ignored anyway. Three layers of nothing, all of it looking like configuration. I read that block twice before working out it could not possibly matter.&lt;/p>
&lt;h2 id="the-order-the-fix-has-to-happen-in">The order the fix has to happen in&lt;/h2>
&lt;p>Fix the store first, turn verification on second, and never the other way round. The sequence matters because each step has to be observable before the next one lands:&lt;/p>
&lt;ol>
&lt;li>Run &lt;code>curl -V&lt;/code> on the affected host and write down the backend. Everything after this depends on it.&lt;/li>
&lt;li>Build the endpoint list from config, code and egress logs. Not from documentation.&lt;/li>
&lt;li>Run the whole list against the current store and record exit codes. This is your baseline and you will need it.&lt;/li>
&lt;li>Fetch a candidate store and run the whole list against it with &lt;code>--cacert&lt;/code>. Compare to the baseline. Nothing regresses, or you stop.&lt;/li>
&lt;li>Install the candidate store. Verify that the legacy path is still a symlink into the extracted directory.&lt;/li>
&lt;li>Add any private anchors to &lt;code>/etc/pki/ca-trust/source/anchors/&lt;/code> and run &lt;code>update-ca-trust extract&lt;/code>. Re-run the list.&lt;/li>
&lt;li>Only now flip &lt;code>CURLOPT_SSL_VERIFYPEER&lt;/code> and &lt;code>CURLOPT_SSL_VERIFYHOST&lt;/code> back to true, in a deployment you can roll back.&lt;/li>
&lt;/ol>
&lt;p>Doing step 7 before step 5 turns a background problem into an outage. Every integration that was quietly unverified starts verifying against a store that is still wrong, and instead of two failing endpoints you have all of them. The temptation is real, because step 7 is the one that actually fixes the security hole and it is a one-line change. Resist it for the length of one deploy cycle.&lt;/p>
&lt;p>Keep the model from earlier: eleven outbound endpoints, two of them failing. Steps 1 through 6 take that to eleven verified and none failing, against a store you have diffed against the one it replaced. On the host that prompted this article the store work was done inside a single sitting and needed no service restart, because nothing in that path caches the bundle across requests. Step 7 shipped separately, the following day, with the baseline from step 3 as the rollback criterion.&lt;/p>
&lt;h2 id="how-i-verified-it-endpoint-by-endpoint">How I verified it, endpoint by endpoint&lt;/h2>
&lt;p>Verification is the loop from earlier, run once more after the change, with the baseline pinned next to it. There is no clever tooling involved and that is the point: a shell loop over a text file of hostnames, exit codes in two columns, and a diff.&lt;/p>
&lt;p>What makes it trustworthy is the negative control. Run the same loop with &lt;code>--cacert /dev/null&lt;/code> and confirm every line reports 77. If they do not, something in the path is ignoring your &lt;code>--cacert&lt;/code>, which on a database-backed backend is the documented behaviour rather than a bug, and you have just learned that your verification loop was proving nothing.&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>$ curl -sS --cacert /dev/null https://curl.se/ -o /dev/null
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>curl: &lt;span style="color:#ff79c6">(&lt;/span>77&lt;span style="color:#ff79c6">)&lt;/span> error setting certificate file: /dev/null
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>That is the third distinct exit code in this article for what a casual reading would call &amp;ldquo;certificate problem&amp;rdquo;: 60 for a chain that will not verify, 77 for a store that will not load, and 77 again on wolfSSL for a chain that will not verify. The numbers are not a taxonomy. They are an artefact of which branch in which backend happened to run.&lt;/p>
&lt;h2 id="bottom-line">Bottom line&lt;/h2>
&lt;p>Treat the text of a TLS error as a build identifier. &lt;code>curl -V&lt;/code> first, the message second, and the trust store third, because the message is written by the library named in the first command and tells you nothing about the certificate that a chain dump would not tell you better. When you do get to the store, diff it by fingerprint rather than by line count, keep a baseline of exit codes across every endpoint you actually call, and remember that a refresh takes roots away as readily as it adds them: 80 out of 133 between two published Mozilla extracts, eight years apart.&lt;/p></content:encoded></item><item><title>PostgreSQL index bloat: why VACUUM never shrinks an index, and how to measure it with avg_leaf_density</title><link>https://bitpage.me/databases/postgres-index-bloat-avg-leaf-density-reindex/</link><pubDate>Wed, 16 Sep 2026 13:50:00 +0000</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.me/databases/postgres-index-bloat-avg-leaf-density-reindex/</guid><description>A b-tree that has lost most of its keys keeps every page it allocated: PostgreSQL reclaims completely empty index pages, but a page with a few keys left on it stays put, and no autovacuum setting changes that. Measure the damage with pgstatindex.avg_leaf_density against the b-tree default fillfactor of 90, predict the recovery as size x density / 90, and fix it with REINDEX rather than VACUUM FULL.</description><content:encoded>&lt;p>&lt;strong>Short answer:&lt;/strong> If a PostgreSQL table&amp;rsquo;s heap is healthy and its indexes are six times larger than the heap, autovacuum is not broken and never was. VACUUM marks dead index entries reusable and reclaims index pages that become &lt;em>completely&lt;/em> empty, but a page holding one surviving key out of several hundred stays allocated forever, because a b-tree does not merge sparse neighbours. Measure it with &lt;code>pgstatindex(...).avg_leaf_density&lt;/code>, compare against the b-tree default fillfactor of 90, and rebuild with &lt;code>REINDEX INDEX CONCURRENTLY&lt;/code>. VACUUM FULL is the wrong tool: it rewrites the entire table to fix a problem that lives in the indexes.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Fix:&lt;/strong> &lt;code>REINDEX INDEX CONCURRENTLY &amp;lt;name&amp;gt;&lt;/code>, one index at a time. Peak extra space is one new index, not one new table, and the disk comes back after each step.&lt;/li>
&lt;li>&lt;strong>Diagnose:&lt;/strong> &lt;code>avg_leaf_density&lt;/code> from &lt;code>pgstattuple&lt;/code>. A freshly built b-tree sits near 90 because &lt;a href="https://www.postgresql.org/docs/15/sql-createindex.html">that is its default fillfactor&lt;/a>. Anything far below is bloat you can quantify.&lt;/li>
&lt;li>&lt;strong>Predict:&lt;/strong> recovered size is roughly &lt;code>current_size x density / 90&lt;/code>. It is arithmetic off two numbers you already have.&lt;/li>
&lt;li>&lt;strong>Don&amp;rsquo;t:&lt;/strong> VACUUM FULL, unless the &lt;em>heap&lt;/em> is the thing that is bloated. It takes &lt;code>ACCESS EXCLUSIVE&lt;/code> and needs free space the size of the whole table.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> an index with &lt;code>idx_scan = 0&lt;/code> is not automatically dead. Uniqueness checks on INSERT do not increment that counter, so unique indexes look unused while they are holding your constraints up.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> &lt;code>pg_stat_*&lt;/code> counters are per node. A zero on the primary says nothing about what the replica is serving.&lt;/li>
&lt;/ul>
&lt;p>A note on numbers before anything else. The absolute volumes below are a model, because someone else&amp;rsquo;s disk is not mine to publish. What is real is the shape: the ratio of index size to heap, the densities rounded to whole percent, and every PostgreSQL default and doc quote, which you can check against the links yourself.&lt;/p>
&lt;h2 id="the-disk-alert-was-about-the-indexes-not-the-tables">The disk alert was about the indexes, not the tables&lt;/h2>
&lt;p>The page that came in said the root volume was over 80% full, and the fix turned out to be in a place the alert could not see. Take a box with a 125 GB root volume, 101 GB used, 24 GB free, and a threshold that fires under 20% free. It had just crossed.&lt;/p>
&lt;p>The space breakdown looked ordinary at first:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>What&lt;/th>
&lt;th>Size&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>PostgreSQL data directory&lt;/td>
&lt;td>62 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Docker images&lt;/td>
&lt;td>14 GB (6 GB unreferenced)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Application uploads&lt;/td>
&lt;td>9 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Application logs&lt;/td>
&lt;td>5 GB, no rotation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>journald&lt;/td>
&lt;td>tens of MB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Logs and stale images are the usual suspects and they are worth about 11 GB here. The database is worth 62. So I went to look at the largest tables, fully intending to VACUUM FULL the worst one.&lt;/p>
&lt;pre tabindex="0">&lt;code> relname | total | heap | idx | n_live_tup | n_dead_tup
-----------------+---------+---------+---------+------------+------------
batch_result | 35 GB | 4710 MB | 30 GB | 19840000 | 2600000
batch_cache | 4504 MB | 30 MB | 4474 MB | 0 | 512000
&lt;/code>&lt;/pre>&lt;p>That stopped the plan. A table of roughly 20 million rows carrying 4.6 GB of heap is fine. Its indexes are 30 GB, more than six times the data they point at. The second row is stranger still: zero live rows, 30 MB of heap, and 4.4 GB of indexes on top of nothing.&lt;/p>
&lt;p>My first half hour went into a hypothesis one query would have killed. The lesson is cheap to state and I keep relearning it: split &lt;code>pg_relation_size&lt;/code> from &lt;code>pg_indexes_size&lt;/code> before choosing a tool. Heap bloat and index bloat are different diseases with different cures, and the combined &lt;code>pg_total_relation_size&lt;/code> hides which one you have.&lt;/p>
&lt;h2 id="autovacuum-was-not-broken-it-was-working-itself-to-death">Autovacuum was not broken, it was working itself to death&lt;/h2>
&lt;p>The counters ruled out every standard explanation for &amp;ldquo;space is not coming back&amp;rdquo;. Autovacuum was on with default settings, there were no replication slots holding an old xmin, and no long-running transaction was pinning the horizon.&lt;/p>
&lt;pre tabindex="0">&lt;code> relname | ins | upd | del | hot | hot_pct | av_cnt
----------------+-------------+-------------+-------------+-----------+---------+--------
batch_staging | 9105000000 | 20300000000 | 0 | 1810000 | 0.0 | 42100
batch_cache | 9160000000 | 0 | 9160000000 | 0 | | 51800
batch_result | 9140000000 | 0 | 9110000000 | 0 | | 7900
&lt;/code>&lt;/pre>&lt;p>Autovacuum had run tens of thousands of times on each of the hot tables. It was not asleep, misconfigured or starved. It was running flat out and the space still was not coming back, which meant the problem was something autovacuum does not do at all.&lt;/p>
&lt;p>This is the counterintuitive part and it sends people down a long road of tuning &lt;code>autovacuum_naptime&lt;/code> and &lt;code>autovacuum_vacuum_cost_limit&lt;/code>. None of those knobs touch the mechanism below. The instinct &amp;ldquo;space is not returning, so vacuum must be failing&amp;rdquo; is wrong here in a specific and useful way.&lt;/p>
&lt;h2 id="what-vacuum-actually-does-with-freed-space">What VACUUM actually does with freed space&lt;/h2>
&lt;p>VACUUM makes space reusable inside the file; it does not hand it back to the operating system. The &lt;a href="https://www.postgresql.org/docs/15/routine-vacuuming.html">routine vacuuming docs&lt;/a> are explicit:&lt;/p>
&lt;blockquote>
&lt;p>The standard form of VACUUM removes dead row versions in tables and indexes and marks the space available for future reuse. However, it will not return the space to the operating system, except in the special case where one or more pages at the end of a table become entirely free and an exclusive table lock can be easily obtained.&lt;/p>
&lt;/blockquote>
&lt;p>That exception almost never fires under a churn workload, because the free pages end up scattered rather than parked at the tail. This is a deliberate trade: VACUUM runs without locking the table, and the price is that &lt;code>df&lt;/code> does not move.&lt;/p>
&lt;p>VACUUM FULL does return space, and the &lt;a href="https://www.postgresql.org/docs/15/sql-vacuum.html">VACUUM reference&lt;/a> spells out what it costs:&lt;/p>
&lt;blockquote>
&lt;p>This method also requires extra disk space, since it writes a new copy of the table and doesn&amp;rsquo;t release the old copy until the operation is complete.&lt;/p>
&lt;/blockquote>
&lt;p>On the numbers above that means rewriting 35 GB with 24 GB free, while holding &lt;code>ACCESS EXCLUSIVE&lt;/code>, in order to reclaim space that is sitting in the indexes. It would have run the volume to zero and taken the application down on the way.&lt;/p>
&lt;h2 id="why-a-b-tree-never-compacts-itself">Why a b-tree never compacts itself&lt;/h2>
&lt;p>A b-tree reclaims index pages that go completely empty and leaves partially empty ones exactly where they are. That single sentence is the root of the whole incident, and the &lt;a href="https://www.postgresql.org/docs/15/routine-reindex.html">reindexing docs&lt;/a> say it plainly:&lt;/p>
&lt;blockquote>
&lt;p>B-tree index pages that have become completely empty are reclaimed for re-use. However, there is still a possibility of inefficient use of space: if all but a few index keys on a page have been deleted, the page remains allocated. Therefore, a usage pattern in which most, but not all, keys in each range are eventually deleted will see poor use of space. For such usage patterns, periodic reindexing is recommended.&lt;/p>
&lt;/blockquote>
&lt;p>There is no page merging and no rebalancing on delete. PostgreSQL will not walk two neighbouring leaf pages that are 5% full each and fold them into one. Once density drops it stays dropped, and the only operation that raises it is a full rebuild of the index.&lt;/p>
&lt;p>Worth being precise about the failure pattern, because it explains why this bites some workloads and not others. Deleting a contiguous range is survivable, since whole pages empty out and get reclaimed. Deleting &lt;em>most&lt;/em> of every range is the bad case, and a batch job that rewrites a table by key is exactly that: it scatters a few survivors across every page it touches.&lt;/p>
&lt;h2 id="why-it-accumulated-so-fast-hot-updates-at-00">Why it accumulated so fast: HOT updates at 0.0%&lt;/h2>
&lt;p>The accelerant was heap-only tuple updates not happening. When an UPDATE leaves every indexed column unchanged and the page has room, PostgreSQL writes the new row version in place and touches no index at all. When it cannot, every index on the table gets a new entry.&lt;/p>
&lt;p>On the staging table above, tens of billions of updates produced 1.8 million HOT updates. Rounded to one decimal that is 0.0%. Every one of those updates wrote a fresh entry into each of the table&amp;rsquo;s indexes, and every superseded entry became a dead key sitting on a page that would never be reclaimed.&lt;/p>
&lt;p>The two mechanisms stack. The b-tree not merging pages explains why the bloat is permanent; HOT at zero explains why it arrived within months instead of years. Looking at either one alone makes the numbers seem impossible.&lt;/p>
&lt;p>The load profile behind it: roughly nine billion inserts and almost exactly nine billion deletes against a table holding 20 million rows. That is the entire contents of the table rewritten several hundred times over. The cache table is the same story with the ending removed, since it is emptied with &lt;code>DELETE&lt;/code> instead of &lt;code>TRUNCATE&lt;/code>, which leaves zero live rows and 4.4 GB of index behind.&lt;/p>
&lt;h2 id="the-table-autovacuum-had-never-touched-once">The table autovacuum had never touched once&lt;/h2>
&lt;p>Separately, a table can be permanently below the threshold and never get vacuumed at all, which looks identical from the outside. The &lt;a href="https://www.postgresql.org/docs/15/runtime-config-autovacuum.html">autovacuum settings docs&lt;/a> describe &lt;code>autovacuum_vacuum_scale_factor&lt;/code> as &amp;ldquo;a fraction of the table size to add to autovacuum_vacuum_threshold&amp;rdquo;, and note that &amp;ldquo;The default is 0.2 (20% of table size)&amp;rdquo;.&lt;/p>
&lt;p>Take a reporting table of 10 million live rows. The threshold is &lt;code>autovacuum_vacuum_threshold&lt;/code> plus that fraction of the table, so 50 plus 2 million dead rows before autovacuum will look at it. This one had accumulated about 240,000, roughly a tenth of what it needed, so its &lt;code>autovacuum_count&lt;/code> was 0 and had always been 0. Its HOT ratio was near 70%, so it was not bloating fast, but its dead rows were never being reclaimed and nothing was going to change that on its own.&lt;/p>
&lt;p>ANALYZE is the part that still happens, because &lt;code>autovacuum_analyze_scale_factor&lt;/code> defaults to 0.1 rather than 0.2 and counts inserts as well as updates and deletes. That is a useful asymmetry to remember when a table looks healthy in &lt;code>pg_stats&lt;/code> and has never been vacuumed in its life.&lt;/p>
&lt;p>For a big, slowly-churning table, 0.2 is the wrong default. A per-table &lt;code>autovacuum_vacuum_scale_factor = 0.01&lt;/code> is the standard remedy. One caveat if you run Patroni: setting it through &lt;code>ALTER SYSTEM&lt;/code> survives until the next restart or failover, because Patroni rewrites &lt;code>postgresql.conf&lt;/code> from its own configuration. Cluster-wide values go through &lt;code>patronictl edit-config&lt;/code>, and per-table ones through &lt;code>ALTER TABLE ... SET&lt;/code>, which lives in the catalog and is safe.&lt;/p>
&lt;h2 id="how-to-measure-bloat-instead-of-guessing-at-it">How to measure bloat instead of guessing at it&lt;/h2>
&lt;p>&lt;code>pgstatindex&lt;/code> from the &lt;code>pgstattuple&lt;/code> extension reports &lt;code>avg_leaf_density&lt;/code>, and the number is directly interpretable because you know what a healthy value is. The &lt;a href="https://www.postgresql.org/docs/15/sql-createindex.html">CREATE INDEX docs&lt;/a> state it: &amp;ldquo;B-trees use a default fillfactor of 90&amp;rdquo;. A rebuilt index packs its leaves to 90% and starts drifting down from there.&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">CREATE&lt;/span> EXTENSION &lt;span style="color:#ff79c6">IF&lt;/span> &lt;span style="color:#ff79c6">NOT&lt;/span> &lt;span style="color:#ff79c6">EXISTS&lt;/span> pgstattuple;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">SELECT&lt;/span> i.indexrelname,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pg_size_pretty(pg_relation_size(i.indexrelid)) &lt;span style="color:#ff79c6">AS&lt;/span> sz,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> round(s.avg_leaf_density::&lt;span style="color:#8be9fd;font-style:italic">numeric&lt;/span>, &lt;span style="color:#bd93f9">1&lt;/span>) &lt;span style="color:#ff79c6">AS&lt;/span> density
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">FROM&lt;/span> (&lt;span style="color:#ff79c6">SELECT&lt;/span> indexrelid, indexrelname
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">FROM&lt;/span> pg_stat_user_indexes
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">ORDER&lt;/span> &lt;span style="color:#ff79c6">BY&lt;/span> pg_relation_size(indexrelid) &lt;span style="color:#ff79c6">DESC&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">LIMIT&lt;/span> &lt;span style="color:#bd93f9">20&lt;/span>) i
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">JOIN&lt;/span> pg_class &lt;span style="color:#ff79c6">c&lt;/span> &lt;span style="color:#ff79c6">ON&lt;/span> &lt;span style="color:#ff79c6">c&lt;/span>.oid &lt;span style="color:#ff79c6">=&lt;/span> i.indexrelid
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">JOIN&lt;/span> pg_am am &lt;span style="color:#ff79c6">ON&lt;/span> am.oid &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#ff79c6">c&lt;/span>.relam &lt;span style="color:#ff79c6">AND&lt;/span> am.amname &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;btree&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">LATERAL&lt;/span> pgstatindex(i.indexrelid) s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">ORDER&lt;/span> &lt;span style="color:#ff79c6">BY&lt;/span> s.avg_leaf_density &lt;span style="color:#ff79c6">ASC&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Two things in that query are load-bearing. The &lt;code>amname = 'btree'&lt;/code> filter is mandatory, because &lt;code>pgstatindex&lt;/code> is documented as returning &amp;ldquo;information about a B-tree index&amp;rdquo; and gives you an error or nonsense on anything else. The &lt;code>LIMIT 20&lt;/code> before the join matters because &lt;code>pgstatindex&lt;/code> reads the index in full, so this is a scan of every byte of the twenty largest indexes and not a catalog lookup. Run it on a replica where you can. The files are identical and the primary does not pay for it.&lt;/p>
&lt;p>What came back:&lt;/p>
&lt;pre tabindex="0">&lt;code> indexrelname | sz | density
------------------------------+---------+---------
batch_cache_period_idx | 4416 MB | 0.6
batch_result_ref_idx | 9528 MB | 11.2
batch_result_owner_idx | 7810 MB | 11.3
batch_result_uniq | 6912 MB | 12.8
batch_result_pkey | 3140 MB | 42.0
batch_result_link_idx | 980 MB | 88.6
&lt;/code>&lt;/pre>&lt;p>The bottom row is the control. One index on the same table sits at 88.6, which is where a b-tree lives when nothing pathological is happening to it, and it confirms the other five are not a measurement artifact. The top row is 4.4 GB of index structure at 0.6% density, pointing at a table with no rows in it.&lt;/p>
&lt;p>An index at 11% density is a filing cabinet with one sheet of paper in each drawer. The filing is correct and the lookups return the right answer. You just walk nine drawers to find one page, and the cabinet takes nine times the floor space it needs.&lt;/p>
&lt;h2 id="predicting-the-payoff-before-you-spend-the-maintenance-window">Predicting the payoff before you spend the maintenance window&lt;/h2>
&lt;p>Multiply current size by current density and divide by 90. Since a rebuild targets fillfactor 90 by definition, that ratio is the compaction you should expect, give or take fragmentation and the fixed overhead of a small index.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Index&lt;/th>
&lt;th>Size&lt;/th>
&lt;th>Density&lt;/th>
&lt;th>Predicted&lt;/th>
&lt;th>After rebuild&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>batch_cache_period_idx&lt;/code>&lt;/td>
&lt;td>4416 MB&lt;/td>
&lt;td>0.6%&lt;/td>
&lt;td>29 MB&lt;/td>
&lt;td>54 MB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>batch_result_ref_idx&lt;/code>&lt;/td>
&lt;td>9528 MB&lt;/td>
&lt;td>11.2%&lt;/td>
&lt;td>1186 MB&lt;/td>
&lt;td>1204 MB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>batch_result_owner_idx&lt;/code>&lt;/td>
&lt;td>7810 MB&lt;/td>
&lt;td>11.3%&lt;/td>
&lt;td>981 MB&lt;/td>
&lt;td>994 MB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>batch_result_uniq&lt;/code>&lt;/td>
&lt;td>6912 MB&lt;/td>
&lt;td>12.8%&lt;/td>
&lt;td>983 MB&lt;/td>
&lt;td>1002 MB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>batch_result_pkey&lt;/code>&lt;/td>
&lt;td>3140 MB&lt;/td>
&lt;td>42.0%&lt;/td>
&lt;td>1465 MB&lt;/td>
&lt;td>1480 MB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The formula lands within a couple of percent everywhere except the first row, and the first row is instructive. At 0.6% density the prediction is 29 MB and the result is 54 MB, because once the payload approaches nothing you are measuring the metapage, the root and a handful of leaves rather than data. The model is good in the range where the decision is actually hard.&lt;/p>
&lt;p>That is enough to walk into a change window with a number instead of a hope. Summed over both tables it predicted around 28 GB of recovery, which is the difference between a ticket for next quarter and a maintenance slot tonight.&lt;/p>
&lt;h2 id="reindex-not-vacuum-full">REINDEX, not VACUUM FULL&lt;/h2>
&lt;p>&lt;code>REINDEX&lt;/code> rebuilds one index at a time, so peak extra space is the size of one new index rather than one new table.&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">REINDEX&lt;/span> &lt;span style="color:#ff79c6">INDEX&lt;/span> &lt;span style="color:#ff79c6">&amp;lt;&lt;/span>name&lt;span style="color:#ff79c6">&amp;gt;&lt;/span>; &lt;span style="color:#6272a4">-- fast, takes ACCESS EXCLUSIVE
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span>&lt;span style="color:#ff79c6">REINDEX&lt;/span> &lt;span style="color:#ff79c6">INDEX&lt;/span> CONCURRENTLY &lt;span style="color:#ff79c6">&amp;lt;&lt;/span>name&lt;span style="color:#ff79c6">&amp;gt;&lt;/span>; &lt;span style="color:#6272a4">-- no write lock, slower, more WAL
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>REINDEX CONCURRENTLY&lt;/code> has been available &lt;a href="https://www.postgresql.org/docs/release/12.0/">since PostgreSQL 12&lt;/a>, which is why &lt;code>pg_repack&lt;/code> was not worth installing here. An external tool for a one-off operation that the server can do natively is extra supply chain for no gain.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Approach&lt;/th>
&lt;th>Lock&lt;/th>
&lt;th>Extra space needed&lt;/th>
&lt;th>Fixes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>VACUUM&lt;/code>&lt;/td>
&lt;td>None&lt;/td>
&lt;td>None&lt;/td>
&lt;td>Nothing on disk, marks space reusable&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>VACUUM (FULL, ANALYZE)&lt;/code>&lt;/td>
&lt;td>&lt;code>ACCESS EXCLUSIVE&lt;/code>&lt;/td>
&lt;td>Size of the whole table&lt;/td>
&lt;td>Heap and all its indexes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>REINDEX INDEX&lt;/code>&lt;/td>
&lt;td>&lt;code>ACCESS EXCLUSIVE&lt;/code> on that index&lt;/td>
&lt;td>Size of one index&lt;/td>
&lt;td>One index&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>REINDEX INDEX CONCURRENTLY&lt;/code>&lt;/td>
&lt;td>Reads and writes continue&lt;/td>
&lt;td>Size of one index&lt;/td>
&lt;td>One index&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The trade-off is narrow and worth stating outright. Plain &lt;code>REINDEX&lt;/code> is faster and has no failure debris, and on a staging box that is the obvious choice. On a primary serving traffic only &lt;code>CONCURRENTLY&lt;/code> is defensible, and you pay for it in runtime, in WAL volume, and in cleanup when it fails. If the heap is the bloated part, VACUUM FULL is right and REINDEX is the wrong answer. Check which one you have first.&lt;/p>
&lt;h2 id="the-zero-scan-index-that-was-holding-up-the-constraints">The zero-scan index that was holding up the constraints&lt;/h2>
&lt;p>While the rebuild plan was being written, the tempting side quest appeared: the hot table carried eight indexes and five of them showed &lt;code>idx_scan = 0&lt;/code>. Dropping five unused indexes would have solved the disk problem outright.&lt;/p>
&lt;p>Two of those five were unique. The &lt;a href="https://www.postgresql.org/docs/15/monitoring-stats.html">monitoring docs&lt;/a> define &lt;code>idx_scan&lt;/code> as the &amp;ldquo;Number of index scans initiated on this index&amp;rdquo;, and a uniqueness check on INSERT is not an index scan in that sense. It does not move the counter. Those two indexes were being consulted on every single insert into the table and their statistics said they had never been touched since the counters were last reset.&lt;/p>
&lt;p>Dropping them would have let duplicates into the results of a financial recalculation, silently, with no error at the time of the mistake. This was caught by a rule rather than by a metric: verify the business meaning of every index before dropping it. The metric did not know what the index was for.&lt;/p>
&lt;p>The query, with the filter that makes it safe:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">SELECT&lt;/span> i.relname &lt;span style="color:#ff79c6">AS&lt;/span> tbl, i.indexrelname &lt;span style="color:#ff79c6">AS&lt;/span> idx,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pg_size_pretty(pg_relation_size(i.indexrelid)) &lt;span style="color:#ff79c6">AS&lt;/span> sz, i.idx_scan
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">FROM&lt;/span> pg_stat_user_indexes i
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">JOIN&lt;/span> pg_index x &lt;span style="color:#ff79c6">ON&lt;/span> x.indexrelid &lt;span style="color:#ff79c6">=&lt;/span> i.indexrelid
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">WHERE&lt;/span> i.idx_scan &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#bd93f9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">AND&lt;/span> &lt;span style="color:#ff79c6">NOT&lt;/span> x.indisunique
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">AND&lt;/span> pg_relation_size(i.indexrelid) &lt;span style="color:#ff79c6">&amp;gt;&lt;/span> &lt;span style="color:#bd93f9">5&lt;/span> &lt;span style="color:#ff79c6">*&lt;/span> &lt;span style="color:#bd93f9">1024&lt;/span> &lt;span style="color:#ff79c6">*&lt;/span> &lt;span style="color:#bd93f9">1024&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">ORDER&lt;/span> &lt;span style="color:#ff79c6">BY&lt;/span> pg_relation_size(i.indexrelid) &lt;span style="color:#ff79c6">DESC&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">-- a zero means nothing without knowing the window it was counted over
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span>&lt;span style="color:#ff79c6">SELECT&lt;/span> stats_reset, now() &lt;span style="color:#ff79c6">-&lt;/span> stats_reset &lt;span style="color:#ff79c6">AS&lt;/span> window
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">FROM&lt;/span> pg_stat_database &lt;span style="color:#ff79c6">WHERE&lt;/span> datname &lt;span style="color:#ff79c6">=&lt;/span> current_database();
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Run the second query first. An &lt;code>idx_scan = 0&lt;/code> over a statistics window that was reset last Tuesday is not evidence of anything, and here the window was over half a year, which is what made the zeros worth investigating in the first place.&lt;/p>
&lt;h2 id="statistics-counters-live-on-each-node-separately">Statistics counters live on each node separately&lt;/h2>
&lt;p>A &lt;code>pg_stat_*&lt;/code> counter describes the node you read it from, not the cluster. Replicas keep their own, so an index that the primary has never scanned may be serving every read on the standby.&lt;/p>
&lt;p>I checked the primary and one standby taken from the inventory file, then had to redo the reasoning properly: the list of nodes has to come from &lt;code>pg_stat_replication&lt;/code> on the primary, not from an inventory that may be out of date. The conclusion held, but it had not been &lt;em>proved&lt;/em> until the database itself confirmed how many subscribers there were. On a two-node cluster that distinction is easy to wave away, and waving it away is exactly how a busy index gets dropped.&lt;/p>
&lt;h2 id="what-bites-you-after-the-decision-is-made">What bites you after the decision is made&lt;/h2>
&lt;p>Five things caught me on the way through, in rough order of how much time each cost.&lt;/p>
&lt;p>&lt;strong>&lt;code>_ccnew&lt;/code> and &lt;code>_ccold&lt;/code> mean opposite things.&lt;/strong> A failed &lt;code>REINDEX CONCURRENTLY&lt;/code> leaves an invalid index behind, and &lt;a href="https://www.postgresql.org/docs/15/sql-reindex.html">the REINDEX docs&lt;/a> distinguish the two cases carefully. A &lt;code>_ccnew&lt;/code> suffix is the transient index from a run that failed, so drop it and retry. A &lt;code>_ccold&lt;/code> suffix is the &lt;em>original&lt;/em>, which means the rebuild succeeded and only the cleanup failed, so drop it and you are done. The docs also note that &amp;ldquo;A nonzero number may be appended to the suffix of the invalid index names to keep them unique, like &lt;code>_ccnew1&lt;/code>, &lt;code>_ccold2&lt;/code>&amp;rdquo;. Any automation should check for leftovers as its first step:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">SELECT&lt;/span> &lt;span style="color:#ff79c6">c&lt;/span>.relname
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">FROM&lt;/span> pg_index i &lt;span style="color:#ff79c6">JOIN&lt;/span> pg_class &lt;span style="color:#ff79c6">c&lt;/span> &lt;span style="color:#ff79c6">ON&lt;/span> &lt;span style="color:#ff79c6">c&lt;/span>.oid &lt;span style="color:#ff79c6">=&lt;/span> i.indexrelid
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">WHERE&lt;/span> &lt;span style="color:#ff79c6">NOT&lt;/span> i.indisvalid;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;strong>A synchronous replica turns REINDEX into application latency.&lt;/strong> A rebuild generates WAL on the order of the size of the index it produces. With &lt;code>synchronous_commit = on&lt;/code>, every commit waits for the standby to confirm, and with &lt;code>wal_compression&lt;/code> off that whole volume crosses the network uncompressed. Rebuild one index at a time, off-peak, checking replication lag between steps.&lt;/p>
&lt;p>&lt;strong>Laravel wraps migrations in a transaction, and only on PostgreSQL.&lt;/strong> &lt;code>DROP INDEX CONCURRENTLY&lt;/code> inside a migration fails with &lt;code>cannot run inside a transaction block&lt;/code>, and nothing in the migration file explains why. The reason is three files deep in the framework. &lt;a href="https://github.com/laravel/framework/blob/11.x/src/Illuminate/Database/Migrations/Migration.php">&lt;code>Migration.php&lt;/code>&lt;/a> declares &lt;code>public $withinTransaction = true;&lt;/code>, &lt;a href="https://github.com/laravel/framework/blob/11.x/src/Illuminate/Database/Migrations/Migrator.php">&lt;code>Migrator.php&lt;/code>&lt;/a> wraps the call when &lt;code>supportsSchemaTransactions() &amp;amp;&amp;amp; $migration-&amp;gt;withinTransaction&lt;/code>, and &lt;a href="https://github.com/laravel/framework/blob/11.x/src/Illuminate/Database/Schema/Grammars/PostgresGrammar.php">&lt;code>PostgresGrammar.php&lt;/code>&lt;/a> sets &lt;code>protected $transactions = true;&lt;/code>. The MySQL grammar does not override it, so it inherits &lt;code>false&lt;/code> from the base class. The same migration passes on MySQL and fails on PostgreSQL. The fix is one line in the migration class:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-php" data-lang="php">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">public&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">$withinTransaction&lt;/span> &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#ff79c6">false&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>That property is not in the Laravel documentation. I found it by reading the source, which is generally the faster route once a framework behaviour has no documented name.&lt;/p>
&lt;p>&lt;strong>Indexes dropped by hand come back.&lt;/strong> If the drop happened as ad-hoc SQL while the declaration is still sitting in a migration or a test schema, the next deploy to a fresh environment restores it. Grep the repository for the index name before dropping anything, and do the drop as a migration.&lt;/p>
&lt;p>&lt;strong>Two indexes with different names can be the same index.&lt;/strong> Matching column lists is not enough to prove it, and differing column order in a UNIQUE constraint does not prove the opposite, because for uniqueness purposes the order of columns means nothing. Compare &lt;a href="https://www.postgresql.org/docs/15/catalog-pg-index.html">&lt;code>pg_index&lt;/code>&lt;/a> properly: &lt;code>indclass&lt;/code>, &lt;code>indcollation&lt;/code>, &lt;code>indoption&lt;/code>, &lt;code>indnullsnotdistinct&lt;/code>, &lt;code>indnkeyatts&lt;/code> to catch a hidden INCLUDE, and &lt;code>indpred&lt;/code> and &lt;code>indexprs&lt;/code> for partial and expression indexes. When every one of those matches, it is a duplicate, and you can drop the one with a billion scans on it: the planner moves to the twin without noticing.&lt;/p>
&lt;h2 id="what-it-bought">What it bought&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>Before&lt;/th>
&lt;th>After&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Disk used&lt;/td>
&lt;td>101 GB (81%)&lt;/td>
&lt;td>63 GB (50%)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Free space&lt;/td>
&lt;td>24 GB&lt;/td>
&lt;td>62 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PostgreSQL data directory&lt;/td>
&lt;td>62 GB&lt;/td>
&lt;td>33 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>batch_result&lt;/code> total&lt;/td>
&lt;td>35 GB&lt;/td>
&lt;td>11 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Its indexes&lt;/td>
&lt;td>30 GB&lt;/td>
&lt;td>6.4 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Density of the five worst&lt;/td>
&lt;td>0.6-42%&lt;/td>
&lt;td>89-93%&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>One detour on the way, for anyone who hits the same thing: after the rebuilds, &lt;code>pg_wal&lt;/code> had grown and &lt;code>df&lt;/code> had not moved as much as the arithmetic promised. Forcing a &lt;code>CHECKPOINT&lt;/code> did nothing, and it should not have. The directory had grown to the configured &lt;code>max_wal_size&lt;/code> ceiling, which is the setting doing its job rather than a leak, and PostgreSQL keeps recycled segments preallocated and shrinks back toward &lt;code>min_wal_size&lt;/code> gradually. Chasing those gigabytes is wasted time.&lt;/p>
&lt;p>The more interesting result was on the production cluster, where the same profile showed up on indexes that are actually hot:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Index&lt;/th>
&lt;th>Size&lt;/th>
&lt;th>Density&lt;/th>
&lt;th>Scans&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Tree-walk index on the hierarchy table&lt;/td>
&lt;td>1.4 GB&lt;/td>
&lt;td>14%&lt;/td>
&lt;td>hundreds of millions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unique on (period, user, sequence)&lt;/td>
&lt;td>10 GB&lt;/td>
&lt;td>15%&lt;/td>
&lt;td>a couple hundred thousand&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Primary key of the main table&lt;/td>
&lt;td>2.9 GB&lt;/td>
&lt;td>42%&lt;/td>
&lt;td>hundreds of millions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Composite index on order links&lt;/td>
&lt;td>12 GB&lt;/td>
&lt;td>71%&lt;/td>
&lt;td>hundreds of millions&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The first row is the one that changed how I think about this. It is a 1.4 GB index at 14% density taking hundreds of millions of scans, and every one of those scans reads pages that are one seventh full. Rebuilt, it lands near 220 MB and fits in cache whole. The disk saving is real but secondary. The alert said &amp;ldquo;disk&amp;rdquo;, and the actual finding was a read path doing seven times the I/O it needed to.&lt;/p>
&lt;h2 id="bottom-line">Bottom line&lt;/h2>
&lt;p>Monitor index density, not free disk space. By the time a disk alert fires, the indexes have been degrading for months and the query latency has been degrading with them, quietly, in a way no threshold is watching. A weekly &lt;code>avg_leaf_density&lt;/code> check over the twenty largest b-trees costs one scan on a replica and tells you months in advance.&lt;/p>
&lt;p>Then treat the rebuild as maintenance rather than an incident response. B-tree pages not merging is not a bug and not a missing feature: it is a documented trade that buys you a VACUUM which never locks the table. Workloads that delete most of each key range pay for that trade, and the payment is a periodic REINDEX. Fix the workload if you can, because &lt;code>TRUNCATE&lt;/code> instead of &lt;code>DELETE&lt;/code> on a cache table removes a whole class of this in one line, and schedule the rebuild if you cannot.&lt;/p></content:encoded></item><item><title>Mass segfaults on a ZFS host: not bad RAM, an unsigned underflow in zfs_fillpage()</title><link>https://bitpage.me/incidents/zfs-fillpage-underflow-segfaults-systemd-freeze/</link><pubDate>Sun, 06 Sep 2026 04:15:00 +0000</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.me/incidents/zfs-fillpage-underflow-segfaults-systemd-freeze/</guid><description>A host that answers on no port while the kernel keeps logging is not failing memory. In OpenZFS before 2.4.4 and 2.3.9, zfs_fillpage() computes io_len = i_size - io_off in unsigned types: race an mmap read against an in-place truncate and the length underflows, so dmu_read() zero-fills physical memory far past the page. Fixed on 21 August 2026 in 2.4.4 and 2.3.9 — but not in 2.2.11, released the same minute.</description><content:encoded>&lt;p>&lt;strong>Short answer:&lt;/strong> unrelated processes segfaulting all at once on a ZFS host, plus &lt;code>systemd[1]: Freezing execution&lt;/code> in the journal, is kernel arithmetic rather than failing memory. In OpenZFS before 2.4.4 and 2.3.9, &lt;code>zfs_fillpage()&lt;/code> computed the read length as &lt;code>io_len = i_size - io_off&lt;/code> on unsigned types. Truncate a file in place at the moment an mmap page is faulted in, &lt;code>i_size&lt;/code> drops below &lt;code>io_off&lt;/code>, the difference underflows to near 2⁶⁴, and &lt;code>dmu_read()&lt;/code> zero-fills physical memory far past the page it was asked for. The machine survives that. What finishes it off is systemd, whose &lt;code>CrashAction&lt;/code> defaults to &lt;code>freeze&lt;/code>.&lt;/p>
&lt;p>From outside it looked like a powered-off box: no SSH, no HTTP, no hypervisor UI. A hard reset from the provider&amp;rsquo;s panel brought it back, which left the question nobody enjoys: what that was, and whether it happens again tomorrow.&lt;/p>
&lt;p>An hour of digging produced two answers instead of one. The ZFS bug is the trigger. The price of the failure was set by a systemd default.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Cause:&lt;/strong> unsigned underflow in &lt;code>zfs_fillpage()&lt;/code> when an mmap read races an in-place truncate. The kernel zero-fills physical pages belonging to whatever else was there, so the victims are random processes.&lt;/li>
&lt;li>&lt;strong>Fixed in:&lt;/strong> OpenZFS &lt;a href="https://github.com/openzfs/zfs/releases/tag/zfs-2.4.4">2.4.4&lt;/a> and &lt;a href="https://github.com/openzfs/zfs/releases/tag/zfs-2.3.9">2.3.9&lt;/a>, both published 21 August 2026. On Proxmox, &lt;code>zfsutils-linux&lt;/code> 2.4.4-pve1.&lt;/li>
&lt;li>&lt;strong>Not fixed in:&lt;/strong> &lt;a href="https://github.com/openzfs/zfs/releases/tag/zfs-2.2.11">2.2.11&lt;/a>, published the same minute as the other two. Verified by reading the code in the tag, not the release notes.&lt;/li>
&lt;li>&lt;strong>Why the outage runs for hours:&lt;/strong> &lt;code>CrashAction=freeze&lt;/code> is the systemd default. A crashed PID 1 hangs forever, nothing restarts services, &lt;code>systemctl&lt;/code> blocks, sshd dies later with nobody to bring it back.&lt;/li>
&lt;li>&lt;strong>One-minute test against bad RAM:&lt;/strong> ECC counters in &lt;code>/sys/devices/system/edac/mc/mc0/&lt;/code> at zero and no &lt;code>Machine Check&lt;/code> in the journal means the DIMMs are not your problem.&lt;/li>
&lt;li>&lt;strong>Tell in the dump:&lt;/strong> the length register of &lt;code>memset&lt;/code> is not garbage, it is a tidy negative number, exactly minus another register. That is a size computation gone wrong, not failing hardware.&lt;/li>
&lt;li>&lt;strong>One-line mitigation:&lt;/strong> &lt;code>CrashAction=reboot&lt;/code> in a drop-in turns a repeat from an overnight outage into a 10-second one.&lt;/li>
&lt;/ul>
&lt;h2 id="why-the-machine-looks-powered-off-when-it-is-not">Why the machine looks powered off when it is not&lt;/h2>
&lt;p>It looks identical to a powered-off machine from outside, which is why the first hypothesis is always wrong. No port answers, the provider&amp;rsquo;s panel reports power as normal, and support has nothing to add. The obvious theory is that the box went down, so you start looking for the cause of a shutdown: an ACPI event, a watchdog, a power dip in the rack.&lt;/p>
&lt;p>The journal kills that theory. &lt;code>journalctl --list-boots&lt;/code> shows the previous boot, and its entries run right up to a point a couple of minutes before the new boot began, which is the board&amp;rsquo;s POST. The last line before the reset was an nginx worker dying:&lt;/p>
&lt;pre tabindex="0">&lt;code>nginx[…]: [alert] …: worker process … exited on signal 11
&lt;/code>&lt;/pre>&lt;p>The machine never powered off. It ran the whole time and kept writing to the journal that it was in trouble. Nobody outside could hear it.&lt;/p>
&lt;p>That yields a second, nastier fact. The moment things actually broke sits much earlier than the &amp;ldquo;crash time&amp;rdquo; you get from &lt;code>last&lt;/code>. &lt;code>last -x&lt;/code> will show &lt;code>crash&lt;/code> without saying when it started. The real start is the first segfault in the avalanche, and it can be a whole night before the reset.&lt;/p>
&lt;h2 id="why-it-is-not-bad-ram-even-though-it-looks-exactly-like-bad-ram">Why it is not bad RAM, even though it looks exactly like bad RAM&lt;/h2>
&lt;p>Simultaneous segfaults across unrelated processes is the classic signature of a failing DIMM, and the reflex it triggers is the wrong one. Before you schedule downtime for memtest86+, spend a minute on two counters:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>cat /sys/devices/system/edac/mc/mc0/ce_count &lt;span style="color:#6272a4"># corrected errors&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>cat /sys/devices/system/edac/mc/mc0/ue_count &lt;span style="color:#6272a4"># uncorrected&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>journalctl -b -1 -k | grep -i &lt;span style="color:#f1fa8c">&amp;#34;machine check&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Zeros in all three places on an ECC system mean memory as a subsystem is healthy. A single flipped bit would have been caught and logged. While you are there, confirm ECC is actually active rather than merely printed on the invoice:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>dmidecode -t memory | grep -E &lt;span style="color:#f1fa8c">&amp;#34;Error Correction Type|Total Width|Data Width&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>Total Width&lt;/code> needs to be 8 bits wider than &lt;code>Data Width&lt;/code>, 72 against 64, because those are the physical bits doing the correction. Equal widths mean you have no ECC, whatever the spec sheet claims.&lt;/p>
&lt;p>That minute saves hours of downtime spent diagnosing the wrong subsystem. ECC protects against flipped bits, not against code that walks the ordinary execution path and writes zeros into the wrong place.&lt;/p>
&lt;h2 id="four-dead-ends-all-through-the-same-function">Four dead ends, all through the same function&lt;/h2>
&lt;p>Every hypothesis that came from searching the symptom was wrong, and they cost more time than the answer did. Writing them out because each one closes a direction: if your stack looks like this, you can skip these branches.&lt;/p>
&lt;p>&lt;strong>Dead end 1: a Direct IO race.&lt;/strong> The first sensible search on the call stack landed on a real race in OpenZFS, where a concurrent &lt;code>O_DIRECT&lt;/code> write can set &lt;code>db-&amp;gt;db_data = NULL&lt;/code> and &lt;code>dmu_read_impl()&lt;/code> then faults on the dereference. The function names matched mine exactly. Checking the tunable made it look even better:&lt;/p>
&lt;pre tabindex="0">&lt;code># cat /sys/module/zfs/parameters/zfs_dio_enabled
1
&lt;/code>&lt;/pre>&lt;p>I said out loud that this was the leading theory. The registers disagreed: what I had was not a NULL dereference but a &lt;code>memset&lt;/code> with a sensible destination and an absurd length. Different mechanism. I should have checked that in the first minute, since the registers were on screen the whole time.&lt;/p>
&lt;p>&lt;strong>Dead end 2: SLUB corruption on encrypted ZFS.&lt;/strong> &lt;a href="https://github.com/openzfs/zfs/issues/18427">Issue #18427&lt;/a> describes my symptom nearly word for word — mass userspace segfaults, reproducible across several releases. One command ended it:&lt;/p>
&lt;pre tabindex="0">&lt;code># zfs get -r encryption rpool
NAME PROPERTY VALUE SOURCE
rpool encryption off default
&lt;/code>&lt;/pre>&lt;p>&lt;strong>Dead end 3: an already-fixed regression in &lt;code>dmu_read_impl&lt;/code>.&lt;/strong> &lt;a href="https://github.com/openzfs/zfs/issues/17886">Issue #17886&lt;/a> is an oops in the same function, also with memory clobbered by a bad length, closed by &lt;a href="https://github.com/openzfs/zfs/pull/17915">PR #17915&lt;/a>. It read like &amp;ldquo;known bug, fix exists, go upgrade.&amp;rdquo; Then I read the PR: it restores lost parentheses in the &lt;code>BRT_RANGESIZE_TO_NBLOCKS()&lt;/code> macro, and the author states the blast radius plainly — &amp;ldquo;this could cause small memory corruptions for vdevs bigger than 64TB+&amp;rdquo;. A web-workload pool of a few terabytes is an order of magnitude short of that threshold. The stack in that bug also runs through &lt;code>brt_load()&lt;/code> during pool import rather than through mmap under load. Same crash site, different bug.&lt;/p>
&lt;p>&lt;strong>Dead end 4: &amp;ldquo;the server was off for hours.&amp;rdquo;&lt;/strong> Covered above. It died against a continuous journal from the previous boot.&lt;/p>
&lt;p>The unpleasant summary: all four trails ran through &lt;code>dmu_read_impl&lt;/code>. It is a popular function, &amp;ldquo;memory corruption&amp;rdquo; is far too general a symptom, and searching by symptom in that situation returns an endless feed of other people&amp;rsquo;s misfortunes. The break came from arithmetic, not from searching.&lt;/p>
&lt;h2 id="the-giveaway-the-memset-length-is-exactly-minus-another-register">The giveaway: the memset length is exactly minus another register&lt;/h2>
&lt;p>The whole answer sits in the oops dump, and it only needs counting. Trimmed to what matters:&lt;/p>
&lt;pre tabindex="0">&lt;code>BUG: unable to handle page fault for address: ffff8983e8400000
#PF: supervisor write access in kernel mode
#PF: error_code(0x0003) - permissions violation
Comm: php-fpm
RIP: 0010:memset+0xb/0x20
RAX: 0000000000020000 RSI: 0000000000000000 RDI: ffff8983e8400000
RDX: ffffffffff7a4000 R15: 000000000085c000
Call Trace:
? dmu_read_impl+0x21e/0x230 [zfs]
dmu_read [zfs]
zfs_fillpage [zfs]
zfs_getpage [zfs]
zpl_read_folio [zfs]
filemap_read_folio
filemap_fault
__do_fault
handle_mm_fault
&lt;/code>&lt;/pre>&lt;p>Read the &lt;code>memset&lt;/code> arguments. Under the x86-64 SysV ABI those are &lt;code>RDI&lt;/code> for destination, &lt;code>RSI&lt;/code> for the fill byte, &lt;code>RDX&lt;/code> for the count.&lt;/p>
&lt;p>&lt;code>RSI = 0&lt;/code>, so it is zero-filling. &lt;code>RDX = 0xffffffffff7a4000&lt;/code> looks like garbage at a glance. Now take &lt;code>R15 = 0x85c000 = 8,765,440&lt;/code>. This is the part worth slowing down for: &lt;code>0xffffffffff7a4000&lt;/code> is exactly −8,765,440 in two&amp;rsquo;s complement. Not a random value, not a clobbered register. A tidy negative number.&lt;/p>
&lt;p>A negative length does not appear from nowhere. It means something subtracted a larger number from a smaller one in unsigned arithmetic. The culprit is not whoever corrupted memory, it is whoever computed the size.&lt;/p>
&lt;p>&lt;code>RAX = 0x20000&lt;/code> fits the picture too: 128 KiB, the default ZFS &lt;code>recordsize&lt;/code>. An ordinary record read whose length went backwards.&lt;/p>
&lt;p>With that hypothesis the search stopped being a search by symptom and became a search by function name plus exact version. The right issue was the first result.&lt;/p>
&lt;h2 id="what-is-broken-in-zfs-an-invariant-that-does-not-survive-the-release-build">What is broken in ZFS: an invariant that does not survive the release build&lt;/h2>
&lt;p>Two lines in &lt;code>zfs_fillpage()&lt;/code> do the damage, and the guard in front of them does not exist in release builds. Here is the code &lt;a href="https://github.com/openzfs/zfs/blob/zfs-2.4.3/module/os/linux/zfs/zfs_vnops_os.c">from the 2.4.3 tag&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd">loff_t&lt;/span> i_size &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#50fa7b">i_size_read&lt;/span>(ip);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd">u_offset_t&lt;/span> io_off &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#50fa7b">page_offset&lt;/span>(pp);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd">size_t&lt;/span> io_len &lt;span style="color:#ff79c6">=&lt;/span> PAGE_SIZE;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#50fa7b">ASSERT3U&lt;/span>(io_off, &lt;span style="color:#ff79c6">&amp;lt;&lt;/span>, i_size);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">if&lt;/span> (io_off &lt;span style="color:#ff79c6">+&lt;/span> io_len &lt;span style="color:#ff79c6">&amp;gt;&lt;/span> i_size)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> io_len &lt;span style="color:#ff79c6">=&lt;/span> i_size &lt;span style="color:#ff79c6">-&lt;/span> io_off;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The invariant is stated in plain sight: the page offset must be smaller than the file size. One line down, a subtraction is built on it. All honest, right up until you open the definition of &lt;code>ASSERT3U&lt;/code> for a non-debug build in &lt;a href="https://github.com/openzfs/zfs/blob/zfs-2.4.3/include/os/linux/spl/sys/debug.h">&lt;code>include/os/linux/spl/sys/debug.h&lt;/code>&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">/*
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * Debugging disabled (--disable-debug)
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">#ifdef NDEBUG
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">#define ASSERT3U(x, y, z) \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6"> ((void) sizeof ((uintptr_t)(x)), (void) sizeof ((uintptr_t)(z)))
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Two &lt;code>sizeof&lt;/code> expressions that check nothing. The header says so directly: assertions are &amp;ldquo;compiled out when NDEBUG is defined, this is the default behavior for the SPL.&amp;rdquo; Distributions, Proxmox included, build ZFS that way. So the invariant is documented, and unenforced exactly where enforcement would have mattered.&lt;/p>
&lt;p>Truncating the file in place while a page is being faulted in breaks that invariant. &lt;code>i_size&lt;/code> drops below &lt;code>io_off&lt;/code>, the subtraction underflows, and &lt;code>io_len&lt;/code> becomes a number close to 2⁶⁴. &lt;code>dmu_read()&lt;/code> takes it at face value and fills.&lt;/p>
&lt;p>The fix adds the runtime check the assertion pretended to be. From the &lt;a href="https://github.com/openzfs/zfs/blob/zfs-2.4.4/module/os/linux/zfs/zfs_vnops_os.c">2.4.4 tag&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6272a4">/*
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * The page may be faulted in after the file has been truncated.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * There is no data to read; just zero-fill the page.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span> (io_off &lt;span style="color:#ff79c6">&amp;gt;=&lt;/span> i_size) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#8be9fd">void&lt;/span> &lt;span style="color:#ff79c6">*&lt;/span>zva &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#50fa7b">kmap&lt;/span>(pp);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">memset&lt;/span>(zva, &lt;span style="color:#bd93f9">0&lt;/span>, PAGE_SIZE);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">kunmap&lt;/span>(pp);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">ClearPageError&lt;/span>(pp);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">SetPageUptodate&lt;/span>(pp);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">return&lt;/span> (&lt;span style="color:#bd93f9">0&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The commit is &lt;a href="https://github.com/openzfs/zfs/commit/223b8bc446851e5e796e5446ac24d03bbf468f43">&lt;code>223b8bc446&lt;/code>&lt;/a>, &amp;ldquo;linux: handle mmap read beyond file size&amp;rdquo;, merged 30 June 2026 by Brian Behlendorf. Its trailers are worth reading: &lt;code>Reported-by: Iliya Polihronov (@vnsavage) (Automattic)&lt;/code>, &lt;code>Reviewed-by: Alexander Motin&lt;/code>. WordPress.com hit this independently, which fits — it is a PHP-shaped bug. &lt;code>readfile()&lt;/code> on a cache file that another request is rewriting in place is the exact race.&lt;/p>
&lt;p>The crashing process in my dump is &lt;code>php-fpm&lt;/code>, running under an unprivileged LXC. Its uid maps to 82 inside the container, which is &lt;code>www-data&lt;/code> in Alpine, so it was PHP from an Alpine image in Docker inside LXC, on a Debian host. Four layers down from the kernel, and none of them at fault.&lt;/p>
&lt;h2 id="which-versions-are-actually-fixed-and-which-release-from-the-same-day-is-not">Which versions are actually fixed, and which release from the same day is not&lt;/h2>
&lt;p>Three OpenZFS releases went out on 21 August 2026, and the fix is in two of them. Checking release notes is not enough here, so I read &lt;code>zfs_vnops_os.c&lt;/code> in each tag:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Branch&lt;/th>
&lt;th>Release&lt;/th>
&lt;th>Published&lt;/th>
&lt;th>&lt;code>zfs_fillpage()&lt;/code> guarded&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>2.4&lt;/td>
&lt;td>zfs-2.4.4&lt;/td>
&lt;td>2026-08-21&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2.3&lt;/td>
&lt;td>zfs-2.3.9&lt;/td>
&lt;td>2026-08-21&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2.2&lt;/td>
&lt;td>zfs-2.2.11&lt;/td>
&lt;td>2026-08-21&lt;/td>
&lt;td>&lt;strong>no&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The three tags were published within about a minute of each other. Release notes for 2.4.4 and 2.3.9 both carry the line &lt;code>linux: handle mmap read beyond file size #18715&lt;/code>; the notes for 2.2.11 do not. In the 2.2.11 tree, &lt;code>ASSERT3U(io_off, &amp;lt;, i_size);&lt;/code> and the bare subtraction are still there. The same holds in the 2.1 branch, so this is an old bug rather than a fresh regression, and downgrading to an older kernel does not help.&lt;/p>
&lt;p>Check your own build rather than trusting a version number:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>zfs version
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># then, for the tag you are on:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>curl -sf &lt;span style="color:#f1fa8c">&amp;#34;https://raw.githubusercontent.com/openzfs/zfs/zfs-&lt;/span>&lt;span style="color:#ff79c6">$(&lt;/span>zfs version | head -1 | cut -d- -f2 | cut -d_ -f1&lt;span style="color:#ff79c6">)&lt;/span>&lt;span style="color:#f1fa8c">/module/os/linux/zfs/zfs_vnops_os.c&amp;#34;&lt;/span> &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> | grep -c &lt;span style="color:#f1fa8c">&amp;#39;io_off &amp;gt;= i_size&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Nonzero means the guard is in. Use &lt;code>curl -sf&lt;/code>: without &lt;code>-f&lt;/code>, a nonexistent tag returns a 404 page that greps to &amp;ldquo;0 matches&amp;rdquo;, which is indistinguishable from &amp;ldquo;the fix is missing.&amp;rdquo; I nearly recorded that as a result once.&lt;/p>
&lt;p>On Proxmox the fixed package is already in the ordinary repository. In &lt;code>pve-no-subscription&lt;/code> for trixie, &lt;code>zfsutils-linux&lt;/code> is at 2.4.4-pve1, so this is a plain &lt;code>apt&lt;/code> upgrade plus a reboot rather than anything exotic.&lt;/p>
&lt;p>Upstream the story is not closed. &lt;a href="https://github.com/openzfs/zfs/issues/18787">Issue #18787&lt;/a>, opened 12 July 2026 by the Incus maintainer, who was losing hosts full of LAMP containers, is still open today even though the fix landed two weeks before it was filed. Track the PR number, not the ticket state. A &lt;a href="https://github.com/openzfs/zfs/pull/18824">regression test&lt;/a> followed on 18 July.&lt;/p>
&lt;h2 id="why-the-outage-lasts-hours-crashaction-defaults-to-freeze">Why the outage lasts hours: CrashAction defaults to freeze&lt;/h2>
&lt;p>The bug corrupts memory in milliseconds; the outage lasts all night because of a systemd default. Milliseconds after the corruption started, PID 1 caught its own crash:&lt;/p>
&lt;pre tabindex="0">&lt;code>systemd[1]: Caught &amp;lt;ABRT&amp;gt;, from our own process.
systemd[1]: Caught &amp;lt;ABRT&amp;gt;, dumped core as pid …
systemd[1]: Freezing execution.
&lt;/code>&lt;/pre>&lt;p>&lt;code>CrashAction=&lt;/code> arrived in systemd 256, alongside the &lt;code>systemd.crash_action=&lt;/code> kernel command line option. The manual is unambiguous about the default:&lt;/p>
&lt;blockquote>
&lt;p>Takes one of &amp;ldquo;freeze&amp;rdquo;, &amp;ldquo;reboot&amp;rdquo; or &amp;ldquo;poweroff&amp;rdquo;. Defaults to &amp;ldquo;freeze&amp;rdquo;. If set to &amp;ldquo;freeze&amp;rdquo;, the system will hang indefinitely when the system manager (PID 1) crashes. If set to &amp;ldquo;reboot&amp;rdquo;, the system manager (PID 1) will reboot the machine automatically when it crashes, after a 10s delay.&lt;/p>
&lt;/blockquote>
&lt;p>Freezing makes sense on a desktop, or anywhere an operator is standing next to the console and wants the corpse preserved. On rented hardware in someone else&amp;rsquo;s data centre it means &amp;ldquo;hang forever and wait for a human to notice.&amp;rdquo;&lt;/p>
&lt;p>With PID 1 frozen the system loses the ability to repair itself. Nothing restarts crashed services, &lt;code>systemctl&lt;/code> blocks, a clean shutdown is impossible. sshd went next, and after its last line there is not one further mention of it in the journal: no login attempts, no refusals. Port 22 simply had nobody listening. nginx meanwhile kept crashing at the same instruction pointer, tens of thousands of times, roughly twice a second, all night. The master was alive and forking workers; each worker inherited poisoned memory and died at once. A restart would have fixed it, and there was no one left to issue one.&lt;/p>
&lt;p>The mitigation is one drop-in file:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-ini" data-lang="ini">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># /etc/systemd/system.conf.d/10-crash-reboot.conf&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">[Manager]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#50fa7b">CrashAction&lt;/span>&lt;span style="color:#ff79c6">=&lt;/span>&lt;span style="color:#f1fa8c">reboot&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>systemd-analyze cat-config systemd/system.conf &lt;span style="color:#6272a4"># inspect before applying&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>systemctl daemon-reexec &lt;span style="color:#6272a4"># apply to the running PID 1&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>A repeat now costs ten seconds instead of a night. That is the whole trade: I cannot remove the bug from a release that does not contain the fix, so I bought recovery time instead of prevention.&lt;/p>
&lt;h2 id="side-gotchas-worth-stealing">Side gotchas worth stealing&lt;/h2>
&lt;p>Two smaller findings came out of the same morning, and both are worth checking on any Docker host.&lt;/p>
&lt;p>&lt;strong>A &lt;code>restart: on-failure&lt;/code> policy survives a crash but may not survive a clean reboot.&lt;/strong> The containers came back after the hard reset because their processes were killed by SIGKILL, so the exit code was nonzero and the policy fired. On a graceful reboot dockerd sends SIGTERM, and anything that exits 0 (nginx does) is not a failure, so &lt;code>on-failure&lt;/code> leaves it down. The configuration survives the disaster and can fail the maintenance window. Switching everything to &lt;code>unless-stopped&lt;/code> with &lt;code>docker update&lt;/code> fixes it live, no recreation and no downtime.&lt;/p>
&lt;p>&lt;strong>A container with no &lt;code>restart:&lt;/code> line at all defaults to &lt;code>no&lt;/code>.&lt;/strong> One monitoring container was in that state, so after every reboot it stayed down, and the proxy that depended on it spun through dozens of restarts inside half an hour with &lt;code>host not found in upstream&lt;/code>. &lt;code>depends_on&lt;/code> does not help here: it orders &lt;code>docker compose up&lt;/code> and has no effect when the daemon starts containers on boot.&lt;/p>
&lt;h2 id="what-to-do-right-now">What to do right now&lt;/h2>
&lt;ol>
&lt;li>Check whether your ZFS build carries the guard: &lt;code>zfs version&lt;/code>, then look for &lt;code>io_off &amp;gt;= i_size&lt;/code> in &lt;code>zfs_fillpage()&lt;/code> for that tag. Anything on 2.2.x or 2.1.x is exposed, including 2.2.11 from 21 August 2026.&lt;/li>
&lt;li>Upgrade to 2.4.4 or 2.3.9. On Proxmox trixie that is &lt;code>zfsutils-linux&lt;/code> 2.4.4-pve1 from &lt;code>pve-no-subscription&lt;/code>, then a reboot into the new module.&lt;/li>
&lt;li>If you cannot upgrade yet, cut the recovery time: drop in &lt;code>CrashAction=reboot&lt;/code> and run &lt;code>systemctl daemon-reexec&lt;/code>. Ten seconds against an open-ended hang.&lt;/li>
&lt;li>Add an availability check from outside the host. Mine was found by a person arriving at work, which is a bigger hole than the kernel bug.&lt;/li>
&lt;li>Audit Docker restart policies: &lt;code>docker inspect -f '{{.Name}} {{.HostConfig.RestartPolicy.Name}}' $(docker ps -aq)&lt;/code>. Anything showing &lt;code>no&lt;/code> or &lt;code>on-failure&lt;/code> is a machine that may not come back from a planned reboot.&lt;/li>
&lt;/ol>
&lt;h2 id="bottom-line">Bottom line&lt;/h2>
&lt;p>Three layers of blame, and you can only fix two of them. The ZFS underflow is upstream&amp;rsquo;s to fix, and as of today it is fixed in 2.4.4 and 2.3.9 while 2.2.11 from the same day still carries it. The systemd default turned a memory-corruption event into an overnight outage, and that costs one line of configuration to change. The missing availability check is what stretched the outage until morning, and that one is nobody&amp;rsquo;s fault but mine.&lt;/p>
&lt;p>The transferable part is the diagnostic move. When the kernel faults inside &lt;code>memset&lt;/code> or &lt;code>memcpy&lt;/code>, look at the length register before you look at anything else, and check whether it is a tidy negative number. If it is, stop investigating the hardware: someone computed a size wrong, and the fastest path to the answer is the function names in the stack plus your exact version, not the symptom.&lt;/p>
&lt;p>Primary sources: &lt;a href="https://github.com/openzfs/zfs/commit/223b8bc446851e5e796e5446ac24d03bbf468f43">commit 223b8bc446&lt;/a> and &lt;a href="https://github.com/openzfs/zfs/pull/18715">PR #18715&lt;/a> in OpenZFS, &lt;a href="https://github.com/openzfs/zfs/issues/18787">issue #18787&lt;/a>, the &lt;a href="https://github.com/openzfs/zfs/releases/tag/zfs-2.4.4">zfs-2.4.4 release&lt;/a>, and &lt;a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.html">systemd(1)&lt;/a> for &lt;code>systemd.crash_action=&lt;/code>.&lt;/p></content:encoded></item><item><title>PostgreSQL conflict with recovery: why both standard fixes make it worse under Patroni</title><link>https://bitpage.me/databases/postgres-conflict-with-recovery-patroni-haproxy/</link><pubDate>Thu, 06 Aug 2026 07:31:00 +0000</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.me/databases/postgres-conflict-with-recovery-patroni-haproxy/</guid><description>With replication slots enabled, hot_standby_feedback moves the bloat risk onto the primary; raising max_standby_streaming_delay feeds the HAProxy check that ejects the replica. Two hundred cancelled queries and thirty-odd replica ejections looked like one problem, but barely one in twenty lined up.</description><content:encoded>&lt;p>&lt;strong>Short answer:&lt;/strong> Both top search results for &lt;code>canceling statement due to conflict with recovery&lt;/code> are wrong on a Patroni cluster with replication slots and one replica. &lt;code>hot_standby_feedback=on&lt;/code> parks the xmin horizon inside the slot, where it survives the replica&amp;rsquo;s death and bloats the primary; raising &lt;code>max_standby_streaming_delay&lt;/code> converts held-back replay into the exact lag your load balancer ejects the replica for. What I shipped instead was removing &lt;code>on-marked-down shutdown-sessions&lt;/code> from the read backend, after measuring that replica lag runs in single-digit milliseconds at the median against a health-check threshold of 10 MB.&lt;/p>
&lt;p>The cluster: one primary and one replica under Patroni 3.0.2 with etcd, PostgreSQL 15, synchronous replication, &lt;code>use_slots: true&lt;/code>. Web requests from PHP-FPM read through HAProxy 2.4.30 against the replica; CLI work (queues, scheduler) goes to the primary. Once every quarter hour a cron job wipes and rebuilds a large table in full, and it belongs to someone else&amp;rsquo;s business logic, so it is not negotiable. The application was catching &lt;code>SQLSTATE[40001]&lt;/code>, and a developer had already written an interceptor at the &lt;code>PostgresConnection&lt;/code> level that retries the cancelled read on the primary. The question I was handed was whether he&amp;rsquo;d done it right. That took a day to answer and turned out to be about something else entirely.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Fix:&lt;/strong> drop &lt;code>on-marked-down shutdown-sessions&lt;/code> from the read backend — it was killing dozens of live sessions per ejection, and the application does not see &lt;code>40001&lt;/code> for those, it sees a dead socket.&lt;/li>
&lt;li>&lt;strong>Don&amp;rsquo;t:&lt;/strong> &lt;code>hot_standby_feedback=on&lt;/code> with replication slots and a single replica — the xmin outlives the replica, so the failure mode moves from &amp;ldquo;a report dies&amp;rdquo; to &amp;ldquo;the primary fills its disk.&amp;rdquo;&lt;/li>
&lt;li>&lt;strong>Don&amp;rsquo;t:&lt;/strong> raise &lt;code>max_standby_streaming_delay&lt;/code> when the load balancer health-checks replayed position — you pay for it with fan-out read outages instead of a couple of dozen cancelled queries a day.&lt;/li>
&lt;li>&lt;strong>Measure:&lt;/strong> &lt;code>replay_lag&lt;/code> sampled from the primary once a second for twenty minutes. Median in single-digit milliseconds, p95 in tens, worst case a couple of seconds — against a byte threshold that a ~170 MB burst clears in a few seconds.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> Patroni&amp;rsquo;s &lt;code>lag=&lt;/code> parameter is bytes-only. A requirement stated in seconds cannot be expressed in it at all.&lt;/li>
&lt;/ul>
&lt;h2 id="why-hot_standby_feedback-is-the-wrong-first-move-with-replication-slots">Why hot_standby_feedback is the wrong first move with replication slots&lt;/h2>
&lt;p>Replication slots change what &lt;code>hot_standby_feedback=on&lt;/code> costs you, because the xmin stops being tied to a live connection. The PostgreSQL 15 documentation is upfront about the general risk: the parameter &amp;ldquo;can be used to eliminate query cancels caused by cleanup records, but can cause database bloat on the primary for some workloads&amp;rdquo; (&lt;a href="https://www.postgresql.org/docs/15/runtime-config-replication.html">runtime-config-replication&lt;/a>). That sentence is what every answer on the internet is implicitly waving away when it recommends the setting.&lt;/p>
&lt;p>The part that applies specifically here is one section over, in the description of slots: &amp;ldquo;Replication slots provide an automated way to ensure that the primary does not remove WAL segments until they have been received by all standbys, and that the primary does not remove rows which could cause a recovery conflict even when the standby is disconnected&amp;rdquo; (&lt;a href="https://www.postgresql.org/docs/15/warm-standby.html">warm-standby&lt;/a>).&lt;/p>
&lt;p>Read that as an operator rather than as a feature description. Without slots, feedback xmin lives as long as the session does; kill the replica and the primary is free again. With slots, the xmin settles into &lt;code>pg_replication_slots.xmin&lt;/code> and stays there while the replica is gone. There&amp;rsquo;s one replica in this cluster and no spare. Turning the setting on would mean any replica outage becomes unbounded bloat on the primary, and I&amp;rsquo;d have traded a couple of dozen cancelled read queries a day for a way to take down the whole database. That one didn&amp;rsquo;t need a benchmark to reject.&lt;/p>
&lt;h2 id="why-raising-max_standby_streaming_delay-feeds-the-flap">Why raising max_standby_streaming_delay feeds the flap&lt;/h2>
&lt;p>Delaying WAL replay is only free if nothing downstream measures replay position, and on this cluster the load balancer does exactly that. &lt;code>max_standby_streaming_delay&lt;/code> is the second-most-popular answer, and on paper it is the polite one: instead of killing the query, hold back the apply. The docs describe it as &amp;ldquo;the maximum total time allowed to apply WAL data once it has been received from the primary server,&amp;rdquo; defaulting to 30 seconds (&lt;a href="https://www.postgresql.org/docs/15/runtime-config-replication.html">runtime-config-replication&lt;/a>). The cost stays on the replica, which is what makes it look safe.&lt;/p>
&lt;p>It isn&amp;rsquo;t safe here. HAProxy&amp;rsquo;s health check calls Patroni&amp;rsquo;s &lt;code>/replica?lag=&lt;/code>, and Patroni computes lag from the &lt;em>replayed&lt;/em> position. Held-back replay is, by definition, replay lag. Push the parameter from 30 s to 300 s and every conflict-heavy moment turns into a five-minute-wide window where the balancer can decide the replica is stale and pull it from rotation. The outcome would have been strictly worse than the problem: instead of a couple of dozen cancelled queries per day, fan-out outages across all reads.&lt;/p>
&lt;p>The full set of candidates, and why each one died:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Candidate&lt;/th>
&lt;th>Where the cost lands&lt;/th>
&lt;th>Why it was rejected&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>hot_standby_feedback=on&lt;/code>&lt;/td>
&lt;td>Primary (bloat)&lt;/td>
&lt;td>With slots, xmin survives replica downtime; single replica, no spare&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>max_standby_streaming_delay&lt;/code> 30s → 300s&lt;/td>
&lt;td>Replica (replay lag)&lt;/td>
&lt;td>Replay lag is what the health check ejects on; amplifies flapping&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Health-check threshold 10 MB → 512 MB&lt;/td>
&lt;td>Nothing measurable&lt;/td>
&lt;td>The requirement was stated in seconds; any byte value is a guess at time&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>HAProxy &lt;code>agent-check&lt;/code> on wall-clock lag&lt;/td>
&lt;td>New moving part&lt;/td>
&lt;td>Correct, but a new component in the infrastructure; deferred until measured&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Drop &lt;code>on-marked-down shutdown-sessions&lt;/code>&lt;/td>
&lt;td>Nothing&lt;/td>
&lt;td>Shipped&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The third row is the one I would have gotten wrong on my own. I proposed raising the byte threshold, and the customer rejected it with a better argument than mine: the requirement was written in time (&amp;ldquo;a second of staleness is fine, half a minute is not&amp;rdquo;), and a byte threshold answers a different question. On steady write load the two are nearly interchangeable. On batch write load they diverge by orders of magnitude, which is the whole story of this cluster.&lt;/p>
&lt;h2 id="two-problems-that-looked-like-one">Two problems that looked like one&lt;/h2>
&lt;p>Conflicts and replica ejections clustered in the same minutes because they share a cause, not because one causes the other; barely one conflict in twenty lines up with an ejection. There were two symptom streams. One in the PostgreSQL log — a couple of hundred cancelled queries over a week and a half:&lt;/p>
&lt;pre tabindex="0">&lt;code>ERROR: canceling statement due to conflict with recovery
DETAIL: User query might have needed to see row versions that must be removed.
FATAL: terminating connection due to conflict with recovery
&lt;/code>&lt;/pre>&lt;p>And one in the HAProxy log (&lt;code>N&lt;/code> here is the live session count at the moment of the ejection — dozens, in this cluster):&lt;/p>
&lt;pre tabindex="0">&lt;code>Server slave/pg_replica is DOWN, reason: Layer7 wrong status, code: 503,
info: &amp;#34;Service Unavailable&amp;#34;, check duration: 4ms. 0 active and 0 backup
servers left. N sessions active, 0 requeued, 0 remaining in queue.
&lt;/code>&lt;/pre>&lt;p>Both landed in the same handful of minutes each hour. The connection seemed too obvious to check, which is precisely why it deserved checking.&lt;/p>
&lt;p>Before I got there I burned time on two hypotheses that a timestamp comparison would have killed in a minute each:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Checkpoints.&lt;/strong> With &lt;code>checkpoint_timeout=15min&lt;/code> the primary logs a checkpoint every quarter hour. The ejections land on entirely different minutes. No overlap at all.&lt;/li>
&lt;li>&lt;strong>The backup window.&lt;/strong> The backup script raises &lt;code>max_standby_streaming_delay&lt;/code> to 3600 s for the duration of &lt;code>pg_dump&lt;/code>, which looked like an excellent suspect. Every single ejection lasted between 6 and 27 seconds. There is not a single hour-long window in the data.&lt;/li>
&lt;/ul>
&lt;p>Then I lined up the two event streams properly (the logs are in different timezones, so this needs converting to one scale first) and compared: about a third of the replica ejections and barely a twentieth of the conflict events fall in shared windows. Two independent symptoms driven by the same cron job, not a chain.&lt;/p>
&lt;p>I should have run that correlation first. It cost one command, and it invalidated a morning&amp;rsquo;s worth of theories I&amp;rsquo;d been carefully stacking on top of each other.&lt;/p>
&lt;h2 id="what-the-lag-actually-was-in-bytes-and-in-seconds">What the lag actually was, in bytes and in seconds&lt;/h2>
&lt;p>The replica was never slow at applying WAL; it was slow at receiving it, and the difference decides which knob is even relevant. I sampled &lt;code>pg_stat_replication&lt;/code> from the primary once per second:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">SELECT&lt;/span> now()::time(&lt;span style="color:#bd93f9">0&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) &lt;span style="color:#ff79c6">AS&lt;/span> replay_bytes,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pg_wal_lsn_diff(pg_current_wal_lsn(), flush_lsn) &lt;span style="color:#ff79c6">AS&lt;/span> flush_bytes
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">FROM&lt;/span> pg_stat_replication;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Put on a relative time scale, with the figures rounded off, a single burst looks like this:&lt;/p>
&lt;pre tabindex="0">&lt;code>T+0 s replay= 0 MB flush= 0 MB
T+1 s replay=150 MB flush=150 MB ← cron batch starts
T+2 s replay=170 MB flush=170 MB
T+3 s replay= 90 MB flush= 90 MB
T+5 s replay= 0 MB flush= 0 MB ← caught up
&lt;/code>&lt;/pre>&lt;p>&lt;code>flush&lt;/code> tracks &lt;code>replay&lt;/code> to within a fraction of a percent. Nothing is queued waiting to be applied, so the lag is purely transport: the primary generated WAL faster than the link carried it. At rest the replica sits tens of kilobytes behind. The health-check threshold is 10 MB. A burst of ~170 MB clears that threshold by a factor of 17 and is gone within a few seconds.&lt;/p>
&lt;p>Then the measurement that actually settled the argument, using the &lt;code>*_lag&lt;/code> interval columns, sampled once a second over twenty minutes:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>&lt;code>replay_lag&lt;/code>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Median&lt;/td>
&lt;td>single-digit milliseconds&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>p95&lt;/td>
&lt;td>tens of milliseconds&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Worst sample seen&lt;/td>
&lt;td>a couple of seconds&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Samples above 5 s&lt;/td>
&lt;td>none&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Samples above 30 s&lt;/td>
&lt;td>none&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The stated requirement was &amp;ldquo;a second is acceptable, half a minute is not.&amp;rdquo; The replica never came within three orders of magnitude of unacceptable. And the nature of the lag is confirmed again by subtracting one column from the other at the peaks — the three largest samples in the window, figures rounded:&lt;/p>
&lt;pre tabindex="0">&lt;code>peak 1 ~160 MB | flush 1.0 s | replay 1.0 s | delta: fractions of a millisecond
peak 2 ~100 MB | flush 0.7 s | replay 0.7 s | delta: fractions of a millisecond
peak 3 ~95 MB | flush 1.7 s | replay 1.7 s | delta: fractions of a millisecond
&lt;/code>&lt;/pre>&lt;p>Applying WAL adds fractions of a millisecond. The two cron runs caught in that window sit exactly a quarter of an hour apart, which is the cron job signing its work.&lt;/p>
&lt;h2 id="what-lag-in-the-patroni-health-check-actually-guards">What lag= in the Patroni health check actually guards&lt;/h2>
&lt;p>The &lt;code>lag=&lt;/code> parameter covers exactly one scenario, &amp;ldquo;replica alive but behind,&amp;rdquo; because role and state are checked independently of it. The source is more useful than the documentation here: the docs describe what the endpoint is for, the code describes what it does. From &lt;code>patroni/api.py&lt;/code> in &lt;a href="https://github.com/patroni/patroni/blob/v3.0.2/patroni/api.py">v3.0.2&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>max_replica_lag &lt;span style="color:#ff79c6">=&lt;/span> parse_int(self&lt;span style="color:#ff79c6">.&lt;/span>path_query&lt;span style="color:#ff79c6">.&lt;/span>get(&lt;span style="color:#f1fa8c">&amp;#39;lag&amp;#39;&lt;/span>, [sys&lt;span style="color:#ff79c6">.&lt;/span>maxsize])[&lt;span style="color:#bd93f9">0&lt;/span>], &lt;span style="color:#f1fa8c">&amp;#39;B&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">if&lt;/span> max_replica_lag &lt;span style="color:#ff79c6">is&lt;/span> &lt;span style="color:#ff79c6">None&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> max_replica_lag &lt;span style="color:#ff79c6">=&lt;/span> sys&lt;span style="color:#ff79c6">.&lt;/span>maxsize
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>is_lagging &lt;span style="color:#ff79c6">=&lt;/span> leader_optime &lt;span style="color:#ff79c6">and&lt;/span> leader_optime &lt;span style="color:#ff79c6">&amp;gt;&lt;/span> replayed_location &lt;span style="color:#ff79c6">+&lt;/span> max_replica_lag
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>replica_status_code &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#bd93f9">200&lt;/span> &lt;span style="color:#ff79c6">if&lt;/span> &lt;span style="color:#ff79c6">not&lt;/span> patroni&lt;span style="color:#ff79c6">.&lt;/span>noloadbalance &lt;span style="color:#ff79c6">and&lt;/span> &lt;span style="color:#ff79c6">not&lt;/span> is_lagging &lt;span style="color:#ff79c6">and&lt;/span> \
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> response&lt;span style="color:#ff79c6">.&lt;/span>get(&lt;span style="color:#f1fa8c">&amp;#39;role&amp;#39;&lt;/span>) &lt;span style="color:#ff79c6">==&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;replica&amp;#39;&lt;/span> &lt;span style="color:#ff79c6">and&lt;/span> response&lt;span style="color:#ff79c6">.&lt;/span>get(&lt;span style="color:#f1fa8c">&amp;#39;state&amp;#39;&lt;/span>) &lt;span style="color:#ff79c6">==&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;running&amp;#39;&lt;/span> &lt;span style="color:#ff79c6">else&lt;/span> &lt;span style="color:#bd93f9">503&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Two things fall out of those few lines. First, &lt;code>parse_int(..., 'B')&lt;/code>: the threshold is bytes, full stop. There&amp;rsquo;s no time-based variant of this endpoint, so the entire discussion about &amp;ldquo;what number should we put there&amp;rdquo; was unanswerable until somebody said the units out loud. Second, &lt;code>is_lagging&lt;/code> sits in a chain with &lt;code>role&lt;/code> and &lt;code>state&lt;/code>, each able to produce a 503 on its own. A genuinely broken replica gets caught by &lt;code>state&lt;/code>/&lt;code>role&lt;/code> anyway: when streaming breaks, the row just vanishes from &lt;code>pg_stat_replication&lt;/code> and there&amp;rsquo;s no byte figure left to compute.&lt;/p>
&lt;p>There&amp;rsquo;s a third detail that makes the threshold shakier than it looks. &lt;code>leader_optime&lt;/code> is read from etcd, refreshed once per &lt;code>loop_wait&lt;/code> — 10 seconds by default (&lt;a href="https://patroni.readthedocs.io/en/latest/SETTINGS.html">Patroni settings&lt;/a>) — so the byte figure being compared can be up to a &lt;code>loop_wait&lt;/code> stale before you even pick a number to compare it against. Against bursts that come and go in a few seconds, the measurement interval is longer than the event.&lt;/p>
&lt;p>Prometheus history sizes the one scenario the threshold does cover (&lt;code>pg_replication_lag_seconds&lt;/code>, 15 s scrape). Over a month, lag exceeded one minute exactly once: the single real failure in that window ran for hours, when the replica&amp;rsquo;s VM came back from a hypervisor reboot without network.&lt;/p>
&lt;p>&lt;strong>A couple of seconds in the worst normal case against hours for a real failure. Nearly four orders of magnitude between them, and the threshold was sitting right up against the lower bound.&lt;/strong> A threshold that far from the signal it&amp;rsquo;s supposed to catch isn&amp;rsquo;t &amp;ldquo;tuned strictly,&amp;rdquo; it&amp;rsquo;s tuned arbitrarily. The &lt;code>group_vars&lt;/code> file even carries a comment from whoever raised it earlier, explaining that the bump was meant to avoid false DOWNs during checkpoint and autovacuum. Against a ~170 MB burst, no value in the tens of megabytes was ever going to work.&lt;/p>
&lt;h2 id="why-pg_replication_lag_seconds-lies-when-nobody-is-writing">Why pg_replication_lag_seconds lies when nobody is writing&lt;/h2>
&lt;p>On a replica, postgres_exporter computes this metric as &lt;code>now() - pg_last_xact_replay_timestamp()&lt;/code>, so during write silence it grows on its own without the replica being behind by anything. Its p99 over the month runs to tens of seconds, and reading that as &amp;ldquo;the replica served data tens of seconds stale&amp;rdquo; is wrong: it means the last transaction it replayed was that old, because no newer transaction existed. On the primary the same metric is flat zero. Only direct &lt;code>replay_lag&lt;/code> sampling from the primary is trustworthy.&lt;/p>
&lt;p>The second limitation is resolution. At a 15-second scrape interval, a burst that lasts a few seconds is invisible by construction. For short events the instrument is the HAProxy log, not the dashboard.&lt;/p>
&lt;h2 id="reading-burst-length-out-of-the-haproxy-log">Reading burst length out of the HAProxy log&lt;/h2>
&lt;p>When monitoring resolution is too coarse, the health check itself is a measuring instrument with known timing. With &lt;code>inter 3s fall 3 rise 2&lt;/code>:&lt;/p>
&lt;ul>
&lt;li>&lt;code>DOWN&lt;/code> is declared after &lt;code>fall 3&lt;/code> × &lt;code>inter 3s&lt;/code> = 9 seconds above threshold.&lt;/li>
&lt;li>&lt;code>UP&lt;/code> returns after &lt;code>rise 2&lt;/code> × &lt;code>inter 3s&lt;/code> = 6 seconds below it.&lt;/li>
&lt;li>Therefore burst length ≈ DOWN duration + 3 s.&lt;/li>
&lt;/ul>
&lt;p>Applied to thirty-odd ejections over a bit more than a week:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>DOWN lasted&lt;/th>
&lt;th>Share of ejections&lt;/th>
&lt;th>Burst was&lt;/th>
&lt;th>Survives &lt;code>fall 5&lt;/code>?&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>6 s&lt;/td>
&lt;td>about two thirds&lt;/td>
&lt;td>~9 s&lt;/td>
&lt;td>no — eliminated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9 s&lt;/td>
&lt;td>a couple of cases&lt;/td>
&lt;td>~12 s&lt;/td>
&lt;td>no — eliminated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>18 s&lt;/td>
&lt;td>a couple of cases&lt;/td>
&lt;td>~21 s&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>21 s&lt;/td>
&lt;td>roughly a fifth&lt;/td>
&lt;td>~24 s&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>27 s&lt;/td>
&lt;td>one case&lt;/td>
&lt;td>~30 s&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two thirds of them lasted the minimum possible DOWN, meaning the burst barely crossed the threshold and was already over. Raising &lt;code>fall 3&lt;/code> → &lt;code>fall 5&lt;/code> requires 15 consecutive seconds above threshold and removes around 70% of the ejections, with no staleness risk given the numbers above. The remaining third ran 21–30 seconds and would still eject.&lt;/p>
&lt;p>To be clear about status: that change is argued for with the numbers above and agreed in principle, but it is not applied. It sits behind the same sign-off as anything else touching the balancer, and the residual third of the ejections needs either a higher byte threshold or the time-based check I keep coming back to.&lt;/p>
&lt;p>That arithmetic required no new tooling, no sampling, and no agreement from anyone. The data had been in the balancer log the whole time.&lt;/p>
&lt;h2 id="what-i-shipped-and-how-the-rollout-went-sideways">What I shipped, and how the rollout went sideways&lt;/h2>
&lt;p>One line removed from the read backend, leaving the failover-critical backends untouched:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-diff" data-lang="diff">&lt;span style="display:flex;">&lt;span> backend slave
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option httpchk GET /replica?lag=10485760 ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> http-check expect status 200
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f55">- default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f55">&lt;/span>&lt;span style="color:#50fa7b;font-weight:bold">+ default-server inter 3s fall 3 rise 2
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The HAProxy 2.4 manual describes the option plainly: with &lt;code>shutdown-sessions&lt;/code>, &amp;ldquo;all connections to the server are immediately terminated when the server goes down&amp;rdquo; (&lt;a href="https://docs.haproxy.org/2.4/configuration.html">configuration manual, §5.2&lt;/a>). On a read backend fronting a replica that comes back within seconds, that is damage with no upside. In the logs it killed dozens of sessions at once. The application does not see &lt;code>40001&lt;/code> for those connections, it sees a severed socket, which the developer&amp;rsquo;s interceptor never catches. The replica can still be ejected during a burst; live reads now finish, and new connections fall through to &lt;code>use_backend master if { nbsrv(slave) eq 0 }&lt;/code>. The option stays in the &lt;code>master&lt;/code> and &lt;code>long_queries&lt;/code> backends, where killing sessions during failover is the point.&lt;/p>
&lt;p>Three things about the rollout worth stealing:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>--check --diff&lt;/code> on the whole playbook, not just my part.&lt;/strong> Someone had added &lt;code>weight 80&lt;/code> / &lt;code>weight 20&lt;/code> by hand on the server and never put them in the template. A normal run would have silently erased them. I pulled the repository up to the server&amp;rsquo;s actual state first, then applied my change on top.&lt;/li>
&lt;li>&lt;strong>A &lt;code>group_vars&lt;/code> file turned out to be a hardlink shared by several inventories.&lt;/strong> One edit, every environment changed.&lt;/li>
&lt;li>&lt;strong>I rolled it out with a restart instead of a reload.&lt;/strong> In master-worker mode &lt;code>kill -USR2&lt;/code> to the master reloads without dropping anything: the master holds the sockets, the port never closes, and the old worker drains its sessions. A restart dropped every connection at once — which is precisely the damage this change exists to prevent. Shipping a fix for connection drops by dropping every connection is the kind of thing you only do once.&lt;/li>
&lt;/ul>
&lt;h2 id="why-patroni-ignores-the-log-settings-in-your-playbook">Why Patroni ignores the log settings in your playbook&lt;/h2>
&lt;p>Anything under &lt;code>bootstrap.dcs&lt;/code> is applied once, at cluster initialization, and edits to it afterwards do nothing. I found this while looking for something else entirely: &lt;code>pg_log&lt;/code> had never rotated. Tens of gigabytes on the primary, single-digit gigabytes on the replica, files that had been piling up for years, on a cluster whose Ansible template configures rotation perfectly well.&lt;/p>
&lt;p>Nobody had edited anything by hand this time. The Patroni documentation states the rule with unusual force: &amp;ldquo;Once Patroni has initialized the cluster for the first time and settings have been stored in the DCS, all future changes to the &lt;code>bootstrap.dcs&lt;/code> section of the YAML configuration will not take any effect!&amp;rdquo; (&lt;a href="https://patroni.readthedocs.io/en/latest/yaml_configuration.html">YAML configuration&lt;/a>). The section &amp;ldquo;will be written into &lt;code>/&amp;lt;namespace&amp;gt;/&amp;lt;scope&amp;gt;/config&lt;/code> of the given configuration store after initializing the new cluster,&amp;rdquo; and from that moment the copy in etcd is the one that counts.&lt;/p>
&lt;p>Changing those values later goes through &lt;code>patronictl edit-config&lt;/code> or the REST API, not the file (&lt;a href="https://patroni.readthedocs.io/en/latest/dynamic_configuration.html">dynamic configuration&lt;/a>). Which means a Patroni cluster has a whole class of settings where the playbook is documentation rather than configuration, and &lt;code>--check --diff&lt;/code> will never tell you, because the file on disk matches the template exactly. It is right and the cluster still ignores it.&lt;/p>
&lt;h2 id="what-the-application-layer-got-wrong">What the application layer got wrong&lt;/h2>
&lt;p>The interceptor I was asked to review was correct; the defects were around it. Findings handed back to the developers:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>&lt;code>ERROR&lt;/code> and &lt;code>FATAL&lt;/code> are different events.&lt;/strong> &lt;code>ERROR: canceling statement due to conflict with recovery&lt;/code> cancels the statement and leaves the connection usable. &lt;code>FATAL: terminating connection due to conflict with recovery&lt;/code> does not. Over the week and a half of logs, nearly all the events were &lt;code>ERROR&lt;/code> and a handful were &lt;code>FATAL&lt;/code>. One retry strategy cannot serve both.&lt;/li>
&lt;li>&lt;strong>The retry loop was unreachable&lt;/strong> past its first iteration, so it retried once regardless of configuration.&lt;/li>
&lt;li>&lt;strong>The application under-reports by half.&lt;/strong> Over two weeks the database logged roughly twice as many conflict events as the application reported through its single &lt;code>Log::warning&lt;/code>. That gap is why nobody knew the size of the problem.&lt;/li>
&lt;li>&lt;strong>&lt;code>application_name&lt;/code> is empty&lt;/strong> in every event, so a conflict cannot be attributed to a service from the database logs — only to a database user.&lt;/li>
&lt;li>&lt;strong>A pre-existing lazy-connection bug in the Laravel wrapper.&lt;/strong> &lt;code>bindValues()&lt;/code> opens with &lt;code>$this-&amp;gt;getPdo()-&amp;gt;getAttribute(...)&lt;/code>, and &lt;code>getPdo()&lt;/code> resolves the lazy closure and opens a connection to the primary. Every query with bindings calls it, read-only ones included, so the read/write split silently leaks connections to the primary on any parameterised read.&lt;/li>
&lt;/ol>
&lt;p>Half of my initial review comments on the interceptor did not survive my own re-checking. Separating &amp;ldquo;I would have written this differently&amp;rdquo; from &amp;ldquo;this is a defect&amp;rdquo; is most of what code review is, and I am not always good at it.&lt;/p>
&lt;h2 id="step-by-step">Step by step&lt;/h2>
&lt;ol>
&lt;li>Count the events before theorizing: split &lt;code>ERROR&lt;/code> from &lt;code>FATAL&lt;/code> in the PostgreSQL log and get the rate per day.&lt;/li>
&lt;li>Correlate every symptom stream by timestamp on one timezone scale, first, before building any causal story.&lt;/li>
&lt;li>Sample &lt;code>pg_stat_replication&lt;/code> from the primary once per second; compare &lt;code>replay_lsn&lt;/code> against &lt;code>flush_lsn&lt;/code> to separate transport lag from apply lag.&lt;/li>
&lt;li>Sample the &lt;code>*_lag&lt;/code> interval columns to get lag in seconds, which is the unit your requirement is almost certainly written in.&lt;/li>
&lt;li>Read your health-check endpoint&amp;rsquo;s source for the units it actually accepts before negotiating a threshold value.&lt;/li>
&lt;li>Derive burst duration from the balancer&amp;rsquo;s own &lt;code>inter&lt;/code>/&lt;code>fall&lt;/code>/&lt;code>rise&lt;/code> timing when scrape intervals are too coarse.&lt;/li>
&lt;li>Check whether &lt;code>hot_standby_feedback&lt;/code> is safe &lt;em>for your topology&lt;/em>: with slots and one replica, it is not.&lt;/li>
&lt;li>Run &lt;code>--check --diff&lt;/code> across the whole playbook before applying, and reload rather than restart.&lt;/li>
&lt;li>For anything Patroni owns, read the live values with &lt;code>patronictl show-config&lt;/code> rather than trusting the YAML in your repository.&lt;/li>
&lt;/ol>
&lt;h2 id="bottom-line">Bottom line&lt;/h2>
&lt;p>Neither of the two canonical answers to &lt;code>conflict with recovery&lt;/code> was usable on this cluster, and both failures were topology-specific rather than wrong in general. &lt;code>hot_standby_feedback&lt;/code> assumes you have no slots or a spare replica; raising the streaming delay assumes nothing downstream measures replay position. Check those assumptions against your own cluster before pasting either one into &lt;code>postgresql.conf&lt;/code>.&lt;/p>
&lt;p>The larger lesson is about units. The health check measured &amp;ldquo;how much WAL has not arrived&amp;rdquo; in bytes while the business requirement said &amp;ldquo;how stale is the data I am serving&amp;rdquo; in seconds. Every discussion about the right threshold was unanswerable until somebody wrote both sentences down next to each other. The measured answer was single-digit milliseconds at the median against a real failure measured in hours — and the deferred work, an &lt;code>agent-check&lt;/code> agent reporting &lt;code>up&lt;/code>/&lt;code>down&lt;/code> from &lt;code>now() - pg_last_xact_replay_timestamp()&lt;/code>, is now a decision that can be made with numbers instead of a guess.&lt;/p></content:encoded></item><item><title>Gmail bounced everything the relay sent: three broken auth mechanisms and a silent OpenDKIM</title><link>https://bitpage.me/incidents/gmail-bounces-everything-spf-dkim-fcrdns/</link><pubDate>Thu, 06 Aug 2026 07:08:22 +0000</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.me/incidents/gmail-bounces-everything-spf-dkim-fcrdns/</guid><description>A relay with more than two years of uptime failed nearly every Gmail delivery because SPF, DKIM and forward-confirmed reverse DNS were broken independently. The root cause of the DKIM half was one word: tables mounted as refile: instead of file:, which makes OpenDKIM drop the key lookup and sign nothing without logging a line.</description><content:encoded>&lt;p>&lt;strong>Short answer:&lt;/strong> Three independent mechanisms were broken at once, and fixing any one of them changed nothing visible. The domain had no &lt;code>v=spf1&lt;/code> record (only a dead SenderID one), the PTR resolved forward to a different server, and OpenDKIM was signing nothing because &lt;code>KeyTable&lt;/code> and &lt;code>SigningTable&lt;/code> were attached as &lt;code>refile:&lt;/code> while their contents were in plain two-column format. That last one cost the most time: when OpenDKIM can&amp;rsquo;t find a key it logs absolutely nothing, so the daemon reads &lt;code>active&lt;/code>, the config parses, the key is readable, and mail still leaves unsigned.&lt;/p>
&lt;p>The server is an outgoing relay for transactional mail from a couple of dozen application hosts: password resets, notifications, alerts. Inbound mail for the domain goes elsewhere, to Google Workspace as MX. Postfix on a legacy container with more than two years of uptime, OpenDKIM 2.10.3, no Ansible, edits by hand only. The daily log summary read a four-digit &lt;code>bounced&lt;/code> against a single-digit &lt;code>sent&lt;/code>: thousands of rejections, a handful of deliveries. Password reset mail was among the casualties, so people could not get back into their accounts. Nobody had changed anything on this box in years, which turned out to be the whole story.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Root cause (DKIM):&lt;/strong> &lt;code>KeyTable&lt;/code>/&lt;code>SigningTable&lt;/code> mounted as &lt;code>refile:&lt;/code> while the entries were plain &lt;code>example.org mail._domainkey.example.org&lt;/code> lines. Under &lt;code>refile:&lt;/code> the key is a regex matched against the whole &lt;code>From:&lt;/code> address, so &lt;code>example.org&lt;/code> never matches &lt;code>noreply@example.org&lt;/code>. Changing one word per line to &lt;code>file:&lt;/code> fixed it.&lt;/li>
&lt;li>&lt;strong>Also broken:&lt;/strong> &lt;code>InternalHosts&lt;/code> listed fewer than ten addresses against a Postfix &lt;code>mynetworks&lt;/code> two to three times longer. Mail from the app hosts was being verified instead of signed.&lt;/li>
&lt;li>&lt;strong>Also broken:&lt;/strong> the only SPF-shaped TXT record was &lt;code>spf2.0/mfrom,pra&lt;/code> SenderID. RFC 7208 discards anything that doesn&amp;rsquo;t begin with &lt;code>v=spf1&lt;/code>, so the domain effectively had no SPF record.&lt;/li>
&lt;li>&lt;strong>Also broken:&lt;/strong> the PTR pointed at a hostname whose A record led to a different machine. Forward-confirmed reverse DNS failed.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> &lt;code>grep -c DKIM-Signature /var/log/mail.log&lt;/code> returns &lt;code>0&lt;/code> whether signing works or not. Postfix does not write message headers to the log.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> testing with &lt;code>sendmail&lt;/code> from the mail server itself passes, because &lt;code>127.0.0.1&lt;/code> was in &lt;code>InternalHosts&lt;/code>. Real traffic from other hosts kept going out bare.&lt;/li>
&lt;/ul>
&lt;h2 id="why-a-server-nobody-touched-stopped-being-deliverable">Why a server nobody touched stopped being deliverable&lt;/h2>
&lt;p>The requirements moved while the configuration stood still. Gmail began requiring authentication from bulk senders in February 2024 and has been ramping enforcement since, and a config that predated those rules simply stopped qualifying (&lt;a href="https://support.google.com/mail/answer/81126">Email sender guidelines&lt;/a>). There was no change window to roll back to, no deploy to blame. The rejection came back verbatim as:&lt;/p>
&lt;pre tabindex="0">&lt;code>550-5.7.26 Your email has been blocked because the sender is unauthenticated.
550-5.7.26 Gmail requires all senders to authenticate with either SPF or DKIM.
550-5.7.26 Authentication results:
550-5.7.26 DKIM = did not pass
550-5.7.26 SPF [example.org] with ip: [203.0.113.51] = did not pass
&lt;/code>&lt;/pre>&lt;p>Both named mechanisms failing at once should have been the tell that this was not one bug. I read it as one bug anyway and spent the next hour paying for it.&lt;/p>
&lt;h2 id="three-of-the-four-facts-in-my-own-notes-were-wrong">Three of the four facts in my own notes were wrong&lt;/h2>
&lt;p>I started from a ticket written a day earlier that already contained a diagnosis, and rechecking it by hand killed three of its four claims. The notes said: PTR is fine and forward and reverse match; DNS is hosted on Cloudflare; DMARC is absent.&lt;/p>
&lt;p>The PTR pointed at &lt;code>legacy.example.org&lt;/code>, whose A record led to &lt;code>198.51.100.216&lt;/code>, a different server entirely. &lt;code>dig NS&lt;/code> returned &lt;code>ns-*.awsdns-*&lt;/code>, so DNS was on Route 53, not Cloudflare. DMARC was present and had been all along, &lt;code>v=DMARC1; p=none&lt;/code>. One claim out of four survived. These were my own notes, from the previous day.&lt;/p>
&lt;p>The other rejection message had been telling me about the PTR the whole time:&lt;/p>
&lt;pre tabindex="0">&lt;code>550-5.7.25 [203.0.113.51] The IP address sending this message does not have a PTR
550-5.7.25 record setup, or the corresponding forward DNS entry does not match the sending IP
&lt;/code>&lt;/pre>&lt;p>The operative half is the second clause. Not &amp;ldquo;you have no PTR record&amp;rdquo; but &amp;ldquo;the forward entry doesn&amp;rsquo;t match&amp;rdquo;. I had read to the first familiar phrase, seen a PTR existed, and moved on. RFC 1912 §2.1 states the rule plainly: &amp;ldquo;Make sure your PTR and A records match. For every IP address, there should be a matching PTR record in the in-addr.arpa domain&amp;rdquo; (&lt;a href="https://www.rfc-editor.org/rfc/rfc1912.txt">RFC 1912&lt;/a>). The same section warns that getting this wrong &amp;ldquo;can cause loss of Internet services similar to not being registered in the DNS at all&amp;rdquo;, which is a fair description of a bounce column four digits wide.&lt;/p>
&lt;p>The fix had two possible shapes. Editing the A record of &lt;code>legacy.example.org&lt;/code> to point here would have satisfied the check, but that hostname is live and fronts an unrelated service, so the blast radius covered somebody else&amp;rsquo;s system. Repointing the PTR to &lt;code>mail.example.org&lt;/code>, whose A record already resolved to the sending IP, changed nothing outside this server. I also set Postfix &lt;code>myhostname&lt;/code> to &lt;code>mail.example.org&lt;/code> so the HELO name matches the PTR.&lt;/p>
&lt;h2 id="why-the-spf-record-that-existed-didnt-count">Why the SPF record that existed didn&amp;rsquo;t count&lt;/h2>
&lt;p>There was a TXT record on the domain, and it was worth exactly nothing, because it was a SenderID record rather than an SPF one. It began &lt;code>spf2.0/mfrom,pra&lt;/code>. RFC 7208 §4.5 is unambiguous about what a verifier does with that: &amp;ldquo;Starting with the set of records that were returned by the lookup, discard records that do not begin with a version section of exactly &amp;lsquo;v=spf1&amp;rsquo;&amp;rdquo; (&lt;a href="https://www.rfc-editor.org/rfc/rfc7208.txt">RFC 7208&lt;/a>).&lt;/p>
&lt;p>Discarded means the domain had no SPF record at all as far as Gmail was concerned, which is precisely what &lt;code>SPF [example.org] ... = did not pass&lt;/code> was reporting. A record that looks like SPF to a human and is invisible to every verifier is worse than an empty zone, because it stops anyone from looking further. Published in its place:&lt;/p>
&lt;pre tabindex="0">&lt;code>v=spf1 ip4:203.0.113.51 include:_spf.google.com include:spf.example.net ~all
&lt;/code>&lt;/pre>&lt;h2 id="why-grepping-the-mail-log-for-dkim-signature-proves-nothing">Why grepping the mail log for DKIM-Signature proves nothing&lt;/h2>
&lt;p>Postfix does not write message headers to its log, so the count is zero whether signing works or not. My first check was &lt;code>grep -c &amp;quot;DKIM-Signature&amp;quot; /var/log/mail.log&lt;/code>, which returned &lt;code>0&lt;/code>, and I took that as evidence that nothing was being signed. It wasn&amp;rsquo;t evidence of anything. The method cannot distinguish a broken signer from a working one, and I used it as the basis for the next hour of work.&lt;/p>
&lt;p>The check that does work is to send a message to a local mailbox and read the headers of the delivered file, &lt;code>/var/mail/nobody&lt;/code>. That shows the actual &lt;code>DKIM-Signature&lt;/code> header or its absence, with no inference in between. Building that first would have made every subsequent change a one-minute yes or no. I built it fourth.&lt;/p>
&lt;h2 id="opendkim-reports-active-parses-clean-and-signs-nothing">OpenDKIM reports active, parses clean, and signs nothing&lt;/h2>
&lt;p>OpenDKIM writes no log line when it fails to find a signing key for a message, so &lt;code>systemctl is-active&lt;/code> returning &lt;code>active&lt;/code> alongside a clean startup is zero evidence that signing happens. I found and fixed two genuine defects on the strength of that assumption, and neither produced a signature.&lt;/p>
&lt;p>The first was the config itself. &lt;code>/etc/opendkim.conf&lt;/code> had been truncated to four lines, with no &lt;code>Mode&lt;/code>, no &lt;code>KeyTable&lt;/code>, no &lt;code>SigningTable&lt;/code>. The fingerprints of a package upgrade were right there: &lt;code>/etc/opendkim/&lt;/code> had been touched recently while the files inside it were years older. I restored the missing directives and restarted. Clean start, no errors, no signature.&lt;/p>
&lt;p>The second was key ownership. The private key belonged to &lt;code>_apt:uuidd&lt;/code> with mode &lt;code>0600&lt;/code>, and the daemon runs as &lt;code>opendkim&lt;/code>. Checking as the service user rather than as root is the only way to see this:&lt;/p>
&lt;pre tabindex="0">&lt;code>su -s /bin/sh opendkim -c &amp;#34;head -c 20 /etc/opendkim/default/mail.private&amp;#34;
head: cannot open &amp;#39;/etc/opendkim/default/mail.private&amp;#39; for reading: Permission denied
&lt;/code>&lt;/pre>&lt;p>A real defect, and &lt;code>chown opendkim:opendkim&lt;/code> is mandatory. The key became readable. Still no signature. Two genuine bugs repaired, both necessary, neither sufficient, and my supply of hypotheses was gone.&lt;/p>
&lt;h2 id="the-actual-root-cause-was-one-word-and-it-was-in-the-man-page">The actual root cause was one word, and it was in the man page&lt;/h2>
&lt;p>The tables were attached as &lt;code>refile:&lt;/code> while their contents were in plain two-column format, and under &lt;code>refile:&lt;/code> the lookup key is treated as a regex matched against the entire &lt;code>From:&lt;/code> address. I stopped guessing and opened &lt;code>man opendkim.conf&lt;/code> on the server itself, same 2.10.3 as the running binary, which is more authoritative than whatever version the web is serving. The answer sits in one paragraph about dataset types:&lt;/p>
&lt;blockquote>
&lt;p>&amp;ldquo;If this table specifies a regular expression file (&amp;ldquo;refile&amp;rdquo;), then the keys are wildcard patterns that are matched against the address found in the From: header field. For all other database types, the full user@host is checked first, then simply host, then user@.domain (with all superdomains checked in sequence…)&amp;rdquo;&lt;/p>
&lt;/blockquote>
&lt;p>The tables held lines like this:&lt;/p>
&lt;pre tabindex="0">&lt;code>example.org mail._domainkey.example.org
&lt;/code>&lt;/pre>&lt;p>Under &lt;code>refile:&lt;/code>, the pattern &lt;code>example.org&lt;/code> is matched against &lt;code>noreply@example.org&lt;/code> and does not match, so no key is found and the message leaves unsigned without a single log line. Under &lt;code>file:&lt;/code>, the lookup falls through to the second step, &amp;ldquo;then simply host&amp;rdquo;, where &lt;code>example.org&lt;/code> matches exactly. The fix was one word in two lines:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-diff" data-lang="diff">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f55">-KeyTable refile:/etc/opendkim/KeyTable
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f55">-SigningTable refile:/etc/opendkim/SigningTable
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f55">&lt;/span>&lt;span style="color:#50fa7b;font-weight:bold">+KeyTable file:/etc/opendkim/KeyTable
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#50fa7b;font-weight:bold">+SigningTable file:/etc/opendkim/SigningTable
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The other direction works too: rewrite every table entry to &lt;code>*@example.org&lt;/code> and keep &lt;code>refile:&lt;/code>. I rejected it because the tables covered several domains, and rewriting every line of data is a wider edit than changing the dataset type to the one that already matches the format on disk.&lt;/p>
&lt;h2 id="the-second-half-of-the-same-bug-internalhosts-was-a-fraction-of-mynetworks">The second half of the same bug: InternalHosts was a fraction of mynetworks&lt;/h2>
&lt;p>OpenDKIM only signs mail arriving from hosts in &lt;code>InternalHosts&lt;/code>; everything else it verifies instead, so any app server missing from that list was sending bare mail by design. The man page states the whole behaviour in one line, describing the option as identifying &amp;ldquo;a set internal hosts whose mail should be signed rather than verified&amp;rdquo; (typo included, as printed in 2.10.3).&lt;/p>
&lt;p>&lt;code>InternalHosts&lt;/code> pointed at &lt;code>TrustedHosts&lt;/code>, which listed fewer than ten addresses. Postfix &lt;code>mynetworks&lt;/code> listed two to three times as many. Syncing the short list against the long one tripled it, finally covering the application hosts that had been relaying through a server which was, on paper, configured to sign their mail. The two files are not required to match exactly, since they answer different questions, but a host that can relay and cannot be signed is almost always an oversight rather than a decision.&lt;/p>
&lt;p>This is also why my manual test had lied to me earlier. Mail sent with &lt;code>sendmail&lt;/code> from the relay itself originates from &lt;code>127.0.0.1&lt;/code>, which was in the list, so a signature appeared and I briefly thought the job was done. The traffic that actually mattered came from the application hosts, none of them in the file. Test along the path production traffic takes, not the path that is convenient from an SSH session.&lt;/p>
&lt;h2 id="the-four-defects-and-the-check-that-actually-proves-each-one">The four defects and the check that actually proves each one&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Broken&lt;/th>
&lt;th>Symptom you see&lt;/th>
&lt;th>Check that settles it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>No &lt;code>v=spf1&lt;/code> record, SenderID only&lt;/td>
&lt;td>&lt;code>SPF … = did not pass&lt;/code>&lt;/td>
&lt;td>&lt;code>dig +short TXT example.org&lt;/code> — the string must begin &lt;code>v=spf1&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PTR forward mismatch&lt;/td>
&lt;td>&lt;code>550-5.7.25 … forward DNS entry does not match&lt;/code>&lt;/td>
&lt;td>&lt;code>dig -x &amp;lt;ip&amp;gt;&lt;/code>, then resolve that name&amp;rsquo;s A record back — it must return &lt;code>&amp;lt;ip&amp;gt;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>refile:&lt;/code> on plain-format tables&lt;/td>
&lt;td>Nothing. No log line at all.&lt;/td>
&lt;td>Read the &lt;code>DKIM-Signature&lt;/code> header of a delivered message&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>InternalHosts&lt;/code> narrower than &lt;code>mynetworks&lt;/code>&lt;/td>
&lt;td>Signs locally, unsigned from apps&lt;/td>
&lt;td>Send from a real application host, not from the relay&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The third row is the one worth internalising. Three of these four failures announce themselves somewhere. The dataset-type bug produces no error, no warning, and a healthy-looking service, and the only way to observe it is to inspect the output rather than the process.&lt;/p>
&lt;h2 id="step-by-step">Step by step&lt;/h2>
&lt;ol>
&lt;li>Build the verification path before touching anything: send to a local mailbox and read the delivered headers. Every later change then gives an immediate yes or no.&lt;/li>
&lt;li>Recheck every fact you were handed, including notes you wrote yourself yesterday.&lt;/li>
&lt;li>Read the whole SMTP rejection. &lt;code>5.7.25&lt;/code> and &lt;code>5.7.26&lt;/code> name different failures, and in both the specificity lives in the second clause of the sentence.&lt;/li>
&lt;li>Resolve the sending IP with &lt;code>dig -x&lt;/code>, then resolve the answer forward. Both directions, or it doesn&amp;rsquo;t count.&lt;/li>
&lt;li>Confirm the TXT record begins with &lt;code>v=spf1&lt;/code> rather than merely mentioning SPF.&lt;/li>
&lt;li>For DKIM: confirm the dataset type matches the table format, confirm the service user can read the key with &lt;code>su -s /bin/sh opendkim -c &amp;quot;…&amp;quot;&lt;/code>, and confirm &lt;code>InternalHosts&lt;/code> covers every host in &lt;code>mynetworks&lt;/code>.&lt;/li>
&lt;li>Send the final test from a host that is not the mail server.&lt;/li>
&lt;/ol>
&lt;p>The end state was &lt;code>DKIM-Signature: v=1; a=rsa-sha256; d=example.org; s=mail&lt;/code> in the delivered headers, &lt;code>spf=pass&lt;/code>, &lt;code>dmarc=pass&lt;/code>, and &lt;code>250 2.0.0 OK&lt;/code> from Gmail. Total time was about an hour and a half, most of it spent on the silent one.&lt;/p>
&lt;h2 id="what-authentication-didnt-fix-six-messages-in-ten-still-bounce">What authentication didn&amp;rsquo;t fix: six messages in ten still bounce&lt;/h2>
&lt;p>Passing SPF and DKIM gets mail accepted, not trusted, and this domain&amp;rsquo;s reputation problem survived every fix above untouched. A full day of counters after the fix still showed more rejections than deliveries — roughly six messages in ten going nowhere — and among them close to a hundred a day addressed to &lt;code>@iclaud.com&lt;/code>, a typo of &lt;code>@icloud.com&lt;/code> that the application has been faithfully retrying for years. Bounce rate is not the metric Google publishes a threshold for; that one is spam rate, which bulk senders are asked to keep under 0.30% in Postmaster Tools. They are different numbers, but they feed the same reputation ledger, and 0.30% is a useful sense of the scale a receiver considers acceptable. Mail with flawless authentication still lands in the spam folder if the domain spends years hammering addresses that don&amp;rsquo;t exist.&lt;/p>
&lt;p>The reason nobody knew is that the feedback loop had been cut at both ends. The server delivered non-delivery reports to the sender address, whose domain it considered local via &lt;code>mydestination&lt;/code>, and no local user by that name existed, so the reports were discarded on arrival. That was hundreds of destroyed reports a day. The application mails a dead address, the report about it is deleted by the same server, nobody finds out, and the application keeps mailing.&lt;/p>
&lt;p>What I deliberately did not do was tighten DMARC past &lt;code>p=none&lt;/code>. Moving to &lt;code>quarantine&lt;/code> before the statistics are clean would route legitimate mail to spam by my own hand, and there is no rush. Still outstanding: the envelope sender equals the &lt;code>From:&lt;/code> address, where a dedicated bounce address with VERP belongs; certbot 0.12.0 is dead on this box, still calling the retired &lt;code>acme-v01&lt;/code> endpoint, with a certificate that expired years ago. And every change here was made by hand on a container that lives outside Ansible, so none of it exists in any repository. The next person to open this server will find no trace of why it works.&lt;/p>
&lt;h2 id="bottom-line">Bottom line&lt;/h2>
&lt;p>When a daemon reports &lt;code>active&lt;/code> and does not do its job while logging nothing, stop repairing hypotheses and go read the man page for the semantics of the specific option you are relying on. Silence is not the absence of a fault. It is its own class of behaviour, and it is always described somewhere in the documentation, usually in a sentence about how lookups are performed.&lt;/p>
&lt;p>The process mistake was ordering. I fixed three genuine defects (truncated config, key ownership, then the host list) before finding the one that actually mattered, and each time I expected the next test to pass. A reliable check built at minute one, reading the headers of a delivered message, would have turned each of those into a one-minute negative result instead of an hour of accumulating false hope. Build the instrument before you start turning things.&lt;/p></content:encoded></item><item><title>How to migrate a stateful Docker app off a server with a dying disk</title><link>https://bitpage.me/infra/migrate-docker-app-dying-disk/</link><pubDate>Fri, 24 Jul 2026 11:35:00 +0000</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.me/infra/migrate-docker-app-dying-disk/</guid><description>Treat a disk that fails on writes as read-only: stream pg_dump over SSH to the target, pull EOL images from the registry instead of rebuilding them, and anchor rsync excludes with a leading slash. Notes from moving a Docker app of some fifteen services with a PostgreSQL database on the order of ten gigabytes.</description><content:encoded>&lt;p>&lt;strong>Short answer:&lt;/strong> When the source server&amp;rsquo;s disk fails on writes but still reads fine, treat it as read-only hardware. Stream &lt;code>pg_dump -Fc&lt;/code> over SSH straight to the target machine, pull Docker images from your registry instead of rebuilding end-of-life Dockerfiles, and anchor every rsync exclude with a leading slash. That moved an app of some fifteen services, with a PostgreSQL database on the order of ten gigabytes, onto a new node without a single write to the failing disk.&lt;/p>
&lt;p>The source was an old OpenVZ box with a degraded RAID and a disk that had quietly stopped accepting writes. Reads were healthy. On it lived a Dockerized PHP application: some fifteen services (nginx, php-fpm, a pool of queue workers, PostgreSQL, Redis), a database on the order of ten gigabytes, a few gigabytes of media. The job was to stand up a production-like copy on a new Proxmox node for testing, with the real traffic cutover scheduled later.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Fix:&lt;/strong> run &lt;code>pg_dump -Fc&lt;/code> on the source but pipe it through &lt;code>ssh -T&lt;/code> to the target&amp;rsquo;s disk — the source only reads, the target does all the writing.&lt;/li>
&lt;li>&lt;strong>Fix:&lt;/strong> don&amp;rsquo;t rebuild images based on EOL distributions; pull the exact production images from the registry, reusing &lt;code>/root/.docker/config.json&lt;/code> from the source host.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> &lt;code>rsync --exclude=log/&lt;/code> without a leading slash matches &lt;code>log&lt;/code> at every depth. It silently removed &lt;code>vendor/yiisoft/yii2/log/&lt;/code> from the copy and the app answered HTTP 500.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> a dump to a dying disk hangs without an error. Watch the output file&amp;rsquo;s size delta, not the process list.&lt;/li>
&lt;/ul>
&lt;h2 id="why-pg_dump-hangs-on-a-dying-disk-instead-of-failing">Why pg_dump hangs on a dying disk instead of failing&lt;/h2>
&lt;p>A dump writing to a dead disk doesn&amp;rsquo;t error out; it stops making progress while the process stays alive. My first attempt was the obvious one: &lt;code>pg_dump -Fc&lt;/code> into a local &lt;code>backup/&lt;/code> directory on the source. The file grew to roughly a gigabyte and froze. Two size checks six seconds apart returned the same size, with the process still running. The disk had died on the write path specifically, and a hung dump was exactly how that looked from userspace.&lt;/p>
&lt;p>The liveness check lied to me too. The watcher used &lt;code>pgrep -f pg_dump&lt;/code>, and its own command line contained the string &lt;code>pg_dump&lt;/code>, so it matched itself and kept reporting the dump as alive. &lt;code>ps -C pg_dump&lt;/code> (exact process name match) or a size delta on the output file are the checks that don&amp;rsquo;t fool themselves.&lt;/p>
&lt;h2 id="streaming-the-dump-keep-every-write-off-the-source">Streaming the dump: keep every write off the source&lt;/h2>
&lt;p>The fix is to leave the source with the read half of the work and move the write half to another machine. &lt;code>pg_dump&lt;/code> runs on the source, but its output goes over SSH to the target&amp;rsquo;s healthy disk:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>ssh -T old-host &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;docker exec app-postgres pg_dump -Fc -U app appdb&amp;#39;&lt;/span> &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &amp;gt; /srv/dumps/appdb.dump
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Dumping from a live database is fine here. The PostgreSQL 11 documentation states it directly: &amp;ldquo;pg_dump does not block other users accessing the database (readers or writers)&amp;rdquo; (&lt;a href="https://www.postgresql.org/docs/11/app-pgdump.html">pg_dump reference&lt;/a>).&lt;/p>
&lt;p>Two details matter for the transport. &lt;code>ssh -T&lt;/code> disables pseudo-terminal allocation, and &lt;code>docker exec&lt;/code> must run without &lt;code>-t&lt;/code>: a tty mangles the binary custom-format stream. With both in place the gigabyte-and-a-half dump completed with exit code 0, and on the target &lt;code>pg_restore --no-owner -j 4 -d appdb&lt;/code> unpacked it back into the same ten gigabytes or so, roughly a hundred tables, with the &lt;code>rum&lt;/code> extension intact.&lt;/p>
&lt;h2 id="pull-eol-images-dont-rebuild-them">Pull EOL images, don&amp;rsquo;t rebuild them&lt;/h2>
&lt;p>If production images already exist in a registry, pull them; rebuilding their Dockerfiles years later is a dead end. My first &lt;code>docker compose pull&lt;/code> on the new host failed with &lt;code>Error response from daemon: error from registry: access forbidden&lt;/code> — a private GitLab registry the new machine wasn&amp;rsquo;t authorized against. Instead of fixing auth, I detoured into rebuilding the images from the same Dockerfiles production once used. That went like this:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Base image&lt;/th>
&lt;th>Debian release inside&lt;/th>
&lt;th>What apt says in 2026&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>nginx:1.17.4&lt;/code>&lt;/td>
&lt;td>buster&lt;/td>
&lt;td>&lt;code>Release 404 Not Found&lt;/code> on deb.debian.org&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>postgres:11&lt;/code>&lt;/td>
&lt;td>stretch&lt;/td>
&lt;td>PGDG packages for PostgreSQL 11 removed, 404&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Buster I could route to &lt;a href="https://www.debian.org/distrib/archive">archive.debian.org&lt;/a>. Then the stretch-based &lt;code>postgres:11&lt;/code> build died on the PGDG repository, which no longer serves packages for an EOL major at all. I stopped there and admitted the detour was me solving the wrong problem.&lt;/p>
&lt;p>The actual answer was sitting on the dying host the whole time: it pulls those images itself, which means working registry credentials live in its &lt;code>/root/.docker/config.json&lt;/code> (that&amp;rsquo;s where &lt;a href="https://docs.docker.com/reference/cli/docker/login/">&lt;code>docker login&lt;/code>&lt;/a> stores them). Copying that one file to the new host let &lt;code>docker compose pull&lt;/code> fetch the whole set of images — the exact bytes production runs, no build step.&lt;/p>
&lt;h2 id="the-rsync-exclude-that-deleted-framework-files">The rsync exclude that deleted framework files&lt;/h2>
&lt;p>&lt;code>--exclude=log/&lt;/code> without a leading slash matches every directory named &lt;code>log&lt;/code> at any depth, not just the top-level one you meant. I added it to skip a couple of gigabytes of application logs during the code-and-media rsync. The copy finished clean, and the app came up with HTTP 500:&lt;/p>
&lt;pre tabindex="0">&lt;code>include(/app/vendor/yiisoft/yii2/log/Logger.php):
failed to open stream: No such file or directory
&lt;/code>&lt;/pre>&lt;p>Nothing in that message points at rsync. A missing framework file reads like a corrupted vendor directory, and I chased that theory first. The real cause: the unanchored pattern had stripped &lt;code>vendor/yiisoft/yii2/log/&lt;/code> out of the transfer. The &lt;a href="https://download.samba.org/pub/rsync/rsync.1">rsync man page&lt;/a> is explicit about the rule: &amp;ldquo;if the pattern starts with a / then it is anchored to a particular spot in the hierarchy of files, otherwise it is matched against the end of the pathname.&amp;rdquo;&lt;/p>
&lt;p>The fix is one character: &lt;code>--exclude=/log/&lt;/code> anchors the pattern to the transfer root. A second rsync run then sent only the &lt;code>log&lt;/code> directories it had wrongly skipped inside the code, a few seconds of incremental transfer.&lt;/p>
&lt;h2 id="testing-against-live-integrations-without-side-effects">Testing against live integrations without side effects&lt;/h2>
&lt;p>Queue workers and cron are what produce outbound effects; a web tier on top of a copied database is safe to start. This app talks to a real payment provider, CRM, SMTP, and ClickHouse, and the test copy had to come up without firing any of that. The compose setup was already split into &lt;code>docker-compose.yml&lt;/code> (web) and &lt;code>docker-compose.queue.yml&lt;/code> (workers and scheduler), so the whole safety switch was one environment variable:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd;font-style:italic">COMPOSE_FILE&lt;/span>&lt;span style="color:#ff79c6">=&lt;/span>docker/docker-compose.yml docker compose up -d
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Web and API up, every queue worker and the scheduler down, database an isolated copy. Smoke test: API returned 200, the site returned its usual 302, the admin backend rendered the login page.&lt;/p>
&lt;p>One integration still broke: ClickHouse refused connections from the new node&amp;rsquo;s IP (&amp;ldquo;Connection refused&amp;rdquo;), and backend pages that touch it answered 503. External allowlists (database, mail, analytics) are part of the migration checklist, not something to discover during cutover.&lt;/p>
&lt;h2 id="step-by-step">Step by step&lt;/h2>
&lt;ol>
&lt;li>Establish the disk&amp;rsquo;s failure mode first: &amp;ldquo;reads work, writes hang&amp;rdquo; changes every decision after it.&lt;/li>
&lt;li>Copy &lt;code>/root/.docker/config.json&lt;/code> from the source to the target; &lt;code>docker compose pull&lt;/code> the production images.&lt;/li>
&lt;li>Stream the database: &lt;code>ssh -T source 'docker exec db pg_dump -Fc -U app appdb' &amp;gt; dump&lt;/code> on the target.&lt;/li>
&lt;li>Restore with &lt;code>pg_restore --no-owner -j 4 -d appdb&lt;/code>.&lt;/li>
&lt;li>Rsync code and media with anchored excludes only (&lt;code>--exclude=/log/&lt;/code>, not &lt;code>--exclude=log/&lt;/code>).&lt;/li>
&lt;li>Bring up the web tier alone via &lt;code>COMPOSE_FILE&lt;/code>, leaving queues and cron down.&lt;/li>
&lt;li>Smoke-test, and collect every external service that needs the new IP allowlisted.&lt;/li>
&lt;li>Cut over later in a quiet window: fresh dump, DNS, allowlists.&lt;/li>
&lt;/ol>
&lt;h2 id="bottom-line">Bottom line&lt;/h2>
&lt;p>Dying hardware changes the rules: the source is read-only whether you accept it or not, so structure the whole migration around that. Stream the dump instead of writing it locally, pull images instead of rebuilding EOL bases, and anchor rsync excludes before they cut into &lt;code>vendor/&lt;/code>. The copy went from &amp;ldquo;disk is dying&amp;rdquo; to a responding production clone in a few hours, and most of the lost time was me fighting battles (EOL apt archives, a phantom vendor corruption) that the constraints had already decided.&lt;/p></content:encoded></item></channel></rss>