Skip to content

fix: Make many concurrent ompweb tabs work - #257

Open
abread wants to merge 3 commits into
kahme247:mainfrom
abread:concurrent-tabs
Open

abread wants to merge 3 commits into
kahme247:mainfrom
abread:concurrent-tabs

Conversation

@abread

@abread abread commented Oct 13, 2026

Copy link
Copy Markdown

Problem

Browsers allow only six HTTP/1.1 connections per host, shared by every tab, and each long-lived SSE response holds one open for as long as it lives. Each omp-web tab opened up to four:

Stream Opened by
/api/agent/running/events?clientId= sidebar (running badges, session-list hints, the tab's notifications)
/api/agent/host-tools/events AppShell (cross-session open_url / notify / open_file)
/api/agent/[id]/events chat (agent events for the open session)
/api/files/...?type=watch file viewer (changes to the open file)

Two or three working tabs used up the limit. After that, every other request from every tab (sending a message, loading a session, reading a file) waited in the browser's queue until a tab was closed. The app looked frozen.

Fix

Use only one connection per browser, carrying many subscriptions:

flowchart LR
    T1[Tab 1] --> W
    T2[Tab 2] --> W
    T3[Tab N] --> W
    W[SharedWorker] -->|"one SSE: GET /api/events"| S[Server hub]
    W -->|"POST /api/events: subscribe/unsubscribe"| S
    S --> C1[running]
    S --> C2[notifications]
    S --> C3[host-tools]
    S --> C4[session]
    S --> C5[file]
Loading
  • /api/events (lib/event-stream-hub.ts, lib/event-stream-protocol.ts): a single SSE response that carries any number of channel subscriptions, added and removed with POST /api/events. Each subscription's frames arrive as event: <subId>;
    connection control frames arrive as event: mux (hello / open / end).
  • public/event-stream-worker.js: a SharedWorker that holds the browser's only /api/events EventSource and multiplexes every tab's subscriptions over it. Where SharedWorker is missing (e.g. Chrome for Android) it runs as a dedicated Worker, so
    each tab still uses a single connection. Closed tabs are detected with a Web Lock held for the tab's lifetime. The worker URL includes a content hash, so a new version never serves tabs still running the old one.
  • lib/event-stream.ts: openEventStream(channel) returns an object that behaves like an EventSource (onerror while CONNECTING means it's reconnecting on its own; CLOSED means it gave up). The sidebar, shell, chat hook, file viewer and
    notifications hook only needed their constructor swapped.

Channels are split by who subscribes, how long it lives, what it's keyed on and which access check applies:

Channel Carries Subscribed from
running running-session ids, session-list refresh hints sidebar
notifications toasts and in-page system notifications for one tab (clientId) useNotifications in AppShell
host-tools cross-session host tool calls AppShell
session agent events for one live session (watch-only: it never starts an omp process) chat hook
file changes to one file (same access check as /api/files) file viewer

Comparison with behaviour from the old streams:

  • While the connection is backed up, message_update frames for a session are still merged so only the latest is sent.
  • A frame that can't be serialized now ends only its own subscription. Before, it would close the shared connection that every other tab depends on.
  • File-access checks are pulled into lib/file-authorization.ts: resolving ~/ and workspace-relative paths, and refusing omp's credential files under the config folder. /api/files and the file channel now run the same check.
  • The provider-login stream (ModelsConfig) and the clone progress stream (DirectoryPicker) still open their own connections. Each lasts only for one user action, so they don't pile up across tabs.
  • Removed: /api/agent/[id]/events, /api/agent/running/events, /api/agent/host-tools/events, /api/files?type=watch, and the window event the sidebar used to relay notifications to useNotifications.

User-visible changes

  • Any number of tabs keeps working; requests no longer pile up behind open streams.
  • A tab on Settings now gets notifications. They used to ride the sidebar's stream, and the sidebar unmounts while Settings is open. So that tab got no toasts and counted as unreachable, and its notifications went to your other devices instead. They
    now have their own channel, mounted in the shell.

Testing

  • npm test: 1409 pass, 0 fail, at the tip and at commit 2 on its own. Typecheck is clean; lint has no new warnings.
  • New and moved tests cover each channel: subscribe/unsubscribe, listener leaks on disconnect, the file access check, merged updates under backpressure, notification reachability and client-id validation, and host-tool delivery. The worker suite covers
    sharing between tabs, reconnects, server restarts, and closed tabs.
  • Manual check in headless Chromium with 2–3 tabs: one shared worker held one open /api/events connection carrying every tab's subscriptions, and the pages themselves opened no EventSources. Switching a tab to Settings dropped its running
    subscription but kept notifications and host-tools.

Browsers allow six HTTP/1.1 connections per host across all tabs, and every
long-lived SSE response pins one. Each omp-web tab held up to four (running
badges and notifications, cross-session host tools, the attached chat, the
watched file), so two or three tabs starved every other request until tabs
were closed.

Add one SSE stream that carries any number of channel subscriptions
(running, host-tools, session, file) attached via POST, so a single
connection can serve every tab. A running subscription that names the tab's
notification client id also carries that tab's notifications. Session frames
keep the per-subscription message_update coalescing under backpressure; an
unserializable frame now ends only its own subscription instead of the
shared socket.

File-access authorization, including the read-path resolution (`~/`,
root-relative paths) and the config-root credential denial, is extracted so
/api/files and the file channel share one allow-list gate.
…Worker

Each tab opened its own long-lived SSE responses (sidebar running badges
and notifications, cross-session host tools, the attached chat, the watched
file). Browsers cap HTTP/1.1 at six connections per host across every tab,
so two or three working tabs pinned all of them and every other request
from every tab queued forever.

A SharedWorker (public/event-stream-worker.js; a dedicated Worker where
SharedWorker is unavailable) now holds the only /api/events EventSource and
multiplexes every tab's subscriptions over it. Tabs use openEventStream()
from lib/event-stream.ts, which keeps EventSource semantics so the sidebar,
shell, chat hook and file viewer only swap their constructor. Dead tabs are
detected via a lifetime Web Lock, and the worker URL is content-hashed so a
changed worker never serves stale tabs.

The per-stream routes (/api/agent/[id]/events, /api/agent/running/events,
/api/agent/host-tools/events, /api/files?type=watch) are removed; their
tests move to the hub and worker suites.
Notifications rode the sidebar's running stream because, before the
multiplexed socket, every extra stream cost one of the browser's six
connections. That reason is gone: a channel costs a POST, not a socket.

The piggyback also tied notifications to the sidebar's lifetime. The sidebar
unmounts while Settings is open, so that tab stopped receiving toasts and
stopped counting as reachable, which let other devices take over a
notification the user was present for.

Add a `notifications` channel keyed by the tab's client id (validated before
anything attaches) and subscribe to it from useNotifications in AppShell,
next to host-tools. The running channel goes back to sidebar state only,
and the window-event relay between the sidebar and useNotifications is
removed.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant