stream: end on shutdown, require the EventSource Accept, tighter window
Review fix round 1. SIGTERM now cancels the context the app is built on (signal.NotifyContext in main; rastrillo's own handler still fires, since signal.Notify fans out) and a live stream watches it, so one open stream no longer holds the ten-second Shutdown and turns a clean stop into an exit 1. Ruling 15: the attaching GET requires an Accept of text/event-stream and is otherwise the usual 404 with boot, so a cross-site navigation cannot take the session's peer slot; the probe forms are exempt. Minors: probe 204s carry Cache-Control no-store; the redundant Connection header is gone; ?last= is refused above 8 KiB before it is parsed; currentPerson's ok is checked; the session key binds the subject as well as the cookie; the stream timings have a floor so a zero cannot panic a ticker in a request; the hub refuses a resume past resume_until rather than waiting for the tick, so the window is thirty seconds and not thirty to forty-five; the SSE reader joins multi-line data, accepts fields without the space after the colon, and blocks with a bounded timeout instead of dropping an event; refusal tests use a two-second context-bounded GET.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
8 files changed,
+344
−60
cmd/meet/main.go+14 −4internal/meet/app.go+43 −10internal/meet/hub/relay.go+10 −1internal/meet/hub/relay_test.go+36 −0internal/meet/stream.go+50 −6internal/meettest/harness_test.go+18 −0internal/meettest/sse_test.go+54 −24internal/meettest/stream_test.go+119 −15
CI passed — run details
diff --git a/cmd/meet/main.go b/cmd/meet/main.go| index a2f1ef6..45fab21 100644 |
| --- a/cmd/meet/main.go |
| +++ b/cmd/meet/main.go |
| @@ -5,7 +5,9 @@ import ( |
| "fmt" |
| "log/slog" |
| "os" |
| + "os/signal" |
| "strings" |
| + "syscall" |
| "amadan.net/rastrillo/rastrillo" |
| "amadan.net/rastrillo/rastrillo/db" |
| @@ -60,10 +62,18 @@ func main() { |
| Logger: logger, |
| } |
| - // context.Background(): the hourly sweep this starts runs for the |
| - // life of the process, and nothing here ever needs to cancel it |
| - // early (review C2-3 — "acceptable" per the brief). |
| - mux, rt, err := meet.App(context.Background(), d, cfg) |
| + // Cancelled on SIGTERM, which is how a live SSE stream learns the |
| + // process is stopping: rastrillo.Serve handles the same signal with |
| + // a ten-second Shutdown, and Shutdown waits for handlers without |
| + // cancelling their request contexts, so a stream that watched |
| + // nothing would hold that whole deadline and turn a clean stop into |
| + // a failed one. signal.Notify fans out, so rastrillo's own handler |
| + // still fires. The hourly sweep and the hub tick end here too, at |
| + // the moment the process is going down anyway (review C2-3). |
| + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) |
| + defer stop() |
| + |
| + mux, rt, err := meet.App(ctx, d, cfg) |
| if err != nil { |
| logger.Error("build app", "err", err) |
| os.Exit(1) |
diff --git a/internal/meet/app.go b/internal/meet/app.go| index d8129f3..9c2fc82 100644 |
| --- a/internal/meet/app.go |
| +++ b/internal/meet/app.go |
| @@ -40,19 +40,43 @@ type app struct { |
| // (spec §4.4): thirty a second, burst sixty. |
| signalLimiter *rateLimiter |
| + // done closes when the context App was given is cancelled — a |
| + // SIGTERM, in the deployed shape. Live streams watch it and return, |
| + // because http.Server.Shutdown waits for handlers without |
| + // cancelling their contexts: one open stream would otherwise hold |
| + // the whole shutdown deadline. |
| + done <-chan struct{} |
| + |
| // The stream's two timings, held as atomics because a test moves |
| // them (SetStreamTimings) while stream handlers are reading them. |
| heartbeat atomic.Int64 |
| ttl atomic.Int64 |
| } |
| -func (a *app) heartbeatEvery() time.Duration { return time.Duration(a.heartbeat.Load()) } |
| -func (a *app) streamLife() time.Duration { return time.Duration(a.ttl.Load()) } |
| +func (a *app) heartbeatEvery() time.Duration { return atLeast(a.heartbeat.Load()) } |
| +func (a *app) streamLife() time.Duration { return atLeast(a.ttl.Load()) } |
| + |
| +// atLeast keeps a timing above the floor: time.NewTicker panics on a |
| +// zero or negative duration, and that would be inside a request. |
| +func atLeast(ns int64) time.Duration { |
| + if d := time.Duration(ns); d > minStreamTiming { |
| + return d |
| + } |
| + return minStreamTiming |
| +} |
| // TestHandle is the test-only control surface NewForTest hands back: |
| // the app's clock, and the sweep that would otherwise only run on a |
| // timer. Tests only; nothing in production holds one. |
| -type TestHandle struct{ a *app } |
| +type TestHandle struct { |
| + a *app |
| + stop context.CancelFunc |
| +} |
| + |
| +// ShutdownForTest cancels the context the app was built on, which is |
| +// what a SIGTERM does to the deployed process: live streams see it and |
| +// return. It does not stop the handler serving requests. |
| +func (h *TestHandle) ShutdownForTest() { h.stop() } |
| // SetStreamTimings shortens the SSE heartbeat and TTL so a test can |
| // watch a stream expire without waiting five minutes. Set it before any |
| @@ -195,9 +219,10 @@ func (a *app) tickForever(ctx context.Context) { |
| // returns the *app alongside the mux so tests can reach into it (see |
| // NewForTest); production code uses App, which discards the *app. |
| // |
| -// ctx bounds the sweep at boot. The hourly ticker is not started here: |
| -// App starts it, NewForTest does not, so a test suite does not leak a |
| -// goroutine per app it builds. |
| +// ctx bounds the sweep at boot, and its cancellation is what tells live |
| +// streams the process is stopping (a.done). The hourly ticker is not |
| +// started here: App starts it, NewForTest does not, so a test suite does |
| +// not leak a goroutine per app it builds. |
| func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, error) { |
| rc, err := resolve(cfg) |
| if err != nil { |
| @@ -237,6 +262,7 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| return nil, nil, err |
| } |
| a.hubSalt = hex.EncodeToString(salt) |
| + a.done = ctx.Done() |
| a.heartbeat.Store(int64(defaultHeartbeat)) |
| a.ttl.Store(int64(defaultStreamTTL)) |
| // The hub reads standing back through the app, and never under its |
| @@ -347,8 +373,13 @@ func (a *app) nextDue() time.Time { |
| // returns a *http.ServeMux because rastrillo.Options.Mux is typed that |
| // way; the chi router mounts inside it, and a *Runtime carrying the |
| // platform wake function main.go wires to Options.NextDue. It also |
| -// starts the hourly sweep on ctx, which runs until ctx is cancelled or |
| -// the process exits. |
| +// starts the hourly sweep and the hub tick on ctx, which run until ctx |
| +// is cancelled or the process exits. |
| +// |
| +// Cancel ctx on SIGTERM: live SSE streams end when it is cancelled, and |
| +// http.Server.Shutdown waits for handlers without cancelling their |
| +// contexts, so a stream that does not watch this holds the shutdown |
| +// deadline open. |
| func App(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *Runtime, error) { |
| mux, a, err := build(ctx, d, cfg) |
| if err != nil { |
| @@ -363,9 +394,11 @@ func App(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *Runtime, e |
| // controls. No sweep ticker is started: see build. Tests only; |
| // production uses App. |
| func NewForTest(d *db.DB, cfg Config) (*http.ServeMux, *TestHandle, error) { |
| - mux, a, err := build(context.Background(), d, cfg) |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + mux, a, err := build(ctx, d, cfg) |
| if err != nil { |
| + cancel() |
| return nil, nil, err |
| } |
| - return mux, &TestHandle{a: a}, nil |
| + return mux, &TestHandle{a: a, stop: cancel}, nil |
| } |
diff --git a/internal/meet/hub/relay.go b/internal/meet/hub/relay.go| index 63bc4d6..081115c 100644 |
| --- a/internal/meet/hub/relay.go |
| +++ b/internal/meet/hub/relay.go |
| @@ -137,11 +137,20 @@ func (h *Hub) resumeRefusalLocked(sqid, session string, peer PeerID) *ResumeErro |
| if t, ok := h.tombstones[peer]; ok && t.room == sqid && t.session == session { |
| return &ResumeError{Status: 410, Reason: t.reason} |
| } |
| - if _, found := h.findLocked(sqid, session, peer); !found { |
| + p, found := h.findLocked(sqid, session, peer) |
| + if !found { |
| // Unknown here, unknown in another session, unknown in another |
| // room: one answer, so nothing leaks (spec §4.2). |
| return &ResumeError{Status: 404, Boot: h.boot} |
| } |
| + // The window is thirty seconds, not thirty plus however long it is |
| + // until the next tick: a resume that arrives after resume_until is |
| + // refused now. Removing the participation and announcing the |
| + // departure is still the tick's, so a refusal never has a room's |
| + // events as a side effect. |
| + if p.sub == nil && !p.resumeUntil.IsZero() && h.cfg.Now().After(p.resumeUntil) { |
| + return &ResumeError{Status: 404, Boot: h.boot} |
| + } |
| return nil |
| } |
diff --git a/internal/meet/hub/relay_test.go b/internal/meet/hub/relay_test.go| index 050fbf9..80a79f1 100644 |
| --- a/internal/meet/hub/relay_test.go |
| +++ b/internal/meet/hub/relay_test.go |
| @@ -326,3 +326,39 @@ func TestResumeAfterWindowIsA404WithTheSameBoot(t *testing.T) { |
| t.Fatalf("lapsed resume: %v", err) |
| } |
| } |
| + |
| +// Spec §4.5's window is thirty seconds, not thirty plus however long it |
| +// is until the next tick: a resume that arrives after resume_until is |
| +// refused on arrival. The participation is still the tick's to remove, |
| +// so a refusal never has a room's events as a side effect. |
| +func TestAResumePastItsWindowIsRefusedBeforeTheTickRemovesIt(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + h := testHub(&now) |
| + a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| + h.Detach("r1", a.Peer, a.Sub) |
| + |
| + now = now.Add(ResumeWindow - time.Second) |
| + if re := h.CanResume("r1", "sa", a.Peer); re != nil { |
| + t.Fatalf("inside the window: %+v", re) |
| + } |
| + now = now.Add(2 * time.Second) // past resume_until, before any tick |
| + re := h.CanResume("r1", "sa", a.Peer) |
| + if re == nil || re.Status != 404 || re.Boot != h.Boot() { |
| + t.Fatalf("past the window: %+v", re) |
| + } |
| + if _, err := h.Resume("r1", "sa", a.Peer, nil); err == nil { |
| + t.Fatal("Resume attached a participation past its window") |
| + } |
| + // Refused, but still the tick's to remove and still this session's |
| + // one peer in the room until then. |
| + if _, ok := h.Find("r1", "sa", a.Peer); !ok { |
| + t.Fatal("the refusal removed the participation") |
| + } |
| + if _, err := h.Open("r1", "sa", 1, "Ann", KindMember); err != ErrSessionHasPeer { |
| + t.Fatalf("a fresh stream before the tick: %v", err) |
| + } |
| + h.Tick(context.Background()) |
| + if _, ok := h.Find("r1", "sa", a.Peer); ok { |
| + t.Fatal("the tick left the lapsed participation behind") |
| + } |
| +} |
diff --git a/internal/meet/stream.go b/internal/meet/stream.go| index 0566791..d00ca9d 100644 |
| --- a/internal/meet/stream.go |
| +++ b/internal/meet/stream.go |
| @@ -6,6 +6,7 @@ import ( |
| "encoding/json" |
| "fmt" |
| "net/http" |
| + "strings" |
| "time" |
| "github.com/go-chi/chi/v5" |
| @@ -23,6 +24,12 @@ const ( |
| defaultHeartbeat = 15 * time.Second |
| defaultStreamTTL = 5 * time.Minute |
| streamWriteWindow = 30 * time.Second |
| + // minStreamTiming is the floor under both, so a zero or negative |
| + // value from SetStreamTimings cannot panic a ticker in a request |
| + // goroutine. |
| + minStreamTiming = 10 * time.Millisecond |
| + // maxLastQuery bounds the resume's ?last= before it is parsed. |
| + maxLastQuery = 8 << 10 |
| ) |
| // sessionKey is the session's identity for the hub (spec §4.2): the |
| @@ -36,17 +43,30 @@ const ( |
| // That is not an identity: two sign-ins of one person within one second |
| // collide, and the second browser is then refused the room. |
| func (a *app) sessionKey(r *http.Request) (string, bool) { |
| - if s, ok := sessions.Current(r); !ok || s.Subject == "" { |
| + s, ok := sessions.Current(r) |
| + if !ok || s.Subject == "" { |
| return "", false |
| } |
| c, err := r.Cookie(a.auth.SessionCookie()) |
| if err != nil || c.Value == "" { |
| return "", false |
| } |
| - sum := sha256.Sum256([]byte(a.hubSalt + "|" + c.Value)) |
| + // The subject is bound in as well as the cookie: the key then |
| + // cannot survive the person it belongs to, whatever a cookie value |
| + // might come to be reused for. |
| + sum := sha256.Sum256([]byte(a.hubSalt + "|" + s.Subject + "|" + c.Value)) |
| return hex.EncodeToString(sum[:]), true |
| } |
| +// acceptsEventStream is ruling 15. The attaching stream is only ever |
| +// opened by EventSource, which always asks for text/event-stream; a |
| +// cross-site top-level navigation asks for HTML, and without this check |
| +// it would take the session's one peer slot in the room and hold it for |
| +// the resume window. The probe forms are exempt: they attach nothing. |
| +func acceptsEventStream(r *http.Request) bool { |
| + return strings.Contains(strings.ToLower(r.Header.Get("Accept")), "text/event-stream") |
| +} |
| + |
| // streamJSON is every refusal this endpoint makes. The room's client is |
| // a script, not a page: spec §4.5 fixes the browser's behaviour per |
| // status and body, so a refusal answers JSON rather than the error page |
| @@ -76,7 +96,12 @@ func (a *app) refuseResume(w http.ResponseWriter, re *hub.ResumeError) { |
| // session (§2.5). ?probe=1 answers the same statuses and stops there. |
| func (a *app) stream(w http.ResponseWriter, r *http.Request) { |
| sqid := chi.URLParam(r, "sqid") |
| - person, _ := currentPerson(r) |
| + person, ok := currentPerson(r) |
| + if !ok { |
| + a.logger.Error("meet: stream with no person in context", "path", r.URL.Path) |
| + streamJSON(w, http.StatusInternalServerError, nil) |
| + return |
| + } |
| session, ok := a.sessionKey(r) |
| if !ok { |
| a.logger.Error("meet: stream with no session subject", "path", r.URL.Path) |
| @@ -84,6 +109,12 @@ func (a *app) stream(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| probe := r.URL.Query().Get("probe") == "1" |
| + if !probe && !acceptsEventStream(r) { |
| + // Not a stream client: the same 404 an unknown meeting gets, and |
| + // no participation is created (ruling 15). |
| + streamJSON(w, http.StatusNotFound, map[string]any{"boot": a.hub.Boot()}) |
| + return |
| + } |
| var sub *hub.Sub |
| var peer hub.PeerID |
| @@ -116,11 +147,18 @@ func (a *app) stream(w http.ResponseWriter, r *http.Request) { |
| a.refuseResume(w, re) |
| return |
| } |
| - w.WriteHeader(http.StatusNoContent) |
| + streamJSON(w, http.StatusNoContent, nil) |
| return |
| } |
| var last map[hub.PeerID]uint64 |
| if raw := r.URL.Query().Get("last"); raw != "" { |
| + // One seq per peer, sixteen peers to a room: a document this |
| + // far past that is not a browser's, and is refused before it |
| + // is parsed. |
| + if len(raw) > maxLastQuery { |
| + streamJSON(w, http.StatusBadRequest, map[string]any{"error": "bad last"}) |
| + return |
| + } |
| if err := json.Unmarshal([]byte(raw), &last); err != nil { |
| streamJSON(w, http.StatusBadRequest, map[string]any{"error": "bad last"}) |
| return |
| @@ -182,7 +220,7 @@ func (a *app) stream(w http.ResponseWriter, r *http.Request) { |
| streamJSON(w, http.StatusConflict, map[string]any{"peer": string(existing)}) |
| return |
| } |
| - w.WriteHeader(http.StatusNoContent) |
| + streamJSON(w, http.StatusNoContent, nil) |
| return |
| } |
| kind := hub.KindGuest |
| @@ -222,7 +260,6 @@ func (a *app) serveStream(w http.ResponseWriter, r *http.Request, sub *hub.Sub, |
| 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") |
| // Nothing between this process and the browser should buffer an |
| // event stream; nginx and its imitators read this header. |
| w.Header().Set("X-Accel-Buffering", "no") |
| @@ -243,6 +280,13 @@ func (a *app) serveStream(w http.ResponseWriter, r *http.Request, sub *hub.Sub, |
| select { |
| case <-r.Context().Done(): |
| return |
| + case <-a.done: |
| + // The process is going down. Shutdown waits for handlers and |
| + // does not cancel their contexts, so a live stream would |
| + // hold the whole ten-second deadline and turn a clean stop |
| + // into a failed one. The browser resumes, as it does at the |
| + // TTL. |
| + return |
| case <-ttl.C: |
| return |
| case <-heartbeat.C: |
diff --git a/internal/meettest/harness_test.go b/internal/meettest/harness_test.go| index 020cbf6..e51ffd4 100644 |
| --- a/internal/meettest/harness_test.go |
| +++ b/internal/meettest/harness_test.go |
| @@ -77,6 +77,11 @@ func (x *harness) advance(d time.Duration) { |
| // for the whole pass). |
| func (x *harness) sweep() { x.ctl.SweepForTest(x.now) } |
| +// shutdown is the SIGTERM the deployed process gets: the context the |
| +// app was built on is cancelled, and live streams end themselves so a |
| +// graceful shutdown is not held open by one. |
| +func (x *harness) shutdown() { x.ctl.ShutdownForTest() } |
| + |
| // authLinks counts rastrillo's unclicked magic links. It is the app's |
| // own table, not Meet's, which is the point: nothing deletes a row there |
| // but presenting the token or sweeping. |
| @@ -234,6 +239,19 @@ func (c *client) get(target string) *httptest.ResponseRecorder { |
| return c.do(httptest.NewRequest(http.MethodGet, target, nil)) |
| } |
| +// getStream is get for the stream endpoint, for the answers that are |
| +// not a stream: it carries EventSource's Accept header (ruling 15) and |
| +// a two-second deadline, so a case that expects a refusal and gets a |
| +// stream fails in two seconds instead of holding the recorder open for |
| +// the whole TTL. A recorder cannot read a stream anyway. |
| +func (c *client) getStream(target string) *httptest.ResponseRecorder { |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + req := httptest.NewRequest(http.MethodGet, target, nil).WithContext(ctx) |
| + req.Header.Set("Accept", "text/event-stream") |
| + return c.do(req) |
| +} |
| + |
| func (c *client) post(target string, form url.Values) *httptest.ResponseRecorder { |
| req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) |
| req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
diff --git a/internal/meettest/sse_test.go b/internal/meettest/sse_test.go| index b9188ee..629d3e6 100644 |
| --- a/internal/meettest/sse_test.go |
| +++ b/internal/meettest/sse_test.go |
| @@ -8,6 +8,7 @@ import ( |
| "io" |
| "net/http" |
| "strings" |
| + "sync" |
| "testing" |
| "time" |
| ) |
| @@ -16,12 +17,14 @@ import ( |
| // against the harness's real server, its lines parsed into events on a |
| // channel while the test goroutine reads them one at a time. |
| type sseReader struct { |
| - t *testing.T |
| - cancel context.CancelFunc |
| - body io.ReadCloser |
| - events chan sseEvent |
| - hb chan struct{} |
| - done chan struct{} |
| + t *testing.T |
| + cancel context.CancelFunc |
| + body io.ReadCloser |
| + events chan sseEvent |
| + hb chan struct{} |
| + done chan struct{} |
| + closing chan struct{} |
| + once sync.Once |
| } |
| type sseEvent struct { |
| @@ -63,42 +66,66 @@ func (c *client) stream(target string) *sseReader { |
| } |
| s := &sseReader{ |
| t: c.t, cancel: cancel, body: resp.Body, |
| - events: make(chan sseEvent, 64), hb: make(chan struct{}, 64), done: make(chan struct{}), |
| + events: make(chan sseEvent, 64), hb: make(chan struct{}, 64), |
| + done: make(chan struct{}), closing: make(chan struct{}), |
| } |
| c.t.Cleanup(s.close) |
| go s.read() |
| return s |
| } |
| +// read parses the wire format the way an EventSource does: a field is |
| +// "name:" with one optional space after the colon, several data lines |
| +// join with a newline, a line starting with a colon is a comment, and a |
| +// blank line dispatches what has been collected. |
| func (s *sseReader) read() { |
| defer close(s.done) |
| sc := bufio.NewScanner(s.body) |
| sc.Buffer(make([]byte, 0, 64*1024), 1<<20) |
| var ev sseEvent |
| + var hasData bool |
| for sc.Scan() { |
| line := sc.Text() |
| switch { |
| case line == "": |
| - if ev.name != "" { |
| - select { |
| - case s.events <- ev: |
| - default: |
| - } |
| + if ev.name != "" || hasData { |
| + s.dispatch(ev) |
| } |
| - ev = sseEvent{} |
| + ev, hasData = sseEvent{}, false |
| case strings.HasPrefix(line, ":"): |
| select { |
| case s.hb <- struct{}{}: |
| default: |
| } |
| - case strings.HasPrefix(line, "event: "): |
| - ev.name = strings.TrimPrefix(line, "event: ") |
| - case strings.HasPrefix(line, "data: "): |
| - ev.data = []byte(strings.TrimPrefix(line, "data: ")) |
| + case strings.HasPrefix(line, "event:"): |
| + ev.name = field(line, "event:") |
| + case strings.HasPrefix(line, "data:"): |
| + if hasData { |
| + ev.data = append(ev.data, '\n') |
| + } |
| + ev.data = append(ev.data, field(line, "data:")...) |
| + hasData = true |
| } |
| } |
| } |
| +func field(line, name string) string { |
| + return strings.TrimPrefix(strings.TrimPrefix(line, name), " ") |
| +} |
| + |
| +// dispatch hands one event to the test goroutine. It blocks rather than |
| +// dropping: an overflowing buffer means a test is not reading what it |
| +// asked for, and that should fail out loud rather than quietly lose the |
| +// event that would have failed the assertion. |
| +func (s *sseReader) dispatch(ev sseEvent) { |
| + select { |
| + case s.events <- ev: |
| + case <-s.closing: |
| + case <-time.After(5 * time.Second): |
| + s.t.Errorf("sse: nothing read %q for five seconds; the event buffer overflowed", ev.name) |
| + } |
| +} |
| + |
| // next waits for one event. |
| func (s *sseReader) next(timeout time.Duration) (string, []byte, bool) { |
| select { |
| @@ -148,13 +175,16 @@ func (s *sseReader) waitClosed(timeout time.Duration) bool { |
| // close is what a browser navigating away does. It waits for the read |
| // goroutine to finish so the test never races the server's own exit. |
| func (s *sseReader) close() { |
| - s.cancel() |
| - s.body.Close() |
| - select { |
| - case <-s.done: |
| - case <-time.After(2 * time.Second): |
| - s.t.Error("the stream reader did not stop") |
| - } |
| + s.once.Do(func() { |
| + close(s.closing) |
| + s.cancel() |
| + s.body.Close() |
| + select { |
| + case <-s.done: |
| + case <-time.After(2 * time.Second): |
| + s.t.Error("the stream reader did not stop") |
| + } |
| + }) |
| } |
| // signal is POST /m/{sqid}/signal, the upstream half of §4.1: a JSON |
diff --git a/internal/meettest/stream_test.go b/internal/meettest/stream_test.go| index 2c02c24..0320afa 100644 |
| --- a/internal/meettest/stream_test.go |
| +++ b/internal/meettest/stream_test.go |
| @@ -4,6 +4,8 @@ import ( |
| "encoding/json" |
| "net/http" |
| "net/http/httptest" |
| + "net/url" |
| + "strings" |
| "testing" |
| "time" |
| ) |
| @@ -36,7 +38,7 @@ func TestStreamSaysHelloWithPeerAndBootAndRefusesTheSecondFreshStream(t *testing |
| if len(peer) != 32 || len(boot) != 32 { |
| t.Fatalf("hello peer=%q boot=%q", peer, boot) |
| } |
| - rec := c.get("/m/" + sqid + "/stream") |
| + rec := c.getStream("/m/" + sqid + "/stream") |
| if rec.Code != http.StatusConflict { |
| t.Fatalf("second fresh stream: %d, want 409", rec.Code) |
| } |
| @@ -73,10 +75,10 @@ func TestStreamNeedsStandingAndAResumeNeedsTheSession(t *testing.T) { |
| guest := x.browser(t) |
| guest.get("/j/" + tok) |
| guest.signIn("guest@example.com") |
| - if rec := guest.get("/m/" + elsewhere + "/stream"); rec.Code != http.StatusNotFound { |
| + if rec := guest.getStream("/m/" + elsewhere + "/stream"); rec.Code != http.StatusNotFound { |
| t.Fatalf("a meeting the guest has no standing for: %d, want 404", rec.Code) |
| } |
| - rec := guest.get("/m/" + sqid + "/stream?peer=" + peer) |
| + rec := guest.getStream("/m/" + sqid + "/stream?peer=" + peer) |
| if rec.Code != http.StatusNotFound { |
| t.Fatalf("resume through another session: %d, want 404", rec.Code) |
| } |
| @@ -84,7 +86,7 @@ func TestStreamNeedsStandingAndAResumeNeedsTheSession(t *testing.T) { |
| if _, gotBoot, _ := answer(t, rec); gotBoot != boot { |
| t.Fatalf("404 boot %q, want %q", gotBoot, boot) |
| } |
| - unknown := guest.get("/m/" + sqid + "/stream?peer=0123456789abcdef0123456789abcdef") |
| + unknown := guest.getStream("/m/" + sqid + "/stream?peer=0123456789abcdef0123456789abcdef") |
| if unknown.Code != http.StatusNotFound || unknown.Body.String() != rec.Body.String() { |
| t.Fatalf("unknown peer: %d %s", unknown.Code, unknown.Body.String()) |
| } |
| @@ -118,7 +120,7 @@ func TestResumeReattachesTheSameParticipation(t *testing.T) { |
| t.Fatalf("state %s, want waiting", data) |
| } |
| // The resumed participation is still the session's one peer. |
| - if rec := c.get("/m/" + sqid + "/stream"); rec.Code != http.StatusConflict { |
| + if rec := c.getStream("/m/" + sqid + "/stream"); rec.Code != http.StatusConflict { |
| t.Fatalf("fresh stream while resumed: %d, want 409", rec.Code) |
| } |
| } |
| @@ -167,7 +169,7 @@ func TestLapsedResumeIs404WithTheSameBootAndAFreshStreamGetsANewPeer(t *testing. |
| x.advance(31 * time.Second) |
| x.ctl.TickForTest() |
| - rec := c.get("/m/" + sqid + "/stream?peer=" + peer) |
| + rec := c.getStream("/m/" + sqid + "/stream?peer=" + peer) |
| _, gotBoot, _ := answer(t, rec) |
| if rec.Code != http.StatusNotFound || gotBoot != boot { |
| t.Fatalf("lapsed resume: %d boot %q, want 404 and %q", rec.Code, gotBoot, boot) |
| @@ -188,8 +190,14 @@ func TestResumeProbeAnswersWithoutAttaching(t *testing.T) { |
| c := x.browser(t) |
| c.signIn("paul@example.com") |
| - if rec := c.get("/m/" + sqid + "/stream?probe=1"); rec.Code != http.StatusNoContent { |
| - t.Fatalf("probe with nothing open: %d, want 204", rec.Code) |
| + // No Accept header: a probe attaches nothing, so ruling 15 exempts |
| + // it. Its answer is still never cached. |
| + rec0 := c.get("/m/" + sqid + "/stream?probe=1") |
| + if rec0.Code != http.StatusNoContent { |
| + t.Fatalf("probe with nothing open: %d, want 204", rec0.Code) |
| + } |
| + if cc := rec0.Header().Get("Cache-Control"); cc != "no-store" { |
| + t.Fatalf("probe Cache-Control %q, want no-store", cc) |
| } |
| s := c.stream("/m/" + sqid + "/stream") |
| peer, boot := s.hello() |
| @@ -200,8 +208,12 @@ func TestResumeProbeAnswersWithoutAttaching(t *testing.T) { |
| } |
| s.close() |
| - if rec := c.get("/m/" + sqid + "/stream?probe=1&peer=" + peer); rec.Code != http.StatusNoContent { |
| - t.Fatalf("probe of a resumable peer: %d, want 204", rec.Code) |
| + probed := c.get("/m/" + sqid + "/stream?probe=1&peer=" + peer) |
| + if probed.Code != http.StatusNoContent { |
| + t.Fatalf("probe of a resumable peer: %d, want 204", probed.Code) |
| + } |
| + if cc := probed.Header().Get("Cache-Control"); cc != "no-store" { |
| + t.Fatalf("resume probe Cache-Control %q, want no-store", cc) |
| } |
| rec = c.get("/m/" + sqid + "/stream?probe=1&peer=0123456789abcdef0123456789abcdef") |
| _, gotBoot, _ := answer(t, rec) |
| @@ -243,12 +255,12 @@ func TestEndingTheMeetingClosesTheStreamAndRefusesWhatComesAfter(t *testing.T) { |
| if !s.waitClosed(2 * time.Second) { |
| t.Fatal("the stream stayed open after the meeting ended") |
| } |
| - rec := c.get("/m/" + sqid + "/stream?peer=" + peer) |
| + rec := c.getStream("/m/" + sqid + "/stream?peer=" + peer) |
| _, _, reason := answer(t, rec) |
| if rec.Code != http.StatusGone || reason != "ended" { |
| t.Fatalf("resume after the ending: %d %q, want 410 ended", rec.Code, reason) |
| } |
| - if rec := c.get("/m/" + sqid + "/stream"); rec.Code != http.StatusNotFound { |
| + if rec := c.getStream("/m/" + sqid + "/stream"); rec.Code != http.StatusNotFound { |
| t.Fatalf("fresh stream on an ended meeting: %d, want 404", rec.Code) |
| } |
| @@ -258,17 +270,109 @@ func TestEndingTheMeetingClosesTheStreamAndRefusesWhatComesAfter(t *testing.T) { |
| if rec := c.post("/meetings/"+quiet+"/end", nil); rec.Code != http.StatusSeeOther { |
| t.Fatalf("end the quiet meeting: %d", rec.Code) |
| } |
| - if rec := c.get("/m/" + quiet + "/stream"); rec.Code != http.StatusNotFound { |
| + if rec := c.getStream("/m/" + quiet + "/stream"); rec.Code != http.StatusNotFound { |
| t.Fatalf("fresh stream on a meeting nobody opened: %d, want 404", rec.Code) |
| } |
| // The peer the ending tombstoned above, offered to a room it was |
| // never in: a tombstone answers only through its own room and |
| // session, so this is the plain 404 an unknown peer gets. |
| - rec = c.get("/m/" + quiet + "/stream?peer=" + peer) |
| + rec = c.getStream("/m/" + quiet + "/stream?peer=" + peer) |
| if _, gotBoot, _ := answer(t, rec); rec.Code != http.StatusNotFound || gotBoot != boot { |
| t.Fatalf("resume into a meeting nobody opened: %d boot %q", rec.Code, gotBoot) |
| } |
| - if rec := c.get("/m/zzzzzzzzzz/stream"); rec.Code != http.StatusNotFound { |
| + if rec := c.getStream("/m/zzzzzzzzzz/stream"); rec.Code != http.StatusNotFound { |
| t.Fatalf("stream for a meeting that does not exist: %d", rec.Code) |
| } |
| } |
| + |
| +// Ruling 15: the attaching stream is opened by EventSource, which always |
| +// asks for text/event-stream. A cross-site top-level navigation asks for |
| +// HTML, and it must not be able to take the session's one peer slot in |
| +// the room and hold it for the resume window. |
| +func TestAStreamWithoutTheEventStreamAcceptTakesNoPeer(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + |
| + rec := c.get("/m/" + sqid + "/stream") // a navigation, not EventSource |
| + if rec.Code != http.StatusNotFound { |
| + t.Fatalf("stream without the Accept header: %d, want 404", rec.Code) |
| + } |
| + // The 404 an unknown meeting gets, and nothing was allocated: the |
| + // real stream that follows is a fresh peer, not a 409. |
| + if _, boot, _ := answer(t, rec); boot == "" { |
| + t.Fatalf("404 body %q, want the boot id", rec.Body.String()) |
| + } |
| + if rec := c.get("/m/" + sqid + "/stream?peer=whatever"); rec.Code != http.StatusNotFound { |
| + t.Fatalf("resume without the Accept header: %d, want 404", rec.Code) |
| + } |
| + s := c.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + if peer, _ := s.hello(); peer == "" { |
| + t.Fatal("the stream that does ask for text/event-stream got no peer") |
| + } |
| +} |
| + |
| +// A resume's ?last= is refused above eight kibibytes, before it is |
| +// parsed: one seq per peer and sixteen peers to a room, so nothing this |
| +// size is a browser's. |
| +func TestAnOversizeLastIsRefusedBeforeItIsParsed(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + s := c.stream("/m/" + sqid + "/stream") |
| + peer, _ := s.hello() |
| + s.close() |
| + |
| + big := strings.Repeat("a", 9<<10) |
| + rec := c.getStream("/m/" + sqid + "/stream?peer=" + peer + "&last=" + big) |
| + if rec.Code != http.StatusBadRequest { |
| + t.Fatalf("oversize last: %d, want 400", rec.Code) |
| + } |
| + // Still resumable: a refused query is not a refused participation. |
| + s2 := c.stream("/m/" + sqid + "/stream?peer=" + peer + "&last=" + url.QueryEscape(`{"`+peer+`":3}`)) |
| + defer s2.close() |
| + if p, _ := s2.hello(); p != peer { |
| + t.Fatalf("resume after the refused last: %s, want %s", p, peer) |
| + } |
| +} |
| + |
| +// A SIGTERM cancels the context the app was built on. rastrillo answers |
| +// the same signal with a ten-second Shutdown, and Shutdown waits for |
| +// handlers without cancelling their contexts, so a live stream that |
| +// watched nothing would hold that whole deadline and the process would |
| +// exit 1 instead of cleanly. The browser resumes, as at the TTL. |
| +func TestShutdownEndsLiveStreams(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + s := c.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + s.hello() |
| + |
| + x.shutdown() |
| + if !s.waitClosed(2 * time.Second) { |
| + t.Fatal("a live stream outlived the app's context") |
| + } |
| +} |
| + |
| +// SetStreamTimings is a test knob, and a zero from it must not panic a |
| +// ticker inside a request goroutine: both timings have a floor. |
| +func TestZeroStreamTimingsAreClampedNotFatal(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + x.ctl.SetStreamTimings(0, 0) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + s := c.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + if peer, _ := s.hello(); peer == "" { |
| + t.Fatal("no peer") |
| + } |
| + if !s.waitClosed(2 * time.Second) { |
| + t.Fatal("a floored TTL did not end the stream") |
| + } |
| + // The app is still serving: nothing panicked. |
| + if rec := c.get("/m/" + sqid + "/join"); rec.Code != http.StatusOK { |
| + t.Fatalf("holding screen after the floored stream: %d", rec.Code) |
| + } |
| +} |