From 1ba91bb4f38f6338d1c86678ebcd19195db2a120 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 8 Sep 2026 23:22:39 +0200 Subject: docs: three lessons from the download freeze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The service worker one is the expensive one: an idle worker is terminated and a streaming response does not count as activity, so `writable.write()` hung with no error anywhere while the node stayed healthy. Written down with what made it unfindable — a stress probe fast enough to hide it, three measurements that correctly cleared everything they touched, and an empty console that was the evidence rather than the absence of it. The other two are the framing headers (three of them, all visible in one `curl -I`, fixed one at a time over an afternoon) and `encodeURIComponent` leaving the apostrophe that RFC 5987 uses as its delimiter. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST --- CLAUDE.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) (limited to 'CLAUDE.md') diff --git a/CLAUDE.md b/CLAUDE.md index f06281e..b02eda7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -511,6 +511,47 @@ anything that assumes one key per person. Nothing errored. When a chain degrades, check what the floor costs on every platform that will reach it +- **A service worker with nothing to do is killed, and a streaming response is + not "something to do".** Firefox terminates an idle worker after about thirty + seconds; `event.respondWith(new Response(stream))` does not extend its life + while the page is still writing to that stream. So the reader vanished + mid-file and `writable.write()` **never resolved and never rejected** — no + error, no log, no failed transfer, just a progress bar that stopped near the + end. Measured 2026-09-08 in Firefox 154, writing 1 MB every 2 s: stalled at + 17 MB after 59 s; with a 10-second ping to the worker, 40 MB in 80 s, + complete. The page pings while it writes and the worker answers, because + receiving a message is the event that resets the timer. + Three things this cost, all worth remembering. **The first stress probe wrote + 450 MB in two seconds and passed** — fast enough to hide the bug entirely, so + a probe for anything time-based has to be paced like the real thing. **The + node was innocent and three measurements proved it** (615 MB pulled whole + over MNP, three files interleaved on one connection, three concurrent worker + streams), which is exactly what made the fault unfindable: nothing was wrong + anywhere anyone looked. And **the empty console was the evidence**, not the + absence of it: `_sendAndWait` logs every timeout, so silence eliminated + everything that reports itself and left the one `await` on that path with no + bound. Every await on a download path is now bounded and says which chunk it + gave up on — an unbounded one is a freeze nobody can report + +- **Three headers decide whether a page may frame itself, and they must agree.** + The same streamed download navigates a hidden iframe to `/_mbdl/`. + `frame-src` was reCAPTCHA's two origins with no `'self'`, `frame-ancestors` + was `'none'`, and `X-Frame-Options` was `DENY`. Each was fixed in turn, each + time costing a redeploy and a retest, and **all three were visible in one + `curl -I` against the deployed hub** — which is where that should have + started. `'self'`/`SAMEORIGIN` refuse every foreign origin exactly as + `'none'`/`DENY` do; what they add is this origin framing itself, which is all + the download needed. When a symptom points at a mechanism, enumerate + everything that governs that mechanism and check the set at once + +- **`encodeURIComponent` does not escape `'`, and `'` is RFC 5987's delimiter.** + `Content-Disposition: filename*=UTF-8''` became unparseable for any + name with an apostrophe, so the browser named the file after the URL: 449 MB + of film arrived complete and correct, called `mtsshk9w-ohqty535`. `(`, `)` and + `*` are excluded from attr-char for the same reason. A plain ASCII + `filename=` rides alongside now, so the next surprise loses accents rather + than the name + - **Two elements each claiming `100vh - 52px`, one inside the other's padding.** `.layout` and `.page-center` both reserved the viewport below the header, and `main`'s `24px` top and bottom were added on top — a permanent 48px scrollbar -- cgit v1.2.3