From 2a0c378ce719d955e9b4ac4412d86e6e00876d21 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 25 Sep 2026 18:37:40 +0200 Subject: [PATCH 1/2] Reject tail fetches for running WebSockets --- AI.md | 4 ++-- jaws_test.go | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++ serve.go | 28 ++++++++----------------- 3 files changed, 69 insertions(+), 21 deletions(-) diff --git a/AI.md b/AI.md index 4b09d46f..feeb8355 100644 --- a/AI.md +++ b/AI.md @@ -110,8 +110,8 @@ The normal page flow has two related HTTP requests: 1. Before writing the response, a page handler calls `Jaws.NewRequest(w, r)`, which replaces `Cache-Control` with `no-store`. `HeadHTML` normally emits the configured resources and request-key metadata. `TailHTML` is optional; - placing it before `` applies queued initial updates before the - WebSocket connects and can reduce flicker. + placing it before `` can apply queued initial updates before the + WebSocket starts and reduce flicker. 2. The bundled script connects to `/jaws/`. `Jaws.ServeHTTP` decodes the key, claims the pending Request through `UseRequest`, upgrades the connection, and begins event and DOM-update processing. diff --git a/jaws_test.go b/jaws_test.go index 64bf030b..37a77386 100644 --- a/jaws_test.go +++ b/jaws_test.go @@ -3367,6 +3367,64 @@ func TestServeHTTP_TailScript_EndpointIsPerRequest(t *testing.T) { is.Equal(w.Code, http.StatusNoContent) } +func TestServeHTTP_TailScript_RunningRequestDoesNotDrain(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + go jw.Serve() + defer jw.Close() + server := httptest.NewServer(jw) + defer server.Close() + + initial := httptest.NewRequest(http.MethodGet, server.URL+"/", nil) + initial.RemoteAddr = "127.0.0.1:1" + rq := jw.NewRequest(httptest.NewRecorder(), initial) + elem := rq.NewElement(&testUi{}) + ready := make(chan struct{}) + resume := make(chan struct{}) + rq.SetConnectFn(func(*Request) error { + close(ready) + <-resume + return nil + }) + conn := dialJawsRequest(t, server.URL, rq) + defer func() { + close(resume) + if err := conn.CloseNow(); err != nil { + t.Error(err) + } + }() + select { + case <-ready: + case <-time.After(testTimeout): + t.Fatal("WebSocket did not reach ConnectFn") + } + + elem.SetClass("cls") + res, err := server.Client().Get(server.URL + "/jaws/.tail/" + rq.JawsKeyString()) + if err != nil { + t.Fatal(err) + } + if err := res.Body.Close(); err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusNotFound { + t.Fatalf("tail status = %d, want 404", res.StatusCode) + } + resume <- struct{}{} + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + _, data, err := conn.Read(ctx) + if err != nil { + t.Fatal(err) + } + want := (&wire.WsMsg{Jid: elem.Jid(), What: what.SClass, Data: "cls"}).Format() + if !strings.Contains(string(data), want) { + t.Fatalf("WebSocket frame %q lacks %q", data, want) + } +} + // TestServeHTTP_TailScript_RejectsRecycledKey covers the finished-request behavior // of the /jaws/.tail endpoint: completion reserves the key with a nil tombstone in // jw.requests, so a tail fetch for the old key finds no live Request and returns diff --git a/serve.go b/serve.go index a21282ae..d197e5bc 100644 --- a/serve.go +++ b/serve.go @@ -588,10 +588,11 @@ func (*Request) writeTailResponse(w http.ResponseWriter, b []byte, sent bool) (e return } -// TailHTML writes optional HTML code at the end of the page's BODY section that -// will immediately apply HTML attribute and class updates made during initial -// rendering, which minimizes flicker without having to write the correct -// value in templates or during [Renderer.JawsRender]. +// TailHTML writes optional HTML code at the end of the page's BODY section. +// +// It can apply queued attribute and class updates before the WebSocket starts, +// reducing flicker without requiring their values in templates or +// [Renderer.JawsRender]. // // It also adds a