<p>There's a particular kind of bug I've come to dread more than crashes. A crash tells you something is wrong. This other kind logs a warning, carries on, and looks — from the outside, from your dashboard, from your uptime monitor — exactly like everything working.</p><p>Askr 1.5.0 is mostly about that kind. I didn't set out to write a release with a theme. It emerged from reading the code adversarially for a few days, and noticing how many findings had the same shape.</p><h2>The bug that named the release</h2><p>Start with the worst one, because it's also the clearest.</p><p>Askr has a shared-memory cache — a fixed-slot hash table in an anonymous mmap that every worker process sees. It's what lets you run Laravel sessions, cache, locks and counters without Redis. Writing to a slot takes a per-slot spinlock. Reading did too.</p><p>Two workers calling <code>set()</code> on the same key can pick different slots. The probe loop holds one slot lock at a time, so one worker can find a slot empty that the other has since filled, or the two can disagree about which entry is oldest because <code>written_at</code> moved underneath them. Fine — last writer wins, no corruption.</p><p>Except <code>delete()</code> looked like this:</p><pre><code class="language-rust">if Self::matches(e, key, h) {
    ptr::write(ptr::addr_of_mut!((*e).state), 2);  // tombstone
    return true;                                    // ← and stop
}
</code></pre><p>It stopped at the first match. So it tombstoned one copy and left the other alive. And <code>get()</code> scans past tombstones by design, so the next lookup found the survivor.</p><p>For a cache entry that's a stale value. For a session key, that's a user who logged out and is logged back in.</p><p>What makes this my favourite finding is the comment that was sitting at the end of <code>set()</code>:</p><pre><code class="language-rust">// Re-validate under the lock: if a racing writer already put *our* key
// somewhere we'd now clobber, last-writer-wins is still correct; we just
// write our value. (Closes the evict/target race window.)
</code></pre><p>The comment describes exactly the fix that was missing. The code below it did no re-validation at all. Somebody — me, at some point — reasoned correctly about the race and then wrote something else.</p><p>The fix: <code>delete()</code> clears every match in the chain, and <code>set()</code> sweeps afterwards so duplicates converge instead of accumulating. The race isn't reproducible on demand, so the regression test plants the duplicate directly and asserts the consequence:</p><pre><code class="language-rust">assert!(delete(key), "delete must report that it removed something");
assert_eq!(get(key), None, "a deleted session that reappears is the bug");
</code></pre><p>I ran that test against the old <code>delete()</code> before shipping it. It fails. A test that hasn't failed once is a decoration.</p><h2>Warnings are not controls</h2><p>Once you're looking for "logs and carries on", it's everywhere.</p><p><strong>The sandbox.</strong> <code>--sandbox</code> installs a seccomp filter and, with write paths configured, a Landlock filesystem restriction. When a kernel is missing a feature:</p><pre><code class="language-rust">Err(e) =&gt; tracing::warn!(error = %e, "seccomp: not applied"),
</code></pre><p>…and the worker serves traffic looking identical to one that hardened successfully. Nothing downstream can tell.</p><p>That default is staying — an upgrade that suddenly refuses to boot is worse than a warning — but you can now opt out of it:</p><pre><code class="language-toml">[server]
sandbox = true
sandbox_write = ["/var/www/app/storage", "/tmp"]
sandbox_required = true    # refuse to serve unhardened
</code></pre><p>A worker that can't fully harden exits <code>78</code> instead of serving, and the crash-loop guard turns a fleet-wide failure into one clear "giving up".</p><p>There's a detail here I like. <code>sandbox_required</code> refuses to start without <code>sandbox_write</code>, and that's not pedantry. Seccomp blocks <code>execve</code> — but Askr interprets PHP in-process. A <code>.php</code> file written into your docroot needs no process creation at all. Seccomp-only "hardening" doesn't stop the thing most people are actually afraid of, so a required sandbox without Landlock write rules would be a promise the sandbox can't keep.</p><p><strong>The canary.</strong> Askr can roll one worker first and compare it against the rest of the fleet before rolling the others. <code>SIGHUP</code> went through that gate. <code>trigger_reload()</code> — which is the admin API, an ACME renewal, and the cert-watcher — called <code>roll_next()</code> directly:</p><pre><code class="language-rust">pub fn trigger_reload() {
    RELOAD_CURSOR.store(0, Ordering::SeqCst);
    roll_next();               // no gate, no health check, whole fleet
}
</code></pre><p><code>[reload] canary = true</code> guarded the deploy you were watching and nothing else. The two reloads that happen with nobody watching were the two that skipped it.</p><p><strong>The crash-loop guard.</strong> It counted a worker that exited non-zero shortly after boot. A worker killed by <code>SIGSEGV</code> fell into the healthy branch — and cleared the streak counter. So a segfaulting worker respawned forever, and a fleet mixing fatals and faults never accumulated a streak at all. The guard existed to stop exactly the loudest version of what it was ignoring.</p><h2>Loopback is not a person</h2><p><code>PURGE</code> and <code>BAN</code> invalidate the response cache over HTTP. With no <code>ASKR_ADMIN_TOKEN</code> set, they were accepted from loopback peers — a sound rule for a server that's its own front door.</p><p>Behind nginx or Caddy on <code>127.0.0.1</code>, every request is a loopback peer.</p><pre><code class="language-bash"># from anywhere on the internet, through your proxy:
curl -X BAN -H 'X-Ban-Url: /*' https://example.com/
</code></pre><p>Now <code>trusted_proxies</code> — the operator stating in writing that loopback is where the proxy sits — makes the token mandatory for those methods. If you have <code>trusted_proxies</code> set and no token, cache invalidation will start answering <code>403</code> after you upgrade. That's the fix working; set the token.</p><p>The admin plane had two more of these. It never looked at <code>Host</code>, so a page on an attacker's domain could re-resolve its own hostname to <code>127.0.0.1</code> and read <code>/api/status</code> — PIDs, memory, error records — as same-origin. Nothing about that request looks cross-site, because to the browser it isn't. The only thing that gives it away is <code>Host: evil.test</code> naming a listener that isn't called that.</p><p>And <code>POST /api/reload</code> is a CORS "simple request": no custom headers, so no preflight, so CORS never got a say and any web page could roll your fleet. Both checks now apply whether or not a token is configured, because both attacks work fine against a plane that never had one.</p><h2>Measure first, and let the benchmark surprise you</h2><p>The one feature in this release started as a suspicion: <code>get()</code> took the slot lock exclusively, so every worker reading the same hot key — a session, a shared config blob, anything Laravel touches per request — queued behind the others.</p><p>Before touching the lock, I wrote a benchmark. Both read paths, measured in the same process, one hot key:</p><pre><code class="language-text">one hot key, 512 byte value:
  threads        locked      lock-free      speedup
        1      22085798       22860599         1.0x
        4       6018470       75000259        12.5x
       12       1547473       19119338        12.4x
</code></pre><p>The locked column is the finding. Throughput doesn't plateau as readers arrive — it falls, 22M/s to 1.5M/s, because a spinlock under contention burns cycles instead of waiting. Adding workers made reads slower in aggregate.</p><p>Reads are now lock-free, via a seqlock: sample a per-slot version counter, copy, sample again. A change means a writer overlapped the copy, so retry; after a bounded number of attempts, take the lock. Writes are unchanged.</p><p>Two things I'd have missed without measuring first.</p><p>The benchmark caught a regression it wasn't looking for. My first version allocated with <code>vec![0u8; vlen]</code>, which zeroes the buffer before the copy. A 4 KB value read by a single thread came out at 0.8× of the locked path it replaced — purely from the extra pass over the page. I'd have shipped a slower cache for large values and called it an optimisation.</p><p>And the counter can't just be incremented. A writer killed mid-update leaves it odd forever; an increment would then make it even during the next write, and a reader would sample a stable-looking counter in the middle of a copy. So <code>Writing::begin</code> forces it odd and <code>Drop</code> forces it even-and-greater, which repairs the slot instead.</p><p>How do I know the seqlock works? I broke it and watched the test catch it. With the two counter samples removed: 584,533 of 2,145,847 reads came back half-written. With them, zero.</p><h2>Two fixes that only make sense together</h2><p>A panic unwinding out of an <code>extern "C"</code> function aborts the process. Askr had 35 of them and no <code>catch_unwind</code> anywhere, so a panic at the FFI boundary killed the worker mid-request. Each now runs inside a guard that answers the caller's failure value — a cache miss, a refused push, a 502 — and logs the entry point by name.</p><p>But: catching a panic mid-<code>write()</code> would have run <code>Writing</code>'s destructor during unwinding, marking a half-updated slot as stable to the brand-new lock-free readers. The panic guard would have introduced torn reads.</p><pre><code class="language-rust">fn drop(&amp;mut self) {
    if std::thread::panicking() {
        return;   // leave it odd; readers take the lock, next writer repairs
    }
    unsafe { (*self.0).version.store(self.1, Ordering::Release) };
}
</code></pre><p>Neither fix is wrong alone. Together, in the wrong order, they'd have been a bug. That interaction is only visible if you're holding both.</p><h2>"Verified self-update" now means something</h2><p><code>askr upgrade</code> runs as root and replaces the binary systemd starts. It fetched a tarball and its <code>.sha256</code> — from the same release.</p><p>A checksum that travels with the file it describes proves the download arrived intact and nothing about who produced it. A compromised release, account or CI token serves a matching pair. That was the entire trust chain.</p><p>Releases are now signed with minisign, and <code>upgrade</code> verifies the signature against a public key compiled into the binary — streamed, so a 30 MB tarball is never held in memory to check it. A bad signature, a signature from another key, or no signature at all is a refusal.</p><pre><code class="language-text">$ askr upgrade
askr 1.4.14 → 1.5.0
↓ downloading askr-1.5.0-linux-x86_64.tar.gz …
· verifying sha256 …
· verifying signature …
✓ signed by the key this build trusts
· extracting …
</code></pre><p>Verify it yourself, if you'd rather not trust the updater:</p><pre><code class="language-bash">minisign -V -p keys/release.pub -m askr-1.5.0-linux-x86_64.tar.gz
gh attestation verify askr-1.5.0-linux-x86_64.tar.gz --repo kwhorne/askr
</code></pre><p>The key lives in the repository and is <code>include_str!</code>'d into the binary, which is the point: changing what an install trusts means changing the source and getting it built and released. I don't hold that key and neither does any tool — it was generated by its owner, on their machine, and only its public half exists anywhere I can see.</p><p>Oh, and one more: this release was blocked for an afternoon because <code>cargo audit</code> was red. <code>h2 0.4.15</code>, RUSTSEC-2026-0258 — a peer can hold an HTTP/2 connection open with unbounded empty DATA frames. A denial of service against the transport Askr serves on by default. Now on <code>0.4.19</code>. That one was found by asking why a red CI badge was red, which is a habit worth more than most tooling.</p><h2>What isn't fixed</h2><p>Six things are still open, and I'd rather name them than let you find them.</p><p>The queue's <code>delete</code>/<code>release</code> are unfenced against an expired lease — a delayed worker can ack a job another worker is running. The fix needs a lease token threaded through <code>shim.c</code>, the Rust bridge and the Laravel driver, and I'm not burying a three-layer signature change in a batch of small fixes.</p><p>The response cache refuses what it can't vary on rather than varying on it. If your app sends <code>Vary: Accept-Language</code>, that response is no longer cached at all. Doing it properly needs a two-level lookup in the cache, because the primary key can't be computed from the request alone once the response gets a say. Correctness costs hit rate on exactly the responses that were being served wrong.</p><p>And the Landlock ABI is still pinned to V1, below what current kernels offer. I could have written the negotiation code. I couldn't compile it — Landlock is a Linux-only dependency and the C shim needs a Linux <code>cc</code> to cross-check — and guessing at a crate API in a security path is how you ship a build break. It's waiting on a Linux build in the loop, and that's a better reason than a shipped guess.</p><h2>Upgrading</h2><p>Nothing is required. Two things are worth adopting deliberately, because upgrading alone won't turn them on: <code>sandbox_required</code>, and <code>ASKR_ADMIN_TOKEN</code> if you sit behind a local reverse proxy.</p><p>Three behaviour changes aren't configurable: responses carrying their own <code>Vary</code> aren't cached, scheme is part of the cache key (expect a one-time hit-rate dip if <code>force_https</code> is off), and underscored header names are dropped — <code>X_Forwarded_For</code> no longer becomes <code>HTTP_X_FORWARDED_FOR</code> in <code>$_SERVER</code>, because it collided with the dashed spelling and bypassed anything filtering it. Same default nginx ships.</p><p>130 unit tests, 21 end-to-end, <code>cargo audit</code> clean, and the full details in the changelog.</p><p>Thanks for reading. Go check whether your warnings are controls.</p>