aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-10 23:25:46 -0700
committerAdam Soltys <[email protected]>2026-09-10 23:25:46 -0700
commit0e25159698ab0202f53e8b061e8659d7cde5279c (patch)
treefe09d9bb8ef502f1ba8be60c9b9af5fce2f7b4c6
parent25c8f34c98f997371616c12f83687561117ea3b5 (diff)
downloadcoinosv3-0e25159698ab0202f53e8b061e8659d7cde5279c.tar.gz
coinosv3-0e25159698ab0202f53e8b061e8659d7cde5279c.zip
Retry avatar thumbnail failures from the old download method
-rw-r--r--src/features/messages.js16
-rw-r--r--tools/avatar-thumb-test.js58
2 files changed, 61 insertions, 13 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index b468614..a9da0c3 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -311,7 +311,8 @@ export function messagesFeature(ctx) {
const s = wallet.loadFeatureState('profiles', {});
s[pk] = { name: p.name || null, picture: p.picture || null, nip05: p.nip05 || null, lud16: p.lud16 || null, t: Date.now(),
...(p.thumbFor === p.picture && p.thumb ? { thumb: p.thumb, thumbFor: p.thumbFor } : {}),
- ...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, thumbFails: p.thumbFails || 1 } : {}) };
+ ...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, thumbFails: p.thumbFails || 1,
+ thumbFailVersion: p.thumbFailVersion || 0 } : {}) };
// Thumbnails are the bulk of this blob, so they live on a budget: the
// least recently seen faces give theirs up first. The row itself stays —
// that face just paints the way it used to.
@@ -357,6 +358,7 @@ export function messagesFeature(ctx) {
entry.thumbFail = prev.thumbFail;
entry.thumbFailAt = prev.thumbFailAt || 0;
entry.thumbFails = prev.thumbFails || 1;
+ entry.thumbFailVersion = prev.thumbFailVersion || 0;
}
return entry;
};
@@ -384,6 +386,9 @@ export function messagesFeature(ctx) {
const THUMB_SLOW = 20_000; // a host that won't answer must not hold a slot
const THUMB_RETRY = 6 * 3600_000; // ...and must not be written off for good
const THUMB_RETRY_MAX = 14 * 24 * 3600_000; // a host that never works, backed off
+ // Older crossOrigin <img> attempts could fail on a cached no-CORS response.
+ // Those failures must not suppress the corrected fetch/blob path.
+ const THUMB_VERSION = 1;
const thumbing = new Set();
function makeThumb(pk, p) {
if (!p || !p.picture || typeof document === 'undefined') return;
@@ -393,7 +398,7 @@ export function messagesFeature(ctx) {
// on CDNs that answer in 700ms one minute and time out the next. So every
// failure is tried again — backing off each time, so a host that truly
// won't have us is asked about twice a month rather than every boot.
- if (p.thumbFail === p.picture
+ if (p.thumbFailVersion === THUMB_VERSION && p.thumbFail === p.picture
&& Date.now() - (p.thumbFailAt || 0) < Math.min(THUMB_RETRY * 2 ** ((p.thumbFails || 1) - 1), THUMB_RETRY_MAX)) return;
if (thumbing.has(pk) || thumbing.size >= 3) return; // a few at a time
// Making the thumbnail costs one more fetch of the original today to
@@ -405,11 +410,16 @@ export function messagesFeature(ctx) {
const done = (patch) => {
thumbing.delete(pk);
const entry = { ...(profiles.get(pk) || p), ...patch };
+ if (patch.thumb) {
+ delete entry.thumbFail; delete entry.thumbFailAt;
+ delete entry.thumbFails; delete entry.thumbFailVersion;
+ }
profiles.set(pk, entry);
persistProfile(pk, entry);
if (patch.thumb) scheduleRepaint();
};
- const failed = () => done({ thumbFail: url, thumbFailAt: Date.now(), thumbFails: (p.thumbFails || 0) + 1 });
+ const failed = () => done({ thumbFail: url, thumbFailAt: Date.now(), thumbFailVersion: THUMB_VERSION,
+ thumbFails: (p.thumbFailVersion === THUMB_VERSION && p.thumbFail === url ? p.thumbFails || 0 : 0) + 1 });
// Fetch the bytes rather than pointing a crossOrigin <img> at the URL.
// The avatar itself is painted as a plain background-image, so the
// picture is already in the HTTP cache as a no-CORS entry — and a
diff --git a/tools/avatar-thumb-test.js b/tools/avatar-thumb-test.js
index b73d4d3..b532982 100644
--- a/tools/avatar-thumb-test.js
+++ b/tools/avatar-thumb-test.js
@@ -19,18 +19,19 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
-// a big-ish PNG (1024x1024 of noise) served with CORS, and a request counter
-const big = await (async () => {
- const px = 512, head = Buffer.alloc(0);
- // a real image is needed (the canvas has to decode it): build a PNG via a data URI in the page instead
- return { px, head };
-})();
+// Generate a real PNG in the test browser; no external scratchpad fixture.
+let big;
const html = await buildHtml({ minify: true, pwa: false });
let originalHits = 0;
+let failedCorsHits = 0;
const server = Bun.serve({
port: 5235,
async fetch(req) {
const u = new URL(req.url);
+ if (u.pathname === '/fail.png') {
+ if (req.headers.get('sec-fetch-mode') === 'cors') failedCorsHits++;
+ return new Response('unavailable', { status: 503, headers: { 'access-control-allow-origin': '*' } });
+ }
if (u.pathname.startsWith('/punks')) {
const f = Bun.file('dist' + u.pathname);
if (await f.exists()) return new Response(await f.arrayBuffer(), { headers: { 'content-type': 'image/webp' } });
@@ -43,8 +44,7 @@ const server = Bun.serve({
if (u.pathname === '/redir.png') return new Response(null, { status: 302, headers: { location: '/big.png', 'access-control-allow-origin': '*' } });
if (u.pathname === '/big.png') {
originalHits++;
- const bytes = await Bun.file('/tmp/claude-1000/-home-adam-coinosv3/61dea255-d26d-411d-be8b-92dfb0a75887/scratchpad/big.png').arrayBuffer();
- return new Response(bytes, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*', 'cache-control': 'no-store' } });
+ return new Response(big, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*', 'cache-control': 'no-store' } });
}
return new Response(html, { headers: { 'content-type': 'text/html' } });
},
@@ -54,6 +54,20 @@ const page = await browser.newPage();
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
try {
+ big = Buffer.from(await page.evaluate(() => {
+ const c = document.createElement('canvas');
+ c.width = c.height = 1024;
+ const ctx = c.getContext('2d');
+ const pixels = ctx.createImageData(c.width, c.height);
+ for (let i = 0; i < pixels.data.length; i += 4) {
+ pixels.data[i] = (i * 13) % 251;
+ pixels.data[i + 1] = (i * 29) % 253;
+ pixels.data[i + 2] = (i * 7) % 255;
+ pixels.data[i + 3] = 255;
+ }
+ ctx.putImageData(pixels, 0, 0);
+ return c.toDataURL('image/png').split(',')[1];
+ }), 'base64');
await page.setViewport({ width: 390, height: 844 });
await page.goto('http://localhost:5235/', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
@@ -163,7 +177,8 @@ try {
await page.evaluate((pkHex) => {
for (const k of Object.keys(localStorage).filter((x) => /^btc-wallet-cache:[0-9a-f]+$/.test(x)))
localStorage.setItem(k + ':profiles', JSON.stringify({
- [pkHex]: { name: 'Redirect Test', picture: 'http://localhost:5235/redir.png', t: Date.now() },
+ [pkHex]: { name: 'Redirect Test', picture: 'http://127.0.0.1:5235/redir.png', t: Date.now(),
+ thumbFail: 'http://127.0.0.1:5235/redir.png', thumbFailAt: Date.now(), thumbFails: 5 },
}));
}, pk);
await page.reload({ waitUntil: 'domcontentloaded' });
@@ -175,8 +190,31 @@ try {
const r = rows.find((x) => x && /redir/.test(x.picture || '')) || {};
return { thumb: (r.thumb || '').length, fail: !!r.thumbFail };
});
- check('a picture behind a redirect still gets a thumbnail', redirThumb.thumb > 0 && !redirThumb.fail,
+ check('an old thumbnail failure retries immediately and clears on success', redirThumb.thumb > 0 && !redirThumb.fail,
redirThumb.thumb ? redirThumb.thumb + ' chars' : 'failed');
+
+ // If the corrected path also fails, start a new backoff and keep it
+ // across boots rather than repeatedly downloading a broken picture.
+ await page.evaluate((pkHex) => {
+ for (const k of Object.keys(localStorage).filter((x) => /^btc-wallet-cache:[0-9a-f]+$/.test(x)))
+ localStorage.setItem(k + ':profiles', JSON.stringify({
+ [pkHex]: { name: 'Failure Test', picture: 'http://127.0.0.1:5235/fail.png', t: Date.now(),
+ thumbFail: 'http://127.0.0.1:5235/fail.png', thumbFailAt: Date.now(), thumbFails: 5 },
+ }));
+ }, pk);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await sleep(1500);
+ const failure = await page.evaluate(() => Object.keys(localStorage)
+ .filter((k) => /^btc-wallet-cache:[0-9a-f]+:profiles$/.test(k))
+ .flatMap((k) => Object.values(JSON.parse(localStorage.getItem(k) || '{}')))
+ .find((r) => r.thumbFailVersion === 1));
+ check('a fresh failure starts a new backoff', failedCorsHits === 1 && failure?.thumbFails === 1);
+ failedCorsHits = 0;
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await sleep(1500);
+ check('current failures still respect backoff after reload', failedCorsHits === 0);
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ faces come back instantly' : '\n❌ failed');
process.exit(ok ? 0 : 1);