Skip to content

validate: harden the static staleness probe - #1284

Open
MDA2AV wants to merge 1 commit into
mainfrom
harden/static-staleness-probe
Open

validate: harden the static staleness probe#1284
MDA2AV wants to merge 1 commit into
mainfrom
harden/static-staleness-probe

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Follow-up to the rule change in #1267 / #1277, prompted by #1279: if entries are going to serve bodies from a framework cache, the probe that certifies "follows the disk" has to actually mean something.

Three gaps. Each was found by building a synthetic entry that cheats in exactly that way — all three passed the old probe clean.

1. The replacement changed length, so a size-only cache passed

The probe changed content and length, which made the change as easy to detect as possible. A body cache validated on size alone therefore never noticed.

It now replaces with byte-for-byte the same length, so only a cache keyed on mtime or content sees it — which is what a real framework cache uses. fulmine's, for instance, still passes: it compares the stat the request already paid for.

2. The compressed path was never tested at all

The target was hero.webp — no .br/.gz twin — and the request forced identity.

After the pre-compressed rule change that is the minority path. Validate itself reports 15 of 20 files compressed, and the compressible text is 743 KB of the 842 KB total. An entry could cache .br bodies forever and pass.

There is now a second probe on app.js that replaces the original and both twins together, asking with the q-valued Accept-Encoding the load generator actually sends.

3. The window was 6× longer than a measured run

DURATION=5s (common.sh:22). At 30s, a cache with any TTL under that was never revalidated while the numbers were taken — 30s, 300s and "never" were indistinguishable.

Measured, not argued. A pure 10s TTL cache with no disk check at all:

window result
30s PASS — "served in 10s"
2s FAIL

So the framework window is now 2s: a tolerance for a slow first request or an inotify debounce, not a staleness budget. Nothing legitimate needs the room — every compliant entry measured so far flips on the very next request. Node, Rust, Elixir, Clojure, JVM, PHP, Lua, Perl, C++: all 0s, across 20 observations.

It stays HTTPARENA_STATIC_STALE_WINDOW-overridable.

Infrastructure keeps 30s

Its rule explicitly allows open_file_cache and mmap — "serving files fast from a tuned cache is the job" — and says nothing about following the disk. The tier was being held to a requirement it does not carry. None of nginx/caddy/h2o/varnish configure open_file_cache today, so they passed by luck; the first one to add it would have been failed for doing what its rule invites.

Synthetic results

entry behaviour identity probe variant probe
reads per request PASS PASS
body cache keyed on size only FAIL ← new PASS
caches .br forever, reads original per request PASS FAIL ← new
plain 10s TTL, no disk check FAIL ← new PASS

Also

The comparison is now against what the server served a moment earlier rather than against the file on disk, so it holds whether the entry answers with the original, a pre-compressed variant, or something it compressed itself.

validate: bun 70/0, caddy 53/0.

One thing to decide

If you would rather allow 30s staleness for framework entries too, that is a one-line change — but the rule text needs to move with it. It currently says "replace a file and the next response must carry the new bytes", which 30s does not enforce.

🤖 Generated with Claude Code

Three gaps, each demonstrated against a synthetic entry that cheats in
exactly that way, and each of which the old probe passed clean.

1. Same-length replacement. The probe used to change the content *and* the
   length, which made the change as easy to detect as possible. A body cache
   validated on size alone therefore passed. The replacement is now
   byte-for-byte the same length, so only a cache keyed on mtime or content
   sees it -- which is what a real framework cache uses.

2. The compressed path was never tested. The target was hero.webp, which has
   no .br/.gz twin, and the request forced identity. After the pre-compressed
   rule change that is the minority path: 15 of the 20 files are served
   encoded, and the compressible text is 743 KB of the 842 KB total. An entry
   could cache .br bodies forever and pass. There is now a second probe on
   app.js that replaces the original and both twins together and asks with
   the q-valued Accept-Encoding the load generator actually sends.

3. The window was six times longer than a measured run. DURATION is 5s, so a
   cache with any TTL under 30s was never revalidated while the numbers were
   taken -- 30s, 300s and never were indistinguishable. Measured: a pure 10s
   TTL cache with no disk check at all passes at 30s ("served in 10s") and
   fails at 2s. The framework window is now 2s, which is a tolerance for a
   slow first request or an inotify debounce rather than a staleness budget.
   Every compliant entry measured so far flips on the very next request --
   Node, Rust, Elixir, Clojure, JVM, PHP, Lua, Perl, C++, all at 0s -- so
   nothing legitimate needs the room.

Infrastructure keeps 30s. Its rule explicitly allows open_file_cache and
mmap and says nothing about following the disk, so the tier was being held to
a requirement it does not carry. None of the four configure open_file_cache
today, so they passed by luck; the first one to add it would have failed.

The comparison is now against what the server served a moment earlier rather
than against the file on disk, so it holds whether the entry answers with the
original, a pre-compressed variant, or something it compressed itself.

validate: bun 70/0, caddy 53/0.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant