NOTE 0013 / PROOFFRAME
The stamp covered the worker, not the engine
I shipped ProofFrame 0.7.2, opened the page, and the browser told me it was running 0.7.1. Everything about that sentence turned out to be true, which is why it took a while to believe.
The page has a live demo: the engine is compiled to WebAssembly and runs in the tab, so nothing is uploaded. Above the drop zone sits a badge, and the badge does not print a constant. It calls engine_version on the module that is actually loaded. The copy next to it says so out loud — the version it reports below is the build you are running — which is a good thing to promise right up until it starts telling on you.
It said proofframe 0.7.1 · wasm.
Everything I checked was fine
The obvious suspects went first. The built page carried 0.7.2 in twenty-five places and 0.7.1 in none. The crate had been bumped. The module had been rebuilt from the bumped source — Compiling proofframe-wasm v0.7.2 is in the build log. I pulled the file off the local server and compared it, byte for byte, with the artifact on disk:
curl .../proofframe_wasm_bg.wasm | cmp - assets/proofframe/wasm/proofframe_wasm_bg.wasm # identical
So the server was sending 0.7.2, the page was asking for it, and the engine was answering 0.7.1. Only one thing can sit in that gap.
The cache the page already knew about
This page had met this problem before. The engine does not ship inside the app bundle: it lives in a sibling directory as two files — a worker and, next to it, the WebAssembly module the worker imports. They are served as static files with a ten-minute cache, and nothing makes a browser revalidate them together. So an earlier release had already produced the loud version of this fault: a refreshed page talking to a stale worker, calling an operation the worker had never heard of, which reached the reader as unknown operation.
The fix for that was a content stamp. The worker is requested as linter-worker.js?v=<hash of the worker>, so a new page asks for a new worker and an old page keeps the one it was written against. There is a test that fails if the worker changes and the stamp does not. The comment above the constant explains the whole reasoning, and it is a good comment.
The stamp was c77cc6c9d3b7. It had been c77cc6c9d3b7 before I replaced the engine, and it was still c77cc6c9d3b7 after. The test agreed. Nothing had changed, because nothing the stamp watched had changed: I had rebuilt the engine without touching a line of the worker.
The stamp was doing exactly what it was written to do. What it was written to do covered one of the three files that have to move together.
Why a relative import loses it
The worker imported the engine the way you would expect:
import init, { engine_version, check_csv, … } from "./wasm/proofframe_wasm.js";
A relative specifier resolves against the importing module's URL, and resolution keeps the path while dropping the query. The worker is fetched as linter-worker.js?v=c77cc6c9d3b7; the module it imports is fetched as wasm/proofframe_wasm.js, bare. The stamp stops at the first hop.
And one hop further down, wasm-bindgen's generated glue locates the binary itself:
module_or_path = new URL('proofframe_wasm_bg.wasm', import.meta.url);
Same construction, same result. Three files that must agree, one of them stamped.
What made this hard to see is that the failure is quiet. The stale pair from the earlier bug crashed: the worker was asked for something it did not implement, and somebody got an error. This one runs. The module loads, every operation works, every answer is correct for the code that produced it. The only symptom is a version string one release behind — and a version string is exactly the kind of thing you read past, because you know what it is supposed to say.
The fix
Carry the stamp through by hand, which means dynamic imports, because a static specifier cannot take a runtime value:
const STAMP = new URLSearchParams(self.location.search).get("v");
const query = STAMP ? `?v=${STAMP}` : "";
const { default: init, engine_version, … } =
await import(`./wasm/proofframe_wasm.js${query}`);
const started = init({
module_or_path: new URL(`./wasm/proofframe_wasm_bg.wasm${query}`, import.meta.url),
}).then(engine_version);
And widen the stamp to the set it claims to protect. It is now a hash of all three files, and it is derived rather than typed: a small script reads the bytes and rewrites the constant, and the build runs it before it builds. A number a human has to remember to change is a number that is eventually wrong, and this one had been wrong for a release without anything noticing.
The test moved with it. It hashes the same three files, and it also asserts that the stamping script covers those three — otherwise the two agree on a number while disagreeing about what the number means. It checks that the worker no longer contains a bare specifier for the module, because that is the exact line that failed.
What I took from it
The comment above the stamp was accurate, specific, and honest about the problem it had solved. It described a hazard in terms of the incident that produced it — the crash — and so the guard it justified was shaped like that incident. The quieter form of the same hazard was never in view.
The test had the same shape. It said bump the stamp after changing the worker, which is true, and which is not the invariant. The invariant is that everything fetched as one unit revalidates as one unit. Written that way, the missing file is obvious; written as a rule about one file, it passes.
There is a version of this in most caching setups: the thing you stamp is the thing you remember to stamp, and it is usually the entry point, and the entry point is usually not the part that changes. A demo that reports its own version is a cheap way to find out — provided you believe it when it disagrees with you.
The badge says 0.7.2 now.