Skip to content

Commit 447769c

Browse files
ralyodioclaude
andcommitted
fix(extension): bound every network and storage read the UI opens with
The side panel opened to a header and nothing else, and the New Tab page sat on its placeholders with the tab spinner running. Both surfaces render by awaiting a chain of calls, and most of those calls could not fail. `fetch()` has no default timeout. A host that accepts the connection and then never answers leaves the promise pending for the life of the tab, so the section waiting on it never renders and never errors — it just stays blank. Nine of these were on a render path: the MOTD, Yahoo quotes, ESPN scores, bittorrented `/me` and `/favorites`, CoinPay `/auth/me`, `/login`, `/signup`, `/logout`, the settings pull/push, and the provider model list. background.js, moshpit.js and the per-feed fetch already each carried their own AbortController for exactly this reason; this is that pattern, in one place, for the rest of them. chrome.storage.local is bounded for the same reason. It is backed by a LevelDB in the profile, and a large or damaged one can leave a get() pending. In the side panel that read is `aiConfig`, and until it resolves the setup prompt stays hidden and the panel shows nothing — a stalled read decided whether the UI appeared at all. It now falls back to {} after 3s, which every caller already handles as "nothing stored", so the panel draws its unconfigured state instead of staying blank. Streaming chat calls in providers.js are deliberately left unbounded — a long completion is not a hang. Tests: 9 covering the timeout, pass-through, real-error and fallback paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8341e18 commit 447769c

11 files changed

Lines changed: 171 additions & 27 deletions

File tree

apps/desktop/extensions/ai-sidebar/bittorrented.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
// The token is stored locally (per device) and sent as `Authorization: Bearer`
55
// to bittorrented.com's /api/v1/* endpoints (favorites, live TV, radio, podcasts).
66

7+
import { fetchWithTimeout, storageGet } from './net.js';
8+
79
export const BTR_BASE = 'https://bittorrented.com';
810
const TB_WEB = 'https://tronbrowser.dev';
911
const KEY = 'btrToken';
1012

1113
export async function getToken() {
12-
return (await chrome.storage.local.get(KEY))[KEY] || '';
14+
return (await storageGet(KEY))[KEY] || '';
1315
}
1416

1517
// Open the connect flow in a normal tab and wait for the token. We do NOT use
@@ -44,7 +46,7 @@ export async function verify() {
4446
const token = await getToken();
4547
if (!token) return { connected: false };
4648
try {
47-
const r = await fetch(`${BTR_BASE}/api/v1/me`, { headers: { authorization: `Bearer ${token}` } });
49+
const r = await fetchWithTimeout(`${BTR_BASE}/api/v1/me`, { headers: { authorization: `Bearer ${token}` } });
4850
if (!r.ok) return { connected: false };
4951
const d = await r.json();
5052
return { connected: true, email: d.email || null };

apps/desktop/extensions/ai-sidebar/coinpay-auth.js

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
// (or does the CoinPay OAuth dance) and redirects back to /ext-callback.html,
55
// where our content script hands the session token to the extension. This is
66
// the login — never Google. Override the API base in Settings (self-hosted).
7+
import { fetchWithTimeout, storageGet } from './net.js';
8+
79
const DEFAULT_API = 'https://tronbrowser.dev';
810

911
// Landing page for the redirect, and the storage key its content script writes.
@@ -15,7 +17,7 @@ const HANDOFF_KEY = 'tbAuthToken';
1517
const SIGNIN_TIMEOUT_MS = 180000;
1618

1719
async function apiBase() {
18-
const { syncConfig } = await chrome.storage.local.get('syncConfig');
20+
const { syncConfig } = await storageGet('syncConfig');
1921
return (syncConfig?.url || DEFAULT_API).replace(/\/$/, '');
2022
}
2123

@@ -26,7 +28,7 @@ async function storeSession(sessionToken, method) {
2628
const base = await apiBase();
2729
let label = '';
2830
try {
29-
const me = await fetch(`${base}/api/auth/me`, { headers: { authorization: `Bearer ${sessionToken}` } });
31+
const me = await fetchWithTimeout(`${base}/api/auth/me`, { headers: { authorization: `Bearer ${sessionToken}` } });
3032
if (me.ok) { const d = await me.json(); label = d.email || d.id || ''; }
3133
} catch { /* ignore */ }
3234
await chrome.storage.local.set({
@@ -78,7 +80,7 @@ export async function coinpaySignIn() {
7880
// returns a session token we store exactly like the CoinPay one.
7981
export async function emailSignIn(email, password) {
8082
const base = await apiBase();
81-
const r = await fetch(`${base}/api/auth/login`, {
83+
const r = await fetchWithTimeout(`${base}/api/auth/login`, {
8284
method: 'POST', headers: { 'content-type': 'application/json' },
8385
body: JSON.stringify({ email, password }),
8486
});
@@ -92,7 +94,7 @@ export async function emailSignIn(email, password) {
9294
// a verification email and does NOT sign you in; verify, then sign in.
9395
export async function emailSignUp(email, password) {
9496
const base = await apiBase();
95-
const r = await fetch(`${base}/api/auth/signup`, {
97+
const r = await fetchWithTimeout(`${base}/api/auth/signup`, {
9698
method: 'POST', headers: { 'content-type': 'application/json' },
9799
body: JSON.stringify({ email, password }),
98100
});
@@ -102,7 +104,7 @@ export async function emailSignUp(email, password) {
102104
}
103105

104106
export async function coinpayState() {
105-
const { coinpay } = await chrome.storage.local.get('coinpay');
107+
const { coinpay } = await storageGet('coinpay');
106108
if (coinpay?.sessionToken && (!coinpay.expiresAt || coinpay.expiresAt > Date.now())) {
107109
return { signedIn: true, label: coinpay.label, method: coinpay.method || 'coinpay', token: coinpay.sessionToken };
108110
}
@@ -114,7 +116,7 @@ export async function coinpaySignOut() {
114116
if (coinpay?.sessionToken) {
115117
try {
116118
const base = await apiBase();
117-
await fetch(`${base}/api/auth/logout`, { method: 'POST', headers: { authorization: `Bearer ${coinpay.sessionToken}` } });
119+
await fetchWithTimeout(`${base}/api/auth/logout`, { method: 'POST', headers: { authorization: `Bearer ${coinpay.sessionToken}` } });
118120
} catch { /* ignore */ }
119121
}
120122
await chrome.storage.local.remove('coinpay');

apps/desktop/extensions/ai-sidebar/markets.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
// grant cross-origin reads, so no backend and no API keys are needed (we never
77
// ship our paid Finnhub/Alpaca keys in a public extension).
88

9+
import { fetchWithTimeout } from './net.js';
10+
911
const Y_CHART = 'https://query1.finance.yahoo.com/v8/finance/chart/';
1012

1113
export async function fetchQuote(symbol) {
1214
const url = `${Y_CHART}${encodeURIComponent(symbol)}?range=1d&interval=1d`;
13-
const res = await fetch(url);
15+
const res = await fetchWithTimeout(url);
1416
if (!res.ok) throw new Error('quote ' + res.status);
1517
const m = (await res.json())?.chart?.result?.[0]?.meta;
1618
if (!m || m.regularMarketPrice == null) throw new Error('no data');
@@ -61,7 +63,7 @@ export async function fetchScores(leagueKey) {
6163
const path = LEAGUES[leagueKey];
6264
if (!path) return { league: leagueKey, games: [], error: 'unknown league' };
6365
try {
64-
const res = await fetch(`https://site.api.espn.com/apis/site/v2/sports/${path}/scoreboard`);
66+
const res = await fetchWithTimeout(`https://site.api.espn.com/apis/site/v2/sports/${path}/scoreboard`);
6567
if (!res.ok) throw new Error('espn ' + res.status);
6668
const data = await res.json();
6769
const games = (data.events || []).slice(0, 6).map((e) => {

apps/desktop/extensions/ai-sidebar/media.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { BTR_BASE, getToken, connect, verify, disconnect } from './bittorrented.js';
2+
import { fetchWithTimeout } from './net.js';
23

34
const el = (id) => document.getElementById(id);
45
function escapeHtml(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
@@ -28,7 +29,7 @@ async function loadFavorites() {
2829
el('btr').innerHTML = '<p class="muted">Loading favorites…</p>';
2930
let data;
3031
try {
31-
const r = await fetch(`${BTR_BASE}/api/v1/favorites`, { headers: { authorization: `Bearer ${token}` } });
32+
const r = await fetchWithTimeout(`${BTR_BASE}/api/v1/favorites`, { headers: { authorization: `Bearer ${token}` } });
3233
if (r.status === 401) { showDisconnected(); setStatus('Session expired — connect again.', 'err'); return; }
3334
if (!r.ok) { el('btr').innerHTML = `<p class="muted">Couldn’t load favorites (${r.status}).</p>`; return; }
3435
data = await r.json();

apps/desktop/extensions/ai-sidebar/motd.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@
22
// chrome.storage.local. The /motd endpoint is plain text (message + signature)
33
// and CORS-open. Falls back to the last cached value if the network fails.
44

5+
import { fetchWithTimeout, storageGet } from './net.js';
6+
57
const MOTD_URL = 'https://profullstack.com/motd';
68
const MOTD_TTL = 30 * 60 * 1000; // 30 min
79

810
export async function fetchMotd() {
9-
const { motdCache } = await chrome.storage.local.get('motdCache');
11+
const { motdCache } = await storageGet('motdCache');
1012
if (motdCache && motdCache.text && Date.now() - motdCache.at < MOTD_TTL) {
1113
return motdCache.text;
1214
}
1315
try {
14-
const res = await fetch(MOTD_URL, { redirect: 'follow' });
16+
const res = await fetchWithTimeout(MOTD_URL, { redirect: 'follow' });
1517
if (!res.ok) throw new Error('HTTP ' + res.status);
1618
const text = (await res.text()).trim();
1719
await chrome.storage.local.set({ motdCache: { at: Date.now(), text } });
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Bounded I/O for anything the UI does on open.
2+
//
3+
// The new tab and the side panel both render by awaiting a chain of network and
4+
// storage calls. `fetch()` has no default timeout: a host that accepts the
5+
// connection and then never answers leaves the promise pending forever, and the
6+
// section waiting on it stays on its placeholder for the life of the tab — the
7+
// page looks frozen rather than degraded. background.js, moshpit.js and the
8+
// per-feed fetch already each carried their own AbortController for this
9+
// reason; this is that same pattern, in one place, for the rest of them.
10+
//
11+
// chrome.storage.local is bounded for the same reason: it is backed by a
12+
// LevelDB in the profile, and a large or damaged one can leave a get() pending.
13+
// A settings read that never resolves must not decide whether the UI appears.
14+
15+
/** Network calls on a render path. Long enough for a slow host, short enough to notice. */
16+
export const DEFAULT_TIMEOUT_MS = 8000;
17+
18+
/** Local storage reads. Should be instant; anything near this is already broken. */
19+
export const STORAGE_TIMEOUT_MS = 3000;
20+
21+
/**
22+
* fetch() that always settles. Rejects with 'timed out' rather than hanging.
23+
* Pass `ms` to override the default budget.
24+
*/
25+
export async function fetchWithTimeout(url, opts = {}, ms = DEFAULT_TIMEOUT_MS) {
26+
const ctrl = new AbortController();
27+
const timer = setTimeout(() => ctrl.abort(), ms);
28+
try {
29+
return await fetch(url, { ...opts, signal: ctrl.signal });
30+
} catch (e) {
31+
// A caller-supplied signal aborting is the caller's business; ours is a timeout.
32+
throw e?.name === 'AbortError' ? new Error('timed out') : e;
33+
} finally {
34+
clearTimeout(timer);
35+
}
36+
}
37+
38+
/**
39+
* Resolve to `fallback` if `promise` hasn't settled within `ms`. Used where a
40+
* missing answer is survivable and a missing render is not — the caller gets
41+
* defaults and the page draws.
42+
*/
43+
export function withTimeout(promise, ms, fallback) {
44+
return Promise.race([
45+
Promise.resolve(promise).catch(() => fallback),
46+
new Promise((resolve) => setTimeout(() => resolve(fallback), ms)),
47+
]);
48+
}
49+
50+
/**
51+
* chrome.storage.local.get that always settles. Returns `{}` if storage stalls,
52+
* which every caller here already handles as "no value stored".
53+
*/
54+
export function storageGet(keys) {
55+
return withTimeout(chrome.storage.local.get(keys), STORAGE_TIMEOUT_MS, {});
56+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import { fetchWithTimeout, storageGet, withTimeout } from './net.js';
3+
4+
afterEach(() => {
5+
vi.unstubAllGlobals();
6+
delete globalThis.chrome;
7+
});
8+
9+
// A promise that never settles — the failure this module exists to bound.
10+
const forever = () => new Promise(() => {});
11+
12+
describe('fetchWithTimeout', () => {
13+
it('returns the response when the host answers', async () => {
14+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 }));
15+
await expect(fetchWithTimeout('https://example.test')).resolves.toMatchObject({ ok: true });
16+
});
17+
18+
it('rejects instead of hanging when the host never answers', async () => {
19+
// The host accepted the connection and went quiet: without the abort this
20+
// promise is pending for the life of the tab and its section never renders.
21+
vi.stubGlobal('fetch', vi.fn().mockImplementation((_url, opts) =>
22+
new Promise((_resolve, reject) => {
23+
opts.signal.addEventListener('abort', () => {
24+
const err = new Error('aborted');
25+
err.name = 'AbortError';
26+
reject(err);
27+
});
28+
})));
29+
await expect(fetchWithTimeout('https://example.test', {}, 20)).rejects.toThrow('timed out');
30+
});
31+
32+
it('passes the caller options through and adds a signal', async () => {
33+
const spy = vi.fn().mockResolvedValue({ ok: true });
34+
vi.stubGlobal('fetch', spy);
35+
await fetchWithTimeout('https://example.test', { method: 'POST', headers: { a: 'b' } });
36+
const [, opts] = spy.mock.calls[0];
37+
expect(opts.method).toBe('POST');
38+
expect(opts.headers).toEqual({ a: 'b' });
39+
expect(opts.signal).toBeInstanceOf(AbortSignal);
40+
});
41+
42+
it('surfaces a real network error unchanged', async () => {
43+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ERR_NAME_NOT_RESOLVED')));
44+
await expect(fetchWithTimeout('https://example.test')).rejects.toThrow('ERR_NAME_NOT_RESOLVED');
45+
});
46+
});
47+
48+
describe('withTimeout', () => {
49+
it('passes the value through when it arrives in time', async () => {
50+
await expect(withTimeout(Promise.resolve('v'), 50, 'fallback')).resolves.toBe('v');
51+
});
52+
53+
it('falls back when the promise stalls', async () => {
54+
await expect(withTimeout(forever(), 10, 'fallback')).resolves.toBe('fallback');
55+
});
56+
57+
it('falls back when the promise rejects', async () => {
58+
await expect(withTimeout(Promise.reject(new Error('nope')), 50, 'fallback')).resolves.toBe('fallback');
59+
});
60+
});
61+
62+
describe('storageGet', () => {
63+
it('returns what storage holds', async () => {
64+
globalThis.chrome = { storage: { local: { get: vi.fn().mockResolvedValue({ aiConfig: { model: 'm' } }) } } };
65+
await expect(storageGet('aiConfig')).resolves.toEqual({ aiConfig: { model: 'm' } });
66+
});
67+
68+
it('returns {} rather than hanging when the profile database stalls', async () => {
69+
// Callers all read this as "nothing stored" and render their default state,
70+
// which is the point: a stalled read must not decide whether the UI appears.
71+
globalThis.chrome = { storage: { local: { get: vi.fn().mockImplementation(forever) } } };
72+
await expect(storageGet('aiConfig')).resolves.toEqual({});
73+
}, 10000);
74+
});

apps/desktop/extensions/ai-sidebar/newtab.js

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { coinpaySignIn, coinpayState, coinpaySignOut } from './coinpay-auth.js';
33
import { fetchQuotes, fetchAllScores } from './markets.js';
44
import { fetchMotd } from './motd.js';
55
import { getToken as btrToken, BTR_BASE } from './bittorrented.js';
6+
import { fetchWithTimeout, storageGet } from './net.js';
67

78
const el = (id) => document.getElementById(id);
89

@@ -66,7 +67,7 @@ el('mode-web').addEventListener('click', () => setMode('web'));
6667
el('mode-ai').addEventListener('click', () => setMode('ai'));
6768

6869
// Load the chosen clearnet + Tor search engines and current onion-toggle state.
69-
chrome.storage.local.get(['searchEngine', 'torSearchEngine', 'torEnabled']).then((v) => {
70+
storageGet(['searchEngine', 'torSearchEngine', 'torEnabled']).then((v) => {
7071
if (v.searchEngine && SEARCH_ENGINES[v.searchEngine]) searchEngine = v.searchEngine;
7172
if (v.torSearchEngine && TOR_SEARCH_ENGINES[v.torSearchEngine]) torSearchEngine = v.torSearchEngine;
7273
torEnabled = !!v.torEnabled;
@@ -151,7 +152,7 @@ async function fetchFeed(feed) {
151152

152153
const CACHE_V = 3; // bump to invalidate caches when item shape changes / clear stale
153154
async function getFeedData(feeds) {
154-
const { feedCache } = await chrome.storage.local.get('feedCache');
155+
const { feedCache } = await storageGet('feedCache');
155156
if (feedCache && feedCache.v === CACHE_V && Date.now() - feedCache.at < TTL && feedCache.count === feeds.length) {
156157
return feedCache.data;
157158
}
@@ -217,14 +218,14 @@ function escAttr(s) {
217218
const MKT_TTL = 5 * 60 * 1000;
218219

219220
async function renderMarkets() {
220-
const { tickers } = await chrome.storage.local.get('tickers');
221+
const { tickers } = await storageGet('tickers');
221222
const symbols = splitList(tickers ?? DEFAULT_TICKERS);
222223
const sec = el('markets-sec');
223224
if (!symbols.length) { sec.hidden = true; return; }
224225
sec.hidden = false;
225226

226227
const sig = symbols.join(',');
227-
const { marketCache } = await chrome.storage.local.get('marketCache');
228+
const { marketCache } = await storageGet('marketCache');
228229
let data;
229230
if (marketCache && marketCache.sig === sig && Date.now() - marketCache.at < MKT_TTL) {
230231
data = marketCache.data;
@@ -246,14 +247,14 @@ async function renderMarkets() {
246247
}
247248

248249
async function renderSports() {
249-
const { leagues } = await chrome.storage.local.get('leagues');
250+
const { leagues } = await storageGet('leagues');
250251
const keys = splitList(leagues ?? DEFAULT_LEAGUES).map((k) => k.toLowerCase());
251252
const sec = el('sports-sec');
252253
if (!keys.length) { sec.hidden = true; return; }
253254
sec.hidden = false;
254255

255256
const sig = keys.join(',');
256-
const { sportsCache } = await chrome.storage.local.get('sportsCache');
257+
const { sportsCache } = await storageGet('sportsCache');
257258
let data;
258259
if (sportsCache && sportsCache.sig === sig && Date.now() - sportsCache.at < MKT_TTL) {
259260
data = sportsCache.data;
@@ -305,7 +306,7 @@ async function renderBtr() {
305306
if (!token) { sec.hidden = true; return; }
306307
let data;
307308
try {
308-
const r = await fetch(`${BTR_BASE}/api/v1/favorites`, { headers: { authorization: `Bearer ${token}` } });
309+
const r = await fetchWithTimeout(`${BTR_BASE}/api/v1/favorites`, { headers: { authorization: `Bearer ${token}` } });
309310
if (!r.ok) { sec.hidden = true; return; }
310311
data = await r.json();
311312
} catch { sec.hidden = true; return; }

apps/desktop/extensions/ai-sidebar/providers.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Self-contained provider calling for the sidebar (mirrors
22
// @tronbrowser/model-providers). Plain ESM so it loads unbundled in MV3.
33

4+
import { fetchWithTimeout } from './net.js';
5+
46
export const PROVIDERS = {
57
anthropic: { label: 'Anthropic (Claude)', baseUrl: 'https://api.anthropic.com/v1', anthropic: true, keyless: false },
68
openai: { label: 'OpenAI', baseUrl: 'https://api.openai.com/v1', anthropic: false, keyless: false },
@@ -49,7 +51,7 @@ export async function listModels(cfg) {
4951
} else if (cfg.apiKey) {
5052
headers['authorization'] = 'Bearer ' + cfg.apiKey;
5153
}
52-
const res = await fetch(baseUrl + '/models', { headers });
54+
const res = await fetchWithTimeout(baseUrl + '/models', { headers });
5355
if (!res.ok) throw new Error(cfg.provider + ' ' + res.status);
5456
const data = await res.json();
5557
return (data.data || data.models || [])

0 commit comments

Comments
 (0)