Skip to content

fix(webapp): an unstreamable connection says so instead of going quiet - #218

Merged
ssowonny merged 1 commit into
mainfrom
fix/sse-streams-fail-loudly-when-unflushable
Sep 16, 2026
Merged

ssowonny merged 1 commit into
mainfrom
fix/sse-streams-fail-loudly-when-unflushable

Conversation

@ssowonny

Copy link
Copy Markdown
Contributor

TL;DR

  • Collaborative editing was dead on the managed hub, and live change notification with it — but every stream answered 200, so nothing anywhere said so.
  • The actual culprit was a middleware in the cloud repo (fixed in beardrive-cloud#42); this is the reason it stayed invisible for as long as it did.
  • Both SSE handlers now check flushability before writing a byte, and answer 500 plus a log line naming the cause.
  • No behaviour change on a healthy writer.

What went wrong

handleEvents and handleCollabStream both did this:

w.WriteHeader(http.StatusOK)
if err := rc.Flush(); err != nil {
    return // not a streaming-capable writer; nothing to do but leave
}

By the time the question is asked, the 200 and the text/event-stream headers are already on the wire. Returning hangs up, and what the browser receives is a valid, empty, closed stream — so EventSource retries it, forever, without ever firing an error it can distinguish from a flaky network.

On the managed hub that is exactly what happened: an analytics middleware wrapped http.ResponseWriter and implemented neither Flush nor Unwrap, so http.ResponseController could not reach the real writer. Every stream came back:

HTTP/2 200
content-type: text/event-stream
content-length: 0

Live updates delivered no frames. Two browser tabs on one document each edited in their own world, while the POST leg kept answering {"ok":true}. Nothing failed to compile and nothing was logged — it was found by opening two tabs.

The change

refuseUnstreamable walks the Unwrap chain before anything is written, so the answer can be a real 500 and a log line naming the cause:

bdrive: cannot stream /api/p/<id>/events — an http.ResponseWriter in the
middleware chain implements neither Flush nor Unwrap, so live updates and
collaborative editing cannot work

The hub can't repair somebody's broken wrapper. It can refuse to pretend the stream is fine.

The post-header rc.Flush() check stays — a later flush can still fail on a dead connection, which is the case it now describes.

Tests

stream_guard_test.go covers all three states through the real handler:

writer before after
middleware with no Flush/Unwrap 200, 0 bytes 500 + log
same, plus Unwrap() 200, 0 bytes streams normally
plain net/http writer streams streams

Each fails without the fix — checked by reverting in a throwaway tree.

Verification

go vet ./... clean. internal/webapp, internal/syncer, cmd/bdrive pass. Playwright e2e 243/243, and the guard never fires there — the harness's writer is flushable, which is the point.

Two notes worth carrying forward:

  • Not fixed here: every tab holds a permanent /events stream and every editing tab a second /collab one. On HTTP/1.1 the browser allows six sockets per origin across all tabs, so six browsing tabs — or two editing plus two idle — wedge the whole app, POSTs included. Measured, reproducible, separate change.
  • cmd/bdrive/desktop.go's copyFlushing has the same fail-quiet shape, left alone: nothing wraps the sidecar's writer, and it is not on the path this broke.

🤖 Generated with Claude Code

Both SSE handlers set their headers, wrote a 200, and only then asked
whether the writer could flush — answering "no" by returning. What reaches
the browser then is a valid, empty, closed 200, which EventSource retries
forever. No error, no log, nothing to search for.

That is exactly how it failed on the managed hub: an analytics middleware
there wrapped http.ResponseWriter and implemented neither Flush nor
Unwrap, so http.ResponseController could not reach the real writer.
content-length: 0 on every /events and /collab stream. Live change
notification delivered no frames and collaborative editing delivered no
keystrokes, while the POST leg kept answering {"ok":true} — for as long as
it took someone to open two tabs and notice.

The hub cannot repair a broken wrapper, but it can refuse to pretend.
refuseUnstreamable walks the Unwrap chain BEFORE a byte is written, so the
answer can be a 500 and a log line naming the cause. The next middleware
that does this costs one log line, not a silent feature outage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ssowonny
ssowonny merged commit 0548d55 into main Sep 16, 2026
3 of 4 checks passed
@ssowonny
ssowonny deleted the fix/sse-streams-fail-loudly-when-unflushable branch September 16, 2026 14:35
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