Engineering · Reliability · Testing

The stamp that ate a closing tag

Our build stamps every script address with a hash. Written inside a sentence, one address matched too far and the page shipped with an unclosed tag.


Every script and stylesheet on this site is served under an address that carries a hash of the file. The build writes those hashes in, so a till that has downloaded a file once never downloads it again until the file really changes. It is a good rule and it has saved phones on bad signal a great deal of waiting. The build does it by searching every page for a path that looks like one of ours and rewriting the bit after the question mark.

The day before this, we had bounded a sister transform because it was editing prose. A blog post that mentioned a version stamp in passing had its number quietly rewritten to the current release, every build, for weeks. That was a number changing. The obvious next question was whether the other document-wide rewrites could do the same, and one of them could do something worse.

What a path inside a sentence looks like to a matcher

The hash stamp is matched as a run of characters that are not a quote, an ampersand, whitespace or a closing angle bracket. That is a sensible class for an attribute value. Inside a sentence it is not, because a sentence that shows a reader an address typically wraps it in a code tag, and the run that follows the stamp is the slash and the letters of the closing tag. The matcher admits all of those. So an example written as app.js with a stamp, closed with a code tag, matched from the stamp right through to the angle bracket at the end of the closing tag, and the replacement put a fresh hash where the closing tag used to be.

The published page would have carried an unclosed code element. Every paragraph after it would have rendered in monospace. That is strictly worse than the first fault, which only changed a number nobody checked.

Two more failure modes came out of the same driving session. A path mentioned in prose that does not exist as a file killed the build with a message about a missing file, which sends whoever reads it hunting through the assets directory when the cause was a sentence. And the build's own count of stamps reported one that nobody had written, which would have failed the test that checks every stamp on every page against the file on disk, reading exactly like a stale build.

A trap, not a live bug, and said so

Nothing on the site was tripping any of this. We checked: no sentence anywhere carries a script or stylesheet path with a stamp on it. That is the honest difference from the day before, when a real page had been quietly wrong for weeks. We closed it anyway, for one reason. The post about caching currently shows a bare file name with a stamp as its example, and the one edit an editor would naturally make is to turn that into the realistic full path. We made that edit on a copy and watched it eat the closing tag silently. A trap that sits one obvious edit away from a live page is worth springing on purpose.

The fix is the same bound as the day before, plus the angle bracket removed from the character class. The rebuild afterwards changed zero of the 284 generated pages, byte for byte, which is the proof the narrowing costs nothing real. A transform that becomes stricter and changes nothing it was already handling correctly is the only kind of narrowing you can ship without a second thought.

The limit we could not close, written down as a test

One case still matches. A raw double quote inside a code block, showing the reader an attribute exactly as it would be written, is at that point character for character a real attribute, and no pattern can tell the two apart. Escaped markup is safe, because an ampersand ends the run. Rather than pretend that case is handled, it is asserted as a known limit, so the next person who hits it finds a test naming it instead of a mystery.

Six tests went in, including the pair that matters most: a real reference is still stamped, and a real missing reference still stops the build. That second one is the guard against shipping an address that answers not found for a year under an immutable cache header. We reverted the bound and watched four tests fail by name before putting it back. A fix that cannot be proven to bite is a comment with extra steps.

The lesson generalises past this build. Anything that rewrites a whole document by pattern will eventually meet its own pattern inside a sentence about itself. The time to find out what it does there is before an editor does.


Try it on tonight’s service.

Nothing to install, no card. Not better by the weekend? Close the tab.