Each open conversation holds one long-lived
`GET /v1/sessions/{id}/stream` SSE request for as long as it is bound.
Browsers cap HTTP/1.1 connections at ~6 per origin and that budget is
shared across every tab in the profile, so with 5-6 conversations open
the held streams occupy every slot and unrelated requests (navigation,
API calls) queue behind them — the UI appears hung with nothing in the
app explaining why.
Surface the cause. Each bound stream holds a uniquely-named Web Lock, so
any tab can count how many streams the profile is holding via
`navigator.locks.query()`; at 5 a banner explains the browser limit and
points at the one remedy the user has (close a few tabs).
Web Locks rather than a localStorage/BroadcastChannel heartbeat: the
browser releases a lock automatically when its tab goes away, including
a crash or force-quit, so there are no phantom tabs and no expiry window
to tune.
Two things keep it from crying wolf. The count is per BOUND STREAM, not
per tab, so a tab on the sidebar or settings — which holds no stream and
no connection — never inflates it. And it renders only when the page was
served over HTTP/1.1: HTTP/2 and HTTP/3 multiplex over a single
connection, so the cap does not bind on the Databricks Apps ingress.
This is advisory. It explains the stall rather than removing it; lifting
the limit needs a transport that doesn't consume an HTTP connection per
conversation, which is a follow-up.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Related issue
N/A
Summary
Each open conversation holds one long-lived
GET /v1/sessions/{id}/streamSSE request for as long as it's bound. Browsers cap HTTP/1.1 connections at ~6 per origin, and that budget is shared across every tab in the profile — so with 5–6 conversations open the held streams occupy every slot and unrelated requests (navigation, API calls) queue behind them. The UI appears hung, with nothing in the app explaining why.This PR makes the cause visible:
navigator.locks.query()(web/src/lib/streamTabRegistry.ts).web/src/components/StreamTabLimitBanner.tsx).Scope — this is advisory. It explains the stall; it does not remove it. Lifting the limit needs a transport that doesn't consume an HTTP connection per conversation, which is a follow-up PR (see below).
Design notes
localStorage/BroadcastChannelheartbeat would need both.startStreamPumpowns acquire/release for exactly the window its stream exists — verified that this is the single place a stream is opened (one caller;switchToaborts the prior stream, so a tab never holds two).ELI5
The browser only lets ~6 long-lived connections talk to one site at a time, and every tab shares that budget. Each open chat holds one open forever to receive live updates. Open six chats and there's nothing left for anything else, so the app freezes. This PR notices that situation and says so, instead of leaving you with a mysterious hang.
Test Plan
cd web && pnpm vitest run src/lib/streamTabRegistry.test.ts src/components/StreamTabLimitBanner.test.tsx→ 15 passed. Covers: counting this tab's stream, counting peer tabs' streams, ignoring non-stream locks, prompt release on stream end, unique lock names (a shared name would pin the count at 1), no-op without Web Locks, and the HTTP/1.1-vs-h2/h3 gate including the unknown-protocol default.cd web && pnpm vitest run src/store/chatStore.test.ts→ 305 passed (thestartStreamPumpacquire/release wiring changes nothing about the pump's behavior).src/shell/NewChatDialog.test.tsx— confirmed identical on cleanorigin/main(stashed this branch's changes and re-ran), so unrelated to this PR.tests/e2e_ui/chat/test_stream_tab_limit_banner.pyopens 5 conversation tabs in one browser context (tabs in one context share both the connection pool and the Web Locks scope, like real tabs in one profile), asserts the banner appears, then closes a tab and asserts it clears.just dev, open 5 conversations in separate tabs against the local HTTP/1.1 server → banner appears; close one → it clears. Withh2in front, the banner stays hidden.Demo
Type of change
Test coverage
Coverage notes
Manual verification: confirmed the banner appears at 5 stream-holding tabs on a local HTTP/1.1 server, clears when a tab closes, and stays hidden behind an HTTP/2 front end. Automated coverage is unit-level: the lock registry (count, release, foreign locks, unique naming, unsupported-browser fallback, protocol gate) and the banner (threshold, dismiss, re-arm on worsening, multiplexed suppression).
E2E coverage opens 5 conversation tabs in a single browser context and asserts both the appearance and the self-clearing of the banner. Before writing it I confirmed the premise empirically with a standalone Playwright probe: 3 pages in one context see all 3 stream locks, a page in a second context sees none of them (so separate contexts would have silently counted only themselves), and closing a page drops its lock — which is exactly the auto-release property that makes Web Locks the right primitive here.
I could not execute the e2e test locally. The local harness currently fails to bring a runner online within its 30s budget —
test_smoke.py, which this PR does not touch and which passes in CI, fails identically on this machine, so it is environmental rather than caused by this test. The e2e result in CI is therefore the first real execution; if it fails I will fix it rather than waive the gate.Reviewer note — this explains the hang rather than fixing it. At 6 simultaneously-visible conversation tabs the UI will still stall; the banner just makes the cause legible. The transport fix that actually removes the ceiling is split out per review feedback: #4342 (event WebSocket — server route + client transport + tests, already implemented and green).
Changelog
[UI] Omnigent now warns when enough tabs are open to hit the browser's connection limit and slow the app down.