add sidebar footer RSS readout (polls /memory every 30s)

Footer now shows "<bun-rss> · <tab-count>" sourced from the /memory
endpoint, polled every 30s. Color thresholds: orange warn at 2 GB Bun
RSS or 50 tabs; red bad at 8 GB or 200 tabs (matches the tab-guardrail
threshold landing in a later commit). The footer gives the user an
early signal that the cliff is forming, instead of only learning when
the OS OOM-kills the process.

Backoff per Codex's flag: if a poll takes > 2s response time the
sidebar drops to a 5-minute cadence until the next successful fast
poll. The diagnostic shouldn't add load to a browser that's already
unhealthy.

Start/stop is wired to the existing setServerInfo() hook so the timer
only runs while the sidebar is connected to a server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-05-27 07:30:07 -07:00
parent 10495978e6
commit 98b2ae8103
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
3 changed files with 114 additions and 0 deletions

View File

@ -1137,6 +1137,21 @@ footer {
transition: color 150ms;
}
.footer-port:hover { color: var(--text-label); }
.footer-mem {
color: var(--text-meta);
font-family: var(--font-mono);
font-size: 11px;
margin-right: 6px;
padding: 1px 6px;
border-radius: var(--radius-sm);
transition: color 150ms;
}
.footer-mem.warn {
color: #f59e0b;
}
.footer-mem.bad {
color: #ef4444;
}
.port-input {
width: 56px;
padding: 2px 6px;

View File

@ -166,6 +166,7 @@
<button class="footer-btn" id="reload-sidebar" title="Reload sidebar">reload</button>
</div>
<div class="footer-right">
<span class="footer-mem" id="footer-mem" title="Process memory + tab count from $B memory (polled every 30s, paused if slow)"></span>
<span class="dot" id="footer-dot"></span>
<span class="footer-port" id="footer-port" title="Click to change port"></span>
<input type="text" class="port-input" id="port-input" placeholder="34567" autocomplete="off" style="display:none">

View File

@ -292,6 +292,97 @@ async function connectSSE() {
});
}
// ─── Memory Footer Readout ──────────────────────────────────────
//
// Polls /memory every 30s and renders "RSS: 1.4 GB · 12 tabs" in the
// footer. Backs off to 5min if a poll takes > 2s (Codex flag — diagnostic
// shouldn't add load when the browser is already unhealthy). Uses Bearer
// auth like /refs above; /memory is a plain GET so EventSource semantics
// don't apply.
const MEM_POLL_FAST_MS = 30_000;
const MEM_POLL_SLOW_MS = 5 * 60_000;
const MEM_POLL_TIMEOUT_MS = 8_000;
const MEM_POLL_SLOW_THRESHOLD_MS = 2_000;
let memPollTimer = null;
let memPollMode = 'fast'; // 'fast' | 'slow'
function fmtBytesShort(n) {
if (typeof n !== 'number' || isNaN(n)) return '?';
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(0) + ' MB';
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
}
function renderMemFooter(snapshot) {
const el = document.getElementById('footer-mem');
if (!el) return;
const bunRss = snapshot?.bunServer?.rss ?? 0;
const tabCount = Array.isArray(snapshot?.tabs) ? snapshot.tabs.length : 0;
el.textContent = `${fmtBytesShort(bunRss)} · ${tabCount} tabs`;
// Color thresholds: ~2 GB Bun RSS or 50 tabs is "watch this"; ~8 GB or
// 200 tabs is "this is the cliff" (matches the 200-tab guardrail).
el.classList.remove('warn', 'bad');
if (bunRss > 8 * 1024 * 1024 * 1024 || tabCount > 200) el.classList.add('bad');
else if (bunRss > 2 * 1024 * 1024 * 1024 || tabCount > 50) el.classList.add('warn');
}
async function pollMemoryOnce() {
if (!serverUrl || !serverToken) return { ok: false, slow: false };
const start = Date.now();
try {
const resp = await fetch(`${serverUrl}/memory`, {
headers: { 'Authorization': `Bearer ${serverToken}` },
signal: AbortSignal.timeout(MEM_POLL_TIMEOUT_MS),
credentials: 'include',
});
const elapsed = Date.now() - start;
if (!resp.ok) return { ok: false, slow: elapsed > MEM_POLL_SLOW_THRESHOLD_MS };
const snapshot = await resp.json();
renderMemFooter(snapshot);
return { ok: true, slow: elapsed > MEM_POLL_SLOW_THRESHOLD_MS };
} catch (err) {
const elapsed = Date.now() - start;
// Don't log every poll failure — common during browser restarts / restoring
// sessions. Only log on the slow path so the user sees something in the
// console if the diagnostic itself is misbehaving.
if (elapsed > MEM_POLL_SLOW_THRESHOLD_MS) {
console.debug('[gstack sidebar] /memory poll slow/failed:', elapsed, 'ms', err && err.message);
}
return { ok: false, slow: elapsed > MEM_POLL_SLOW_THRESHOLD_MS };
}
}
function scheduleNextMemPoll(delayMs) {
if (memPollTimer) clearTimeout(memPollTimer);
memPollTimer = setTimeout(async () => {
const { ok, slow } = await pollMemoryOnce();
if (!ok || slow) {
memPollMode = 'slow';
scheduleNextMemPoll(MEM_POLL_SLOW_MS);
} else {
// Successful + fast → back to fast cadence.
if (memPollMode === 'slow') memPollMode = 'fast';
scheduleNextMemPoll(MEM_POLL_FAST_MS);
}
}, delayMs);
}
function startMemPolling() {
if (memPollTimer) return; // already running
// Kick off an immediate poll so the footer populates within ~1s of sidebar
// open, instead of waiting 30s for the first cycle.
scheduleNextMemPoll(500);
}
function stopMemPolling() {
if (memPollTimer) {
clearTimeout(memPollTimer);
memPollTimer = null;
}
}
// ─── Refs Tab ───────────────────────────────────────────────────
async function fetchRefs() {
@ -893,9 +984,16 @@ function updateConnection(url, token) {
chrome.runtime.sendMessage({ type: 'sidebarOpened' }).catch(() => {});
connectSSE();
connectInspectorSSE();
startMemPolling();
} else {
document.getElementById('footer-dot').className = 'dot';
document.getElementById('footer-port').textContent = '';
const memEl = document.getElementById('footer-mem');
if (memEl) {
memEl.textContent = '';
memEl.classList.remove('warn', 'bad');
}
stopMemPolling();
setActionButtonsEnabled(false);
if (wasConnected) startReconnect();
}