diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 0cd111049..2bc1c597d 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -18,6 +18,7 @@ import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright'; import { writeSecureFile, mkdirSecure } from './file-permissions'; import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; +import { emitActivity } from './activity'; import { validateNavigationUrl } from './url-validation'; import { TabSession, type RefEntry } from './tab-session'; import { resolveChromiumProfile, cleanSingletonLocks } from './config'; @@ -196,6 +197,51 @@ export class BrowserManager { private connectionMode: 'launched' | 'headed' = 'launched'; private intentionalDisconnect = false; + // ─── Tab Count Guardrail (D5 + Codex single-tab flag) ─────── + // Idempotent threshold trackers: each guardrail fires exactly once per + // upward crossing of its threshold and re-arms when the tab count drops + // back below. Pre-guardrail, nothing tracked tab count growth and a + // user could accumulate hundreds of tabs (each holding 50–300 MB of + // Chromium-side RSS) without warning until the OS OOM-killer fired. + // The toast UX lives in the sidebar (extension/sidepanel.js); the + // server-side responsibility is the audit-trail activity entry that + // appears in the activity feed even when the sidebar is closed. + private static readonly TAB_GUARDRAIL_SOFT = 50; + private static readonly TAB_GUARDRAIL_HARD = 200; + private tabGuardrailSoftHit = false; + private tabGuardrailHardHit = false; + + /** + * Called from context.on('page') after a new tab is tracked. Emits at + * most one activity entry per upward crossing of each threshold. + */ + private checkTabGuardrails(): void { + const total = this.pages.size; + if (!this.tabGuardrailSoftHit && total >= BrowserManager.TAB_GUARDRAIL_SOFT) { + this.tabGuardrailSoftHit = true; + const msg = `Tab count crossed ${BrowserManager.TAB_GUARDRAIL_SOFT} (now ${total}). Consider closing unused tabs — each Chromium tab holds 50–300 MB.`; + console.warn(`[browse] ${msg}`); + emitActivity({ type: 'error', command: 'tab-guardrail', error: msg, tabs: total }); + } + if (!this.tabGuardrailHardHit && total >= BrowserManager.TAB_GUARDRAIL_HARD) { + this.tabGuardrailHardHit = true; + const msg = `Tab count crossed ${BrowserManager.TAB_GUARDRAIL_HARD} (now ${total}). OOM risk imminent. Open the sidebar to see top RAM consumers.`; + console.error(`[browse] ${msg}`); + emitActivity({ type: 'error', command: 'tab-guardrail', error: msg, tabs: total }); + } + } + + /** Called from page.on('close') so the guardrails re-arm. */ + private recheckTabGuardrailsOnClose(): void { + const total = this.pages.size; + if (this.tabGuardrailSoftHit && total < BrowserManager.TAB_GUARDRAIL_SOFT) { + this.tabGuardrailSoftHit = false; + } + if (this.tabGuardrailHardHit && total < BrowserManager.TAB_GUARDRAIL_HARD) { + this.tabGuardrailHardHit = false; + } + } + // Called when the headed browser disconnects without intentional teardown // (user closed the window). Wired up by server.ts to run full cleanup // (sidebar-agent, state file, profile locks) before exiting with code 2. @@ -622,6 +668,7 @@ export class BrowserManager { // Inject indicator on the new tab page.evaluate(indicatorScript).catch(() => {}); console.log(`[browse] New tab detected (id=${id}, total=${this.pages.size})`); + this.checkTabGuardrails(); }); // Persistent context opens a default page — adopt it instead of creating a new one @@ -1642,6 +1689,7 @@ export class BrowserManager { break; } } + this.recheckTabGuardrailsOnClose(); }); // Clear ref map on navigation — refs point to stale elements after page change diff --git a/browse/test/tab-guardrail.test.ts b/browse/test/tab-guardrail.test.ts new file mode 100644 index 000000000..6adf53d0d --- /dev/null +++ b/browse/test/tab-guardrail.test.ts @@ -0,0 +1,118 @@ +import { describe, test, expect, beforeEach } from 'bun:test'; +import { BrowserManager } from '../src/browser-manager'; +import { subscribe } from '../src/activity'; + +// Tests for the tab-count guardrail. Each threshold fires exactly once per +// upward crossing and re-arms when the count drops back below. The toast +// UX lives in the sidebar; this exercises the server-side audit-trail +// invariant that an activity entry is emitted at each crossing. + +interface CapturedEntry { + type: string; + command?: string; + error?: string; + tabs?: number; +} + +function captureGuardrailEntries(): { entries: CapturedEntry[]; unsubscribe: () => void } { + const entries: CapturedEntry[] = []; + const unsubscribe = subscribe((entry) => { + if (entry.command === 'tab-guardrail') { + entries.push({ + type: entry.type, + command: entry.command, + error: entry.error, + tabs: entry.tabs, + }); + } + }); + return { entries, unsubscribe }; +} + +/** Drive the guardrail by writing directly into the manager's pages map. */ +async function setTabCount(bm: BrowserManager, n: number): Promise { + // Reach into private state via index access — test-only manipulation that + // avoids spinning up a real Chromium just to verify the threshold math. + const inner = bm as unknown as { + pages: Map; + checkTabGuardrails: () => void; + recheckTabGuardrailsOnClose: () => void; + }; + inner.pages.clear(); + for (let i = 0; i < n; i++) inner.pages.set(i, { fakeTab: true }); + // Drive whichever direction matches the count change. + inner.checkTabGuardrails(); + inner.recheckTabGuardrailsOnClose(); + // emitActivity dispatches subscribers via queueMicrotask, so let the + // microtask queue drain before the test assertion runs. + await new Promise((r) => setTimeout(r, 0)); +} + +describe('tab-count guardrail', () => { + let bm: BrowserManager; + let capture: ReturnType; + + beforeEach(() => { + bm = new BrowserManager(); + capture = captureGuardrailEntries(); + }); + + test('1. no entry fires under the soft threshold', async () => { + await setTabCount(bm, 10); + await setTabCount(bm, 49); + expect(capture.entries).toEqual([]); + capture.unsubscribe(); + }); + + test('2. soft threshold (50) fires exactly once on upward crossing', async () => { + await setTabCount(bm, 49); + await setTabCount(bm, 50); + await setTabCount(bm, 51); + await setTabCount(bm, 60); + expect(capture.entries.length).toBe(1); + expect(capture.entries[0].tabs).toBe(50); + expect(capture.entries[0].error).toContain('crossed 50'); + capture.unsubscribe(); + }); + + test('3. hard threshold (200) fires exactly once on upward crossing', async () => { + await setTabCount(bm, 199); + await setTabCount(bm, 200); + await setTabCount(bm, 201); + await setTabCount(bm, 220); + // 0 → 199 fired the soft threshold; 199 → 200 fires the hard one once. + const hardEntries = capture.entries.filter((e) => e.error?.includes('crossed 200')); + expect(hardEntries.length).toBe(1); + expect(hardEntries[0].tabs).toBe(200); + capture.unsubscribe(); + }); + + test('4. both thresholds fire in order when count jumps from 0 → 250', async () => { + await setTabCount(bm, 250); + expect(capture.entries.length).toBe(2); + expect(capture.entries[0].error).toContain('crossed 50'); + expect(capture.entries[1].error).toContain('crossed 200'); + capture.unsubscribe(); + }); + + test('5. soft threshold re-arms when tab count drops below it', async () => { + await setTabCount(bm, 60); + expect(capture.entries.length).toBe(1); + await setTabCount(bm, 30); + await setTabCount(bm, 55); + expect(capture.entries.length).toBe(2); + expect(capture.entries[1].error).toContain('crossed 50'); + capture.unsubscribe(); + }); + + test('6. hard threshold re-arms when tab count drops below it', async () => { + await setTabCount(bm, 210); + const beforeReArm = capture.entries.filter((e) => e.error?.includes('crossed 200')).length; + expect(beforeReArm).toBe(1); + await setTabCount(bm, 150); + await setTabCount(bm, 220); + const afterReArm = capture.entries.filter((e) => e.error?.includes('crossed 200')).length; + expect(afterReArm).toBe(2); + capture.unsubscribe(); + }); +}); diff --git a/extension/sidepanel.css b/extension/sidepanel.css index 12be632bf..0bc306b25 100644 --- a/extension/sidepanel.css +++ b/extension/sidepanel.css @@ -1152,6 +1152,88 @@ footer { .footer-mem.bad { color: #ef4444; } + +/* ─── Memory pressure toast ─────────────────────────────────── */ +.mem-toast { + position: fixed; + left: 12px; + right: 12px; + bottom: 44px; + z-index: 9999; + background: var(--bg-elevated, #1f1f23); + border: 1px solid #ef4444; + border-radius: var(--radius-md, 6px); + padding: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + font-family: var(--font-sans); + font-size: 12px; +} +.mem-toast-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} +.mem-toast-header strong { + color: var(--text-heading); + font-size: 13px; +} +.mem-toast-close { + background: transparent; + border: none; + color: var(--text-meta); + cursor: pointer; + font-size: 18px; + line-height: 1; + padding: 0 4px; +} +.mem-toast-close:hover { color: var(--text-heading); } +.mem-toast-body { + margin-bottom: 8px; + color: var(--text-body); + line-height: 1.4; +} +.mem-toast-body .mem-toast-row { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; +} +.mem-toast-body .mem-toast-row label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} +.mem-toast-body .mem-toast-size { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-meta); + width: 70px; + text-align: right; +} +.mem-toast-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} +.mem-toast-btn { + background: var(--bg-base); + border: 1px solid var(--zinc-600); + border-radius: var(--radius-sm, 4px); + color: var(--text-body); + cursor: pointer; + font-size: 12px; + padding: 4px 12px; +} +.mem-toast-btn:hover { background: var(--zinc-700); } +.mem-toast-btn.primary { + background: #ef4444; + border-color: #ef4444; + color: #fff; +} +.mem-toast-btn.primary:hover { background: #dc2626; } .port-input { width: 56px; padding: 2px 6px; diff --git a/extension/sidepanel.html b/extension/sidepanel.html index b978f3343..b2ce8a1b5 100644 --- a/extension/sidepanel.html +++ b/extension/sidepanel.html @@ -159,6 +159,19 @@ + + +