| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "time" |
| 6 | ) |
| 7 | |
| 8 | // The idle policy, written down rather than emergent (spec §7). Held |
| 9 | // sockets and hibernation pull against each other, and this is where |
| 10 | // that is resolved. |
| 11 | // |
| 12 | // pingEvery keeps the connection alive through proxies that drop a |
| 13 | // silent stream, and gives the client something to notice when the |
| 14 | // server has gone away. |
| 15 | // |
| 16 | // idleTimeout closes a stream that has carried no edit. maxStreamLife |
| 17 | // closes one regardless of activity, so a tab left open all weekend |
| 18 | // cannot hold an instance awake and billable indefinitely. |
| 19 | // |
| 20 | // Spec §7 words the idle policy as "no edits and no presence |
| 21 | // heartbeat". There is no heartbeat here, and the reason is worth |
| 22 | // stating rather than leaving as a gap: a heartbeat POST arrives as a |
| 23 | // separate request with no way to reach the specific goroutine holding |
| 24 | // the stream, so it would have had to be routed through the hub by |
| 25 | // connection id — real machinery to reproduce something the connection |
| 26 | // itself already tells us. Instead the client CLOSES its stream when |
| 27 | // the tab is hidden and opens a new one when it comes back, which is |
| 28 | // strictly stronger than a heartbeat that stops: a hidden tab holds no |
| 29 | // socket at all rather than an idle one. |
| 30 | // |
| 31 | // That only works with the client's cooperation, and it is worth being |
| 32 | // plain that the server cannot enforce it: EventSource reconnects on |
| 33 | // its own after any close, so a client that ignores the "bye" event |
| 34 | // below will simply come straight back. The server's timeouts are |
| 35 | // therefore a backstop against clients that vanished without saying so |
| 36 | // — a sleeping laptop, a dropped network — and not the whole policy. |
| 37 | const ( |
| 38 | pingEvery = 25 * time.Second |
| 39 | idleTimeout = 15 * time.Minute |
| 40 | maxStreamLife = 4 * time.Hour |
| 41 | ) |
| 42 | |
| 43 | // handleStream is the live connection: a streamed 200 carrying |
| 44 | // text/event-stream, one message per committed write on this tab. |
| 45 | // |
| 46 | // A streamed 200, NOT a 101 (spec §7). SSE is an ordinary response with |
| 47 | // a streaming body; 101 is the WebSocket upgrade, and a transport check |
| 48 | // written against 101 would never pass here. The conflation is worth |
| 49 | // naming because the source document this design came from made it. |
| 50 | func (a *App) handleStream(w http.ResponseWriter, r *http.Request) { |
| 51 | _, ok := a.actor(r) |
| 52 | if !ok { |
| 53 | a.notFound(w, r) |
| 54 | return |
| 55 | } |
| 56 | sheetID, tabID := sheetIDFrom(r), tabIDFrom(r) |
| 57 | if sheetID == 0 || tabID == 0 || !a.tabBelongsTo(sheetID, tabID) { |
| 58 | a.notFound(w, r) |
| 59 | return |
| 60 | } |
| 61 | // The stream carries every cell of every edit on this tab, so it |
| 62 | // needs the same grant a read of the grid does. Without this check |
| 63 | // the fan-out would be the one route that ignored sharing entirely |
| 64 | // — and the quietest possible leak, since nothing renders it. |
| 65 | if !a.streamAuthorized(r, sheetID) { |
| 66 | a.notFound(w, r) |
| 67 | return |
| 68 | } |
| 69 | |
| 70 | sub := a.hub.Subscribe(tabID) |
| 71 | defer sub.Close() |
| 72 | rc := http.NewResponseController(w) |
| 73 | |
| 74 | w.Header().Set("Content-Type", "text/event-stream") |
| 75 | w.Header().Set("Cache-Control", "no-store") |
| 76 | w.Header().Set("Connection", "keep-alive") |
| 77 | // The edge will happily buffer a streaming response into a complete |
| 78 | // one, which turns live sync into "everything arrives when you |
| 79 | // close the tab". This header is what stops it, and its absence is |
| 80 | // invisible in every test that does not run through a real proxy. |
| 81 | w.Header().Set("X-Accel-Buffering", "no") |
| 82 | w.WriteHeader(http.StatusOK) |
| 83 | |
| 84 | // Flushed immediately after subscribing, so the client's onopen |
| 85 | // fires immediately rather than on the first edit. A grid that says |
| 86 | // "connecting" until somebody types looks broken for as long as |
| 87 | // nobody does. |
| 88 | if err := rc.Flush(); err != nil { |
| 89 | return |
| 90 | } |
| 91 | |
| 92 | ping := time.NewTicker(pingEvery) |
| 93 | defer ping.Stop() |
| 94 | idle := time.NewTimer(idleTimeout) |
| 95 | defer idle.Stop() |
| 96 | life := time.NewTimer(maxStreamLife) |
| 97 | defer life.Stop() |
| 98 | |
| 99 | for { |
| 100 | select { |
| 101 | case <-r.Context().Done(): |
| 102 | return |
| 103 | |
| 104 | case <-life.C: |
| 105 | // The ceiling. Say so rather than dropping silently: the |
| 106 | // client reconnects on a close either way, but a named |
| 107 | // reason is the difference between a debuggable log and a |
| 108 | // mystery. |
| 109 | writeEvent(w, rc, "bye", `{"reason":"max-life"}`) |
| 110 | return |
| 111 | |
| 112 | case <-idle.C: |
| 113 | writeEvent(w, rc, "bye", `{"reason":"idle"}`) |
| 114 | return |
| 115 | |
| 116 | case <-ping.C: |
| 117 | // A comment line: valid SSE, ignored by EventSource, and |
| 118 | // enough to keep an intermediary from calling the |
| 119 | // connection dead. |
| 120 | if _, err := w.Write([]byte(": ping\n\n")); err != nil { |
| 121 | return |
| 122 | } |
| 123 | if err := rc.Flush(); err != nil { |
| 124 | return |
| 125 | } |
| 126 | |
| 127 | case m := <-sub.C(): |
| 128 | // The grant is re-checked before every delivery, not only |
| 129 | // at subscribe. |
| 130 | // |
| 131 | // Authorization on a long-lived connection is a question |
| 132 | // about NOW, and this stream lives for up to |
| 133 | // maxStreamLife with every edit resetting the idle timer |
| 134 | // that would otherwise end it. Checked once, a revoked |
| 135 | // guest keeps receiving cells written after their access |
| 136 | // was taken away — which is the quietest possible leak, |
| 137 | // because the owner has done the thing that should have |
| 138 | // stopped it and sees no sign that it did not. Found by an |
| 139 | // independent review (Codex, 2026-09-05); the gate at open |
| 140 | // was real and ran exactly once. |
| 141 | // |
| 142 | // One indexed read per delivered message, not per edit on |
| 143 | // the instance: a subscriber only reaches this line when a |
| 144 | // message is already addressed to its tab. |
| 145 | if !a.streamAuthorized(r, sheetID) { |
| 146 | writeEvent(w, rc, "bye", `{"reason":"access-changed"}`) |
| 147 | return |
| 148 | } |
| 149 | if sub.Lost() { |
| 150 | // This subscriber overran its buffer and has missed at |
| 151 | // least one message, so everything after it would paint |
| 152 | // a grid with holes in it. Tell the client to refetch |
| 153 | // and stop: a reload is cheap, and a silently wrong |
| 154 | // spreadsheet is not. |
| 155 | writeEvent(w, rc, "resync", `{"reason":"overrun"}`) |
| 156 | return |
| 157 | } |
| 158 | if !writeEvent(w, rc, "cells", string(m.Data)) { |
| 159 | return |
| 160 | } |
| 161 | // A delivered edit resets the idle clock: this tab is |
| 162 | // demonstrably part of a live session. |
| 163 | resetTimer(idle, idleTimeout) |
| 164 | } |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | // writeEvent sends one SSE event and flushes it. It returns false once |
| 169 | // the connection is gone, which is the only error worth distinguishing: |
| 170 | // there is nobody left to tell. |
| 171 | // |
| 172 | // The data is written as a single line. Every payload this app sends is |
| 173 | // compact JSON, which contains no newline — and a newline inside an SSE |
| 174 | // data field would split one message into two, producing a truncated |
| 175 | // JSON parse on the client rather than an error anybody could read. |
| 176 | func writeEvent(w http.ResponseWriter, rc *http.ResponseController, event, data string) bool { |
| 177 | if _, err := w.Write([]byte("event: " + event + "\ndata: " + data + "\n\n")); err != nil { |
| 178 | return false |
| 179 | } |
| 180 | return rc.Flush() == nil |
| 181 | } |
| 182 | |
| 183 | // resetTimer restarts a timer that may or may not have fired. Stop then |
| 184 | // Reset is the shape that is correct for both. |
| 185 | func resetTimer(t *time.Timer, d time.Duration) { |
| 186 | t.Stop() |
| 187 | t.Reset(d) |
| 188 | } |
| 189 | |