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