From 724764c8dfc5ebcc0bc52f6ce10f01b9e6856aba Mon Sep 17 00:00:00 2001 From: rays Date: Tue, 13 Oct 2026 12:16:50 +0000 Subject: [PATCH] Page: catch background fetches, no HTML in errors, a trace ends after a quiet minute (#219) The page reported NetworkError rejections from the live update's list refresh and the first feed load, which had no catch, whenever the network was gone (a laptop asleep). A Cloudflare 502 page became a request's error message, since api() used whatever body came back. And the trace id lived until the next tap, so background requests nine hours apart shared one trace. The id now ends after a minute without a request; a play's position saves, every ten seconds, keep it going. Co-Authored-By: Claude Opus 5.5 --- tests/page-smoke.js | 12 ++++++++++++ web/CHANGELOG.md | 4 ++++ web/src/events.ts | 5 +++-- web/src/util.ts | 19 ++++++++++++++----- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/tests/page-smoke.js b/tests/page-smoke.js index ca1c716..8a810b1 100644 --- a/tests/page-smoke.js +++ b/tests/page-smoke.js @@ -68,6 +68,18 @@ for (const [name, fn] of drive) { } } +// A trace is one tap's (#219): the same id while requests keep coming, a new one after a quiet +// minute, so a refresh hours later is not filed under the last tap. +if (PAGE === 'index.html') { + const id = () => ctx.traceparent().split('-')[1]; + vm.runInContext('Date.now = (n => () => n)(1e12)', ctx); + const first = id(); + vm.runInContext('Date.now = (n => () => n)(1e12 + 30_000)', ctx); + if (id() !== first) { console.error('FAIL: a trace ended 30 s into a tap'); process.exit(1); } + vm.runInContext('Date.now = (n => () => n)(1e12 + 120_000)', ctx); + if (id() === first) { console.error('FAIL: a trace outlived a quiet minute'); process.exit(1); } +} + // theme.ts keeps the Settings theme controls in step when Settings is open, and looks before it // touches them. The admin page has no Settings, so those are the ones it may ask for and not find. const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield']); diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 82d8865..bc19f06 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- A live update or a list refresh that fails because the network is gone (a laptop asleep) is no longer reported as an error, an error page from Cloudflare no longer becomes a request's message, and requests a minute or more after a tap start a trace of their own instead of joining that tap's (#219). + ## [1.0.0] - 2026-10-12 ### Added diff --git a/web/src/events.ts b/web/src/events.ts index b859482..a99f734 100644 --- a/web/src/events.ts +++ b/web/src/events.ts @@ -15,7 +15,8 @@ function connect(){ // Back after a drop: whatever was sent meanwhile went to nobody. sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } }; const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; }; - const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); }); + // Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219). + const refreshEntries=soon(()=>{ if(S.feed) loadEntries().catch(()=>{}); }); // Every scan's events reach everyone; only this person's feeds are theirs to show or refresh. const mine=id=>S.feeds.some(f=>f.id===id); sse.onmessage=m=>{ @@ -70,4 +71,4 @@ function connect(){ sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); }; } connect(); -loadFeeds(); +loadFeeds().catch(()=>{}); diff --git a/web/src/util.ts b/web/src/util.ts index 73f32b6..37416df 100644 --- a/web/src/util.ts +++ b/web/src/util.ts @@ -72,11 +72,18 @@ function spanText(secs){ } /// One trace for each thing you do (#190): the requests a tap or a key sets off share a trace id, /// sent as traceparent, so Tempo shows them together. getRandomValues, not randomUUID, which a -/// LAN address over plain HTTP does not have. -let traceId=''; +/// LAN address over plain HTTP does not have. A trace ends after a minute with no request in it: +/// the requests a live update or a woken laptop sets off hours later used to join the last tap's +/// trace, which then lasted nine hours (#219). A play's position saves, every ten seconds, keep it. +let traceId='', traceAt=0; const hex=(n: number)=>Array.from(crypto.getRandomValues(new Uint8Array(n)),b=>b.toString(16).padStart(2,'0')).join(''); -for(const t of ['pointerdown','keydown']) window.addEventListener(t,()=>{ traceId=hex(16); },{capture:true,passive:true}); -function traceparent(){ return `00-${traceId||=hex(16)}-${hex(8)}-01`; } +for(const t of ['pointerdown','keydown']) window.addEventListener(t,()=>{ traceId=hex(16); traceAt=Date.now(); },{capture:true,passive:true}); +function traceparent(){ + const now=Date.now(); + if(!traceId||now-traceAt>60_000) traceId=hex(16); + traceAt=now; + return `00-${traceId}-${hex(8)}-01`; +} /// Something that went wrong, or how long something took, in the browser, for the server's log /// (#190). The same thing at most once a minute: a failure in a loop would otherwise send a line @@ -95,7 +102,9 @@ window.addEventListener('unhandledrejection',e=>report('rejection',{msg:String(e async function api(url: string, opts?: RequestInit): Promise{ const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts}); if(r.status===401){ location.href='/login'; throw new Error('signed out'); } - if(!r.ok) throw new Error(await r.text().catch(()=>'')||String(r.status)); + // A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a + // whole 502 page (#219). + if(!r.ok) throw new Error((!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>''))||`${r.status} ${r.statusText}`.trim()); return r.status===204?null:r.json().catch(()=>null); } // navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN