Skip to content

fix(webapp): tabs of one project share a single /events stream (BEA-228) - #219

Merged
ssowonny merged 2 commits into
mainfrom
worktree-bea-228-shared-events-stream
Sep 16, 2026
Merged

ssowonny merged 2 commits into
mainfrom
worktree-bea-228-shared-events-stream

Conversation

@ssowonny

Copy link
Copy Markdown
Contributor

TL;DR

  • Six open tabs could wedge the whole app, not just stop live updates — each tab held its own /events stream and burned one of the browser's ~6 per-origin sockets.
  • Every tab of a project got the identical fan-out, so all but one of those streams bought nothing and cost a hub concurrency slot each.
  • One tab now owns the stream and relays frames to the rest; the others run the handler they already ran.
  • Leadership is a Web Lock, so a crashed or force-quit tab hands off correctly — the case a heartbeat/TTL scheme gets wrong.
  • Known gap: Web Locks needs a secure context, so a plain-http LAN hub still gets a stream per tab — the deployment that most wants this is the one that can't have it yet.

Closes BEA-228. Split out of BEA-227, which tracks the rest (device-side streams and hub scale-out).

The problem

useProjectEvents did new EventSource(apiBase + "events") per mount, and there was no SharedWorker or BroadcastChannel anywhere in the frontend — so every tab dialed independently. Each SSE request is permanently in-flight, which costs at both ends:

  • Hub: one per-instance concurrency slot per tab, held until the stream ends.
  • Browser: one of ~6 per-origin HTTP/1.1 sockets. Two editing tabs (each also holding /collab) plus two idle ones exhausts the pool, and subsequent API calls queue behind streams that never finish.

Since all tabs of a project receive byte-identical frames, tab two onward was pure waste.

The change

One tab holds the EventSource and rebroadcasts each frame verbatim over a BroadcastChannel keyed per project. The frame handler was extracted so it runs identically whether a frame arrived off the wire or from a peer tab — the relay ships the raw data string rather than anything it has already interpreted.

Leader election is a Web Lock held for the leader's lifetime. The browser reassigns it when that tab's document is destroyed, including a crash or force-quit, so there is no stale leader to detect and no timeout to tune. Frames lost in the handover gap are the 15s poll's job, as they were before any of this existed.

Where BroadcastChannel or navigator.locks is missing, every tab opens its own stream and behaves exactly as before.

/collab is deliberately left alone. Two tabs editing one document are two distinct CRDT peers with their own awareness state; sharing that stream would be wrong, not thrifty.

Verification

Two specs in live.spec.ts, both using two pages in one context (two contexts are two browsers and are supposed to hold a stream each — running it that way would pass for the wrong reason):

spec asserts
a second tab rides the first tab's stream exactly 1 /events request for 2 tabs, and the follower still updates live within 10s
closing the leader tab hands the stream to a survivor survivor keeps updating after the leader is closed without warning

Negative control run: with sharing forced off, the first spec sees 2 streams where it asserts 1 — so the test measures the fix rather than passing either way.

tsc, go build ./..., go vet ./... clean; full live.spec.ts 6/6.

Architecture changes

architecture/webapp-frontend.md: the hooks class member for useProjectEvents changes from a per-tab EventSource to one stream per browser, and the hook gains two browser-platform collaborators that were not previously drawn — a BroadcastChannel (per-project relay) and a Web Lock (leader election). A note records the handover semantics, the secure-context fallback, and why /collab stays per-tab.

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

flowchart TB
    Browser["Browser.tsx"]
    hooks["<div style='text-align:left'><b>hooks</b><br/>+useConfig<br/>+useHub<br/>+useBrowse<br/><span style='background:#ef444455;padding:0 4px;border-radius:3px'>❌ <s>+useProjectEvents (SSE → invalidate)</s></span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +useProjectEvents (one SSE per BROWSER → invalidate)</span><br/>+usePresence (10s heartbeat)<br/>+useTextAt (any URL) → useBlobText (sha-keyed, immutable)<br/>+fetchBlobText(url) BlobText<br/>+fileURLFor(apiBase, path, version) string</div>"]
    WebLock["<div style='text-align:left'><b>Web Lock</b><br/>navigator.locks<br/>bdrive:events:&lt;apiBase&gt;:leader</div>"]
    Channel["<div style='text-align:left'><b>BroadcastChannel</b><br/>bdrive:events:&lt;apiBase&gt;</div>"]
    Note["ONE stream per browser, not per tab.<br/>Leader holds the EventSource;<br/>followers run the same handler<br/>on the same raw data string.<br/>Lock is released by the browser when<br/>the tab dies — crash and force-quit included.<br/>No secure context → a stream per tab, as before.<br/>/collab deliberately NOT shared."]
    Browser --> hooks
    hooks -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ elects the one tab that dials</span>" --> WebLock
    hooks -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ relays each frame verbatim</span>" --> Channel
    hooks -.- Note
    classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px
    classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2
    class WebLock added
    class Channel added
    class Note noteBox
    linkStyle 1 stroke:#22c55e,stroke-width:2px
    linkStyle 2 stroke:#22c55e,stroke-width:2px
Loading

🤖 Generated with Claude Code

ssowonny and others added 2 commits September 16, 2026 11:28
Every tab dialed /events for itself. Each of those is a permanently
in-flight request, so N tabs burned N of the hub's finite per-instance
concurrency — and on HTTP/1.1 (local and self-hosted plain-http hubs) N of
the browser's ~6 per-origin sockets, which is how six tabs wedged the whole
app rather than merely stopping live updates. Every tab of a project
receives the identical fan-out, so N-1 of those streams bought nothing.

One tab now holds the EventSource and relays each frame verbatim over a
BroadcastChannel; the rest run the handler they already ran. The relay
ships the raw `data` string, so a frame is handled identically whether it
came off the wire or from a peer tab.

Leadership is a Web Lock held for the leader's lifetime. The browser hands
it to the next waiter when that tab dies — including a crash or force-quit,
which is exactly the case a heartbeat-and-TTL scheme gets wrong. No stale
leader to detect, no timeout to tune.

Where Web Locks or BroadcastChannel are missing the hook opens a stream per
tab, as before. Web Locks needs a secure context, so a hub served over plain
http to a LAN address gets the old behavior — the deployment that most wants
this is the one that cannot have it yet.

/collab is deliberately left alone: two tabs editing one document are two
distinct CRDT peers with their own awareness state.

Verified by negative control — with sharing forced off the new spec sees 2
streams where it asserts 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er tab

Records the leader/follower split in the frontend diagram: the Web Lock that
elects the tab holding the EventSource, the BroadcastChannel that relays
frames to the rest, the secure-context fallback, and why /collab stays
per-tab.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ssowonny
ssowonny merged commit 1277882 into main Sep 16, 2026
2 checks passed
@ssowonny
ssowonny deleted the worktree-bea-228-shared-events-stream branch September 16, 2026 18:41
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