Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions tests/e2e_ui/chat/test_stream_tab_limit_banner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""E2E: the too-many-tabs banner appears when open streams fill the HTTP pool.

Each tab with a conversation open holds one long-lived
``GET /v1/sessions/{id}/stream`` SSE request. Browsers cap HTTP/1.1
connections at ~6 per origin and share that budget across every tab in the
profile, so once ~6 conversations are open the held streams occupy every slot
and unrelated requests queue behind them — the app appears hung with nothing
explaining why. The banner makes that cause visible.

Why several pages in ONE browser context: Web Locks (which back the count) are
scoped to a browsing-context group, and pages in one Playwright context share a
lock manager — the same scope as real tabs in one browser profile. Separate
contexts are isolated from each other and would each count only themselves.

The same conversation opened N times is deliberate and realistic: every tab runs
its own store and stream pump, so N tabs on one session hold N streams and
consume N connections, exactly as N tabs on N sessions would.

A failure here means one of:

- The lock registry stopped counting held streams
(``web/src/lib/streamTabRegistry.ts``), or ``startStreamPump`` stopped
acquiring/releasing a slot for the stream's lifetime.
- The banner's threshold or render logic regressed
(``web/src/components/StreamTabLimitBanner.tsx``), or it fell out of the
standalone root in ``web/src/main.tsx``.
- The HTTP/1.1 gate started suppressing the banner on the local dev server
(which serves HTTP/1.1, so the cap genuinely applies).
"""

from __future__ import annotations

from playwright.sync_api import Browser, expect

_COMPOSER = "Ask the agent anything…"
# Mirrors WARN_AT_TABS in web/src/components/StreamTabLimitBanner.tsx: the
# banner fires while the app still works, one slot before the pool is full.
_WARN_AT_TABS = 5


def test_banner_warns_once_open_tabs_threaten_the_connection_pool(
browser: Browser,
seeded_session: tuple[str, str],
) -> None:
"""Opening enough conversation tabs surfaces the warning; closing clears it.

:param browser: Playwright session-scoped browser. One context stands in
for one browser profile, whose tabs share both the connection pool and
the Web Locks scope.
:param seeded_session: ``(base_url, session_id)`` from the fixture.
"""
base_url, session_id = seeded_session
context = browser.new_context()
try:
pages = []
for _ in range(_WARN_AT_TABS):
page = context.new_page()
page.goto(f"{base_url}/c/{session_id}")
# Wait for the composer before opening the next tab: the stream (and
# so the lock) is only held once the conversation has actually bound.
expect(page.get_by_placeholder(_COMPOSER)).to_be_visible(timeout=30_000)
pages.append(page)

# Assert on the last tab: its own slot acquisition refreshes the count
# immediately, so it doesn't wait on the peer-tab poll interval.
banner = pages[-1].get_by_role("status").filter(has_text="conversation open")
expect(banner).to_be_visible(timeout=30_000)
expect(banner).to_contain_text(f"{_WARN_AT_TABS} tabs have a conversation open")

# Closing a tab releases its stream — and its connection — so the
# warning must retire itself rather than persist after the user has
# already acted on it.
pages[0].close()
expect(banner).not_to_be_visible(timeout=30_000)
finally:
context.close()
78 changes: 78 additions & 0 deletions web/src/components/StreamTabLimitBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

const { mockUseStreamTabCount, mockLowLimit } = vi.hoisted(() => ({
mockUseStreamTabCount: vi.fn(),
mockLowLimit: vi.fn(),
}));

vi.mock("@/hooks/useStreamTabCount", () => ({
useStreamTabCount: mockUseStreamTabCount,
}));
vi.mock("@/lib/streamTabRegistry", () => ({
connectionHasLowStreamLimit: mockLowLimit,
}));

import { StreamTabLimitBanner } from "./StreamTabLimitBanner";

/** Default to the case where the connection cap actually binds (HTTP/1.1). */
function setup(tabCount: number, lowLimit = true): void {
mockUseStreamTabCount.mockReturnValue(tabCount);
mockLowLimit.mockReturnValue(lowLimit);
}

afterEach(() => {
vi.clearAllMocks();
});

describe("StreamTabLimitBanner", () => {
it("stays hidden while tab count is below the warning threshold", () => {
setup(4);
const { container } = render(<StreamTabLimitBanner />);
expect(container).toBeEmptyDOMElement();
});

it("warns once enough tabs hold a stream to threaten the connection pool", () => {
setup(5);
render(<StreamTabLimitBanner />);
expect(screen.getByRole("status")).toHaveTextContent("5 tabs have a conversation open");
});

it("stays hidden on multiplexed connections where the cap does not bind", () => {
// HTTP/2 / HTTP/3: N streams share one connection, so there is nothing to
// warn about and warning anyway would be a false alarm in production.
setup(8, false);
const { container } = render(<StreamTabLimitBanner />);
expect(container).toBeEmptyDOMElement();
});

it("hides after dismissal", () => {
setup(5);
render(<StreamTabLimitBanner />);
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});

it("re-warns when the situation worsens after a dismissal", () => {
setup(5);
const { rerender } = render(<StreamTabLimitBanner />);
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(screen.queryByRole("status")).not.toBeInTheDocument();

// Opening yet another conversation tab is new information — the pool is
// now fuller than when the user dismissed.
setup(6);
rerender(<StreamTabLimitBanner />);
expect(screen.getByRole("status")).toHaveTextContent("6 tabs have a conversation open");
});

it("stays dismissed when the count drops back", () => {
setup(6);
const { rerender } = render(<StreamTabLimitBanner />);
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));

setup(5);
rerender(<StreamTabLimitBanner />);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
});
63 changes: 63 additions & 0 deletions web/src/components/StreamTabLimitBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useStreamTabCount } from "@/hooks/useStreamTabCount";
import { connectionHasLowStreamLimit } from "@/lib/streamTabRegistry";
import { cn } from "@/lib/utils";

/**
* Tabs-with-an-open-conversation at which we warn.
*
* Browsers allow ~6 concurrent HTTP/1.1 connections per origin, shared across
* every tab in the profile, and each open conversation holds one for its live
* event stream. At 6 the pool is full and unrelated requests — navigation, API
* calls — queue behind the streams, which presents as the whole app hanging.
* Warn at 5 so the message arrives while the app still works, rather than
* appearing (or failing to load) once things are already wedged.
*/
const WARN_AT_TABS = 5;

/**
* Warns when enough tabs hold a conversation stream to exhaust the browser's
* per-origin connection pool.
*
* Advisory only — it explains a stall the app cannot otherwise account for and
* names the one remedy available to the user (close a tab). It does not prevent
* the exhaustion; removing the limit needs a transport that doesn't consume an
* HTTP connection per conversation.
*
* Renders nothing when the page was served over HTTP/2/3 (multiplexed, so the
* cap doesn't bind) or where Web Locks is unavailable (count reads 0).
*/
export function StreamTabLimitBanner() {
const tabCount = useStreamTabCount();
const [dismissedAt, setDismissedAt] = useState<number | null>(null);

// Re-arm after dismissal only if the situation gets WORSE. Dismissing at 5
// shouldn't re-nag at 5, but crossing to 6 is new information.
const suppressed = dismissedAt !== null && tabCount <= dismissedAt;

if (tabCount < WARN_AT_TABS || suppressed || !connectionHasLowStreamLimit()) {
return null;
}

return (
<div
role="status"
aria-live="polite"
className={cn(
"fixed inset-x-0 top-0 z-[100] flex flex-wrap items-center justify-center gap-x-3 gap-y-1 px-4 py-2",
"border-b border-border bg-background/95 backdrop-blur",
"supports-[backdrop-filter]:bg-background/80",
)}
>
<span className="text-ui text-foreground">
{tabCount} tabs have a conversation open. Browsers limit how many live connections one site
may hold, so opening more can make Omnigent slow to respond — closing a few tabs restores
it.
</span>
<Button size="sm" variant="ghost" onClick={() => setDismissedAt(tabCount)}>
Dismiss
</Button>
</div>
);
}
15 changes: 15 additions & 0 deletions web/src/hooks/useStreamTabCount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// React binding for the count of same-origin tabs holding a session event
// stream. Backs the too-many-tabs warning banner.

import { useSyncExternalStore } from "react";
import { getStreamTabCount, subscribeStreamTabCount } from "@/lib/streamTabRegistry";

/**
* Subscribe to how many same-origin tabs currently hold a session event stream.
*
* @returns The observed count (including this tab), or 0 during SSR / where the
* Web Locks API is unavailable.
*/
export function useStreamTabCount(): number {
return useSyncExternalStore(subscribeStreamTabCount, getStreamTabCount, () => 0);
}
Loading
Loading