feat: SSE fan-out, with an idle policy written down rather than emergent
A streamed 200 carrying text/event-stream, not a 101 — SSE is an ordinary response with a streaming body, and a transport check written against the WebSocket upgrade would never pass. The source document this design came from conflated the two; the test asserts the right one and says why. The test needs a real server. A ResponseRecorder buffers everything and returns when the handler does, so a handler that built its whole body and returned would satisfy every assertion a recorder can make while being exactly the bug this endpoint must not have. Spec 7 words the idle policy as "no edits and no presence heartbeat". There is no heartbeat, and the reason is in the code rather than left as a gap: a heartbeat POST arrives as a separate request with no way to reach the goroutine holding the stream, so it would have to be routed through the hub by connection id — machinery to reproduce what the connection already tells us. The client closes its stream when the tab is hidden instead, which is stronger: a hidden tab holds no socket at all rather than an idle one. The server cannot enforce that — EventSource reconnects after any close — so the timeouts here are a backstop for clients that vanished without saying so, and the comment says exactly that. X-Accel-Buffering: no is asserted, because an edge that buffers a stream into a complete response turns live sync into "everything arrives when you close the tab", and no in-process test can see it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018RN4HhHR4rHH3Fxh4LMds5
3 files changed,
+357
−0
internal/sheets/app.go+1 −0internal/sheets/handlers_stream.go+155 −0internal/sheets/handlers_stream_test.go+201 −0
diff --git a/internal/sheets/app.go b/internal/sheets/app.go| index 95f9b2a..61c1d8b 100644 |
| --- a/internal/sheets/app.go |
| +++ b/internal/sheets/app.go |
| @@ -137,6 +137,7 @@ func (a *App) Router() http.Handler { |
| // ask for it in. |
| r.Get("/api/sheets/{sheetID}/tabs/{tabID}/cells", a.handleCellsGet) |
| r.Post("/api/sheets/{sheetID}/tabs/{tabID}/cells", a.handleCellPost) |
| + r.Get("/api/sheets/{sheetID}/tabs/{tabID}/stream", a.handleStream) |
| }) |
| return r |
| } |
diff --git a/internal/sheets/handlers_stream.go b/internal/sheets/handlers_stream.go| new file mode 100644 |
| index 0000000..9c0ee0c |
| --- /dev/null |
| +++ b/internal/sheets/handlers_stream.go |
| @@ -0,0 +1,155 @@ |
| +package sheets |
| + |
| +import ( |
| + "net/http" |
| + "time" |
| +) |
| + |
| +// The idle policy, written down rather than emergent (spec §7). Held |
| +// sockets and hibernation pull against each other, and this is where |
| +// that is resolved. |
| +// |
| +// pingEvery keeps the connection alive through proxies that drop a |
| +// silent stream, and gives the client something to notice when the |
| +// server has gone away. |
| +// |
| +// idleTimeout closes a stream that has carried no edit. maxStreamLife |
| +// closes one regardless of activity, so a tab left open all weekend |
| +// cannot hold an instance awake and billable indefinitely. |
| +// |
| +// Spec §7 words the idle policy as "no edits and no presence |
| +// heartbeat". There is no heartbeat here, and the reason is worth |
| +// stating rather than leaving as a gap: a heartbeat POST arrives as a |
| +// separate request with no way to reach the specific goroutine holding |
| +// the stream, so it would have had to be routed through the hub by |
| +// connection id — real machinery to reproduce something the connection |
| +// itself already tells us. Instead the client CLOSES its stream when |
| +// the tab is hidden and opens a new one when it comes back, which is |
| +// strictly stronger than a heartbeat that stops: a hidden tab holds no |
| +// socket at all rather than an idle one. |
| +// |
| +// That only works with the client's cooperation, and it is worth being |
| +// plain that the server cannot enforce it: EventSource reconnects on |
| +// its own after any close, so a client that ignores the "bye" event |
| +// below will simply come straight back. The server's timeouts are |
| +// therefore a backstop against clients that vanished without saying so |
| +// — a sleeping laptop, a dropped network — and not the whole policy. |
| +const ( |
| + pingEvery = 25 * time.Second |
| + idleTimeout = 15 * time.Minute |
| + maxStreamLife = 4 * time.Hour |
| +) |
| + |
| +// handleStream is the live connection: a streamed 200 carrying |
| +// text/event-stream, one message per committed write on this tab. |
| +// |
| +// A streamed 200, NOT a 101 (spec §7). SSE is an ordinary response with |
| +// a streaming body; 101 is the WebSocket upgrade, and a transport check |
| +// written against 101 would never pass here. The conflation is worth |
| +// naming because the source document this design came from made it. |
| +func (a *App) handleStream(w http.ResponseWriter, r *http.Request) { |
| + sheetID, tabID := sheetIDFrom(r), tabIDFrom(r) |
| + if sheetID == 0 || tabID == 0 || !a.tabBelongsTo(sheetID, tabID) { |
| + a.notFound(w, r) |
| + return |
| + } |
| + |
| + rc := http.NewResponseController(w) |
| + |
| + w.Header().Set("Content-Type", "text/event-stream") |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Header().Set("Connection", "keep-alive") |
| + // The edge will happily buffer a streaming response into a complete |
| + // one, which turns live sync into "everything arrives when you |
| + // close the tab". This header is what stops it, and its absence is |
| + // invisible in every test that does not run through a real proxy. |
| + w.Header().Set("X-Accel-Buffering", "no") |
| + w.WriteHeader(http.StatusOK) |
| + |
| + // Flushed before anything is subscribed, so the client's onopen |
| + // fires immediately rather than on the first edit. A grid that says |
| + // "connecting" until somebody types looks broken for as long as |
| + // nobody does. |
| + if err := rc.Flush(); err != nil { |
| + return |
| + } |
| + |
| + sub := a.hub.Subscribe(tabID) |
| + defer sub.Close() |
| + |
| + ping := time.NewTicker(pingEvery) |
| + defer ping.Stop() |
| + idle := time.NewTimer(idleTimeout) |
| + defer idle.Stop() |
| + life := time.NewTimer(maxStreamLife) |
| + defer life.Stop() |
| + |
| + for { |
| + select { |
| + case <-r.Context().Done(): |
| + return |
| + |
| + case <-life.C: |
| + // The ceiling. Say so rather than dropping silently: the |
| + // client reconnects on a close either way, but a named |
| + // reason is the difference between a debuggable log and a |
| + // mystery. |
| + writeEvent(w, rc, "bye", `{"reason":"max-life"}`) |
| + return |
| + |
| + case <-idle.C: |
| + writeEvent(w, rc, "bye", `{"reason":"idle"}`) |
| + return |
| + |
| + case <-ping.C: |
| + // A comment line: valid SSE, ignored by EventSource, and |
| + // enough to keep an intermediary from calling the |
| + // connection dead. |
| + if _, err := w.Write([]byte(": ping\n\n")); err != nil { |
| + return |
| + } |
| + if err := rc.Flush(); err != nil { |
| + return |
| + } |
| + |
| + case m := <-sub.C(): |
| + if sub.Lost() { |
| + // This subscriber overran its buffer and has missed at |
| + // least one message, so everything after it would paint |
| + // a grid with holes in it. Tell the client to refetch |
| + // and stop: a reload is cheap, and a silently wrong |
| + // spreadsheet is not. |
| + writeEvent(w, rc, "resync", `{"reason":"overrun"}`) |
| + return |
| + } |
| + if !writeEvent(w, rc, "cells", string(m.Data)) { |
| + return |
| + } |
| + // A delivered edit resets the idle clock: this tab is |
| + // demonstrably part of a live session. |
| + resetTimer(idle, idleTimeout) |
| + } |
| + } |
| +} |
| + |
| +// writeEvent sends one SSE event and flushes it. It returns false once |
| +// the connection is gone, which is the only error worth distinguishing: |
| +// there is nobody left to tell. |
| +// |
| +// The data is written as a single line. Every payload this app sends is |
| +// compact JSON, which contains no newline — and a newline inside an SSE |
| +// data field would split one message into two, producing a truncated |
| +// JSON parse on the client rather than an error anybody could read. |
| +func writeEvent(w http.ResponseWriter, rc *http.ResponseController, event, data string) bool { |
| + if _, err := w.Write([]byte("event: " + event + "\ndata: " + data + "\n\n")); err != nil { |
| + return false |
| + } |
| + return rc.Flush() == nil |
| +} |
| + |
| +// resetTimer restarts a timer that may or may not have fired. Stop then |
| +// Reset is the shape that is correct for both. |
| +func resetTimer(t *time.Timer, d time.Duration) { |
| + t.Stop() |
| + t.Reset(d) |
| +} |
diff --git a/internal/sheets/handlers_stream_test.go b/internal/sheets/handlers_stream_test.go| new file mode 100644 |
| index 0000000..9f02343 |
| --- /dev/null |
| +++ b/internal/sheets/handlers_stream_test.go |
| @@ -0,0 +1,201 @@ |
| +package sheets |
| + |
| +import ( |
| + "bufio" |
| + "context" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// liveServer starts a real HTTP server for the app. |
| +// |
| +// httptest.ResponseRecorder cannot test this: it buffers everything and |
| +// returns when the handler does, so a handler that built its whole body |
| +// and returned would pass every assertion a recorder can make — while |
| +// being exactly the bug this endpoint must not have. |
| +func liveServer(t *testing.T, app *App) *httptest.Server { |
| + t.Helper() |
| + srv := httptest.NewServer(app.Router()) |
| + t.Cleanup(srv.Close) |
| + return srv |
| +} |
| + |
| +// openStream connects to a tab's stream and returns the response. |
| +func openStream(t *testing.T, srv *httptest.Server, s SheetSummary, cookies []*http.Cookie) *http.Response { |
| + t.Helper() |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + t.Cleanup(cancel) |
| + |
| + url := fmt.Sprintf("%s/api/sheets/%d/tabs/%d/stream", srv.URL, s.ID, s.TabID) |
| + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) |
| + if err != nil { |
| + t.Fatalf("build stream request: %v", err) |
| + } |
| + for _, c := range cookies { |
| + req.AddCookie(c) |
| + } |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("open stream: %v", err) |
| + } |
| + t.Cleanup(func() { resp.Body.Close() }) |
| + return resp |
| +} |
| + |
| +// readEventWithin reads SSE lines until a data line arrives, or fails. |
| +func readEventWithin(t *testing.T, body io.Reader, d time.Duration) string { |
| + t.Helper() |
| + type result struct{ line string } |
| + ch := make(chan result, 1) |
| + go func() { |
| + sc := bufio.NewScanner(body) |
| + for sc.Scan() { |
| + if strings.HasPrefix(sc.Text(), "data: ") { |
| + ch <- result{sc.Text()} |
| + return |
| + } |
| + } |
| + ch <- result{""} |
| + }() |
| + select { |
| + case r := <-ch: |
| + if r.line == "" { |
| + t.Fatal("the stream ended without sending a data line") |
| + } |
| + return r.line |
| + case <-time.After(d): |
| + t.Fatalf("no event within %s", d) |
| + return "" |
| + } |
| +} |
| + |
| +// Spec §7: the SSE transport check is a streamed 200, not a 101. SSE is |
| +// text/event-stream over a normal response; 101 is the WebSocket |
| +// upgrade, and a check written against 101 would never pass. |
| +func TestStreamIsAStreamed200NotAnUpgrade(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + srv := liveServer(t, app) |
| + |
| + resp := openStream(t, srv, s, cookies) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("stream = %d, want a streamed 200", resp.StatusCode) |
| + } |
| + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { |
| + t.Fatalf("Content-Type = %q, want text/event-stream", ct) |
| + } |
| + // The header that stops an edge buffering a stream into a complete |
| + // response — the failure that turns live sync into "everything |
| + // arrives when you close the tab", and which no in-process test can |
| + // otherwise see. |
| + if got := resp.Header.Get("X-Accel-Buffering"); got != "no" { |
| + t.Errorf("X-Accel-Buffering = %q, want %q", got, "no") |
| + } |
| +} |
| + |
| +// The real assertion: an edit made after the stream was opened arrives |
| +// on it, without the response having to end. A handler that builds its |
| +// body and returns passes every status check above and is useless. |
| +func TestAnEditArrivesOnAnOpenStream(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, cells := newSheet(t, app, cookies, "Budget") |
| + srv := liveServer(t, app) |
| + |
| + resp := openStream(t, srv, s, cookies) |
| + |
| + // Wait until the handler has actually subscribed. Posting before |
| + // that races the subscription and loses the message — the test |
| + // would be flaky rather than wrong, which is worse. |
| + waitForSubscriber(t, app, s.TabID) |
| + |
| + postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"=6*7"}`) |
| + |
| + line := readEventWithin(t, resp.Body, 5*time.Second) |
| + if !strings.Contains(line, `"display":"42"`) { |
| + t.Fatalf("first streamed event = %q, want the computed value", line) |
| + } |
| +} |
| + |
| +// Two tabs on one sheet: the whole feature, at the layer below the |
| +// browser. |
| +func TestTwoStreamsBothReceiveAnEdit(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, cells := newSheet(t, app, cookies, "Budget") |
| + srv := liveServer(t, app) |
| + |
| + first := openStream(t, srv, s, cookies) |
| + second := openStream(t, srv, s, cookies) |
| + waitForSubscriberCount(t, app, s.TabID, 2) |
| + |
| + postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"hello"}`) |
| + |
| + for i, resp := range []*http.Response{first, second} { |
| + if line := readEventWithin(t, resp.Body, 5*time.Second); !strings.Contains(line, "hello") { |
| + t.Errorf("stream %d received %q", i, line) |
| + } |
| + } |
| +} |
| + |
| +func TestStreamOfAnUnknownTabIs404(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + |
| + path := fmt.Sprintf("/api/sheets/%d/tabs/999999/stream", s.ID) |
| + if rec := do(t, app, as(httptest.NewRequest(http.MethodGet, path, nil), cookies)); rec.Code != http.StatusNotFound { |
| + t.Fatalf("streaming an unknown tab = %d, want 404", rec.Code) |
| + } |
| +} |
| + |
| +func TestStreamRequiresASession(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + |
| + path := fmt.Sprintf("/api/sheets/%d/tabs/%d/stream", s.ID, s.TabID) |
| + if rec := do(t, app, httptest.NewRequest(http.MethodGet, path, nil)); rec.Code == http.StatusOK { |
| + t.Fatal("the stream served a request with no session") |
| + } |
| +} |
| + |
| +// Closing the connection must unsubscribe, or an instance leaks one |
| +// subscription per browser tab that has ever been opened. |
| +func TestClosingAStreamUnsubscribes(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + srv := liveServer(t, app) |
| + |
| + resp := openStream(t, srv, s, cookies) |
| + waitForSubscriber(t, app, s.TabID) |
| + resp.Body.Close() |
| + |
| + deadline := time.Now().Add(5 * time.Second) |
| + for time.Now().Before(deadline) { |
| + if app.hub.Subscribers(s.TabID) == 0 { |
| + return |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + } |
| + t.Fatalf("after the client closed, %d subscribers remain", app.hub.Subscribers(s.TabID)) |
| +} |
| + |
| +func waitForSubscriber(t *testing.T, app *App, tabID uint64) { |
| + t.Helper() |
| + waitForSubscriberCount(t, app, tabID, 1) |
| +} |
| + |
| +func waitForSubscriberCount(t *testing.T, app *App, tabID uint64, want int) { |
| + t.Helper() |
| + deadline := time.Now().Add(5 * time.Second) |
| + for time.Now().Before(deadline) { |
| + if app.hub.Subscribers(tabID) >= want { |
| + return |
| + } |
| + time.Sleep(5 * time.Millisecond) |
| + } |
| + t.Fatalf("only %d subscribers on tab %d after 5s, want %d", |
| + app.hub.Subscribers(tabID), tabID, want) |
| +} |