Skip to content
Pipfold

The installed version re-downloaded 39 MB on every open

· shipping · tooling

A PWA makes exactly one promise: load once, then it starts. Till had installed Pipfold on an iPhone, and on every open the game downloaded again. So the promise was broken — and with it the argument for why iOS does not need an app in the store.

Four bugs, independent of each other. Two of them would have sufficed alone.

1. The big chunk never reached the cache

Measured in the browser: eight entries in the cache, usage 2.3 MB instead of 39.

The service worker had everything except index.wasm — 37.7 of 38.9 MB, so 97 % of the payload. The 1.2 MB index.pck got through, the wasm never did.

Godot stores like this:

cache.put(req, response.clone())

clone() yields a stream body. And Cache.put() rejects a stream over roughly 16 MB. index.wasm is 37.7 MB and therefore failed every time; the 1.2 MB of the .pck sit far below the threshold and arrived.

So it is not a race and not an intermittent fault, but a hard threshold — reproducible, every time, since day one.

And it was invisible. Godot calls cache.put(...) with neither await nor catch — the error vanishes as an unhandled promise rejection. No message, no warning. On top of that, console.error from a service worker does not land in the page console. Whoever looks there sees nothing and concludes nothing happened.

On a development machine it never showed either, because the ordinary HTTP cache revalidates with an ETag: 304 instead of 37 MB. The worker was empty and the game still started instantly. On an iPhone, iOS evicts a single resource that large quickly — there the worker was the only thing that could have held the file, and it was precisely the one that did not have it.

Fixed in deploy.sh, which post-processes the generated worker anyway: the response is buffered once in full and both consumers are served from it — cache and game. No clone, no race.

A first attempt only stored the clone in the background (event.waitUntil(manaCachePut(...))). In the source that looks like the fix: storing no longer blocks, the error is caught. In the browser the cache stayed at 2.2 MB. Measured against the result, not against the source — otherwise it would have passed as “fixed” and the whole thing would have sat there for another fortnight.

2. The patch never reached the browser

It still had no effect at first.

Reason: /spielen/index-sw-*.js matched no rule in the Caddyfile. The neighbouring entry /index-sw-*.js only hits the root — Caddy’s path compares the whole path, not the ending. Without Cache-Control Cloudflare applied its zone default: max-age=14400, cf-cache-status: HIT. The patch was on the origin, the old version in the browser.

That is worse than it sounds. The loader carries a fingerprint in its name, but the worker only as long as the .pck changes — the fingerprint is derived from it. Whoever touches only the worker ships under the same name. Which is exactly the case here: working on a bug in the worker means using the one route along which the change cannot arrive.

Fixed: /spielen/index-sw-*.js added to the @frisch matcher, edge purged once.

3. The zone overwrote all our headers

browser_cache_ttl was set to 14400 for the whole pipfold.com zone.

That made every carefully placed header ineffective: no-cache on the entry pages became four hours — and the download packages got four hours instead of immutable (one year).

This is the same bug as on 25 July, only on a different zone. A trap you have understood once snaps shut again on the next hostname, because the setting is per zone and a new domain brings it along fresh.

Set to 0 (“respect origin”). The previous value sits in ~/.config/mana/cf-browsercachettl-pipfold-vor-20260819.json.

4. Installing from the front page produced only a bookmark

The fourth bug is the one most likely to be mistaken for user error: Till had installed from pipfold.com/ — from the landing page, not from the game.

And the landing page had no manifest and no apple meta tags. On an iPhone that produces a plain web clip: no app identity, no scope, no claim on the game’s cache.

Fixed: both landing pages (/ and /en/) now point at the same manifest as the game. Deliberately not at one of their own — the browser derives the identity of an installed app from start_url, and a second manifest would be a second app on the same home screen, with its own storage and its own save file.

What is general about this

Four bugs along one path, and three of them do not announce themselves.

An unhandled promise rejection in a service worker, a Caddy rule that misses a path, and a zone setting in somebody else’s control panel have one thing in common: they behave exactly like the normal case. Nothing is red, nothing is slow, nothing is in the log. It is only slower than it needs to be, and you notice that when somebody uses it.

So at the end of this story sits tools/pwa_pruefen.mjs. It checks three things: that the shipped worker carries the patch — not the one in the repository —, that it arrives with fresh headers at all, and that Cache.put accepts 40 MB as a blob, so the mechanism holds.

🔴 What it cannot do: reproduce the installed state. Godot registers the worker only about 30 seconds after loading, and headless navigator.serviceWorker.controller stays empty — tried with a persistent profile, it does not work. The final state has to be checked by hand once, and the guard prints the recipe itself:

await navigator.storage.estimate()   // usage must be > 30 MB

A guard that says honestly where it stops is worth more than one that pretends to cover everything. The second kind is exactly what could have missed this bug for six weeks.

← Devlog