From 8f662295ce4cd1d1470ab6276ac7f9cfa382bfd1 Mon Sep 17 00:00:00 2001 From: shahariaz Date: Sat, 23 May 2026 01:01:30 +0600 Subject: [PATCH 1/3] feat: add InitSSE(), SSEStream() and fix deprecated CloseNotifier in Stream() --- context.go | 70 +++++++++++++++++++++++++++++++++++++++++++++++- context_test.go | 71 ++++++++++++++++++++++++++++++++++++++++++------- docs/doc.md | 30 +++++++++++++++++++++ 3 files changed, 161 insertions(+), 10 deletions(-) diff --git a/context.go b/context.go index 5174033eb3..a8ef30cb0f 100644 --- a/context.go +++ b/context.go @@ -1315,7 +1315,33 @@ func (c *Context) FileAttachment(filepath, filename string) { http.ServeFile(c.Writer, c.Request, filepath) } +// InitSSE prepares the response for a Server-Sent Events stream by setting the +// required HTTP headers: Content-Type is set to "text/event-stream", +// Cache-Control to "no-cache", and Connection to "keep-alive". +// The headers are flushed to the client immediately so that the browser opens +// the stream before the first event is sent. +// +// Call this once at the beginning of your SSE handler, before any SSEvent call: +// +// router.GET("/stream", func(c *gin.Context) { +// c.InitSSE() +// for i := range 5 { +// c.SSEvent("message", i) +// c.Writer.Flush() +// } +// }) +func (c *Context) InitSSE() { + c.Writer.Header().Set("Content-Type", sse.ContentType) + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.WriteHeaderNow() +} + // SSEvent writes a Server-Sent Event into the body stream. +// It sets Content-Type and Cache-Control headers on the first call if they have +// not already been set (e.g. by InitSSE). The writer is NOT flushed automatically; +// call c.Writer.Flush() after each event to push it to the client immediately. +// To include the optional id or retry fields use c.Render(-1, sse.Event{…}) directly. func (c *Context) SSEvent(name string, message any) { c.Render(-1, sse.Event{ Event: name, @@ -1323,11 +1349,53 @@ func (c *Context) SSEvent(name string, message any) { }) } +// SSEStream initializes an SSE response and calls step in a loop to send events +// until either the client disconnects or step returns false. +// +// It returns true when the client disconnected (c.Request.Context() was cancelled) +// and false when step returned false (normal end-of-stream). +// +// The writer is flushed automatically after every successful step call. +// step receives the current Context so it can call c.SSEvent, c.Render, or +// inspect c.Request.Context().Done() for its own blocking select: +// +// router.GET("/events", func(c *gin.Context) { +// ch := make(chan string) +// go produce(ch) +// c.SSEStream(func(c *gin.Context) bool { +// select { +// case msg, ok := <-ch: +// if !ok { +// return false // channel closed → end stream normally +// } +// c.SSEvent("message", msg) +// return true +// case <-c.Request.Context().Done(): +// return false // client gone → end stream +// } +// }) +// }) +func (c *Context) SSEStream(step func(c *Context) bool) bool { + c.InitSSE() + ctx := c.Request.Context() + for { + select { + case <-ctx.Done(): + return true + default: + if !step(c) { + return false + } + c.Writer.Flush() + } + } +} + // Stream sends a streaming response and returns a boolean // indicates "Is client disconnected in middle of stream" func (c *Context) Stream(step func(w io.Writer) bool) bool { w := c.Writer - clientGone := w.CloseNotify() + clientGone := c.Request.Context().Done() for { select { case <-clientGone: diff --git a/context_test.go b/context_test.go index ef60379d77..41f2ab129e 100644 --- a/context_test.go +++ b/context_test.go @@ -1441,6 +1441,58 @@ func TestContextRenderSSE(t *testing.T) { assert.Equal(t, strings.ReplaceAll(w.Body.String(), " ", ""), strings.ReplaceAll("event:float\ndata:1.5\n\nid:123\ndata:text\n\nevent:chat\ndata:{\"bar\":\"foo\",\"foo\":\"bar\"}\n\n", " ", "")) } +func TestContextInitSSE(t *testing.T) { + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + c.Request, _ = http.NewRequest(http.MethodGet, "/", nil) + + c.InitSSE() + + assert.Equal(t, sse.ContentType, w.Header().Get("Content-Type")) + assert.Equal(t, "no-cache", w.Header().Get("Cache-Control")) + assert.Equal(t, "keep-alive", w.Header().Get("Connection")) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestContextSSEStreamNormalEnd(t *testing.T) { + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + c.Request, _ = http.NewRequest(http.MethodGet, "/", nil) + + count := 0 + disconnected := c.SSEStream(func(c *Context) bool { + count++ + c.SSEvent("ping", count) + return count < 3 + }) + + assert.False(t, disconnected) + assert.Equal(t, 3, count) + assert.Equal(t, sse.ContentType, w.Header().Get("Content-Type")) + assert.Equal(t, "no-cache", w.Header().Get("Cache-Control")) + assert.Equal(t, "keep-alive", w.Header().Get("Connection")) + assert.Contains(t, w.Body.String(), "event:ping") +} + +func TestContextSSEStreamClientDisconnect(t *testing.T) { + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + c.Request, _ = http.NewRequestWithContext(ctx, http.MethodGet, "/", nil) + + // step cancels the context and returns true (keep streaming). + // On the next loop iteration SSEStream's outer select sees ctx.Done() + // is closed and returns true, indicating client disconnected. + result := c.SSEStream(func(c *Context) bool { + cancel() // simulate client disconnect + return true + }) + + assert.True(t, result) +} + func TestContextRenderFile(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) @@ -3030,10 +3082,6 @@ func (r *TestResponseRecorder) CloseNotify() <-chan bool { return r.closeChannel } -func (r *TestResponseRecorder) closeClient() { - r.closeChannel <- true -} - func CreateTestResponseRecorder() *TestResponseRecorder { return &TestResponseRecorder{ httptest.NewRecorder(), @@ -3044,6 +3092,7 @@ func CreateTestResponseRecorder() *TestResponseRecorder { func TestContextStream(t *testing.T) { w := CreateTestResponseRecorder() c, _ := CreateTestContext(w) + c.Request, _ = http.NewRequest(http.MethodGet, "/", nil) stopStream := true c.Stream(func(w io.Writer) bool { @@ -3064,17 +3113,21 @@ func TestContextStreamWithClientGone(t *testing.T) { w := CreateTestResponseRecorder() c, _ := CreateTestContext(w) - c.Stream(func(writer io.Writer) bool { - defer func() { - w.closeClient() - }() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + c.Request, _ = http.NewRequestWithContext(ctx, http.MethodGet, "/", nil) + // step cancels the context and returns true (keep streaming). + // On the next loop iteration Stream's outer select sees clientGone + // is closed and returns true, indicating client disconnected. + result := c.Stream(func(writer io.Writer) bool { _, err := writer.Write([]byte("test")) require.NoError(t, err) - + cancel() // simulate client disconnect return true }) + assert.True(t, result) assert.Equal(t, "test", w.Body.String()) } diff --git a/docs/doc.md b/docs/doc.md index d1c33b8762..d974cd24b6 100644 --- a/docs/doc.md +++ b/docs/doc.md @@ -1879,6 +1879,36 @@ func main() { } ``` +### Server-Sent Events (SSE) + +Use `c.InitSSE()` to set the required headers, then `c.SSEvent()` + `c.Writer.Flush()` to push events: + +```go +router.GET("/stream", func(c *gin.Context) { + c.InitSSE() + for i := range 5 { + c.SSEvent("message", gin.H{"count": i}) + c.Writer.Flush() + } +}) +``` + +For a long-running stream that stops when the client disconnects, use `c.SSEStream()`: + +```go +router.GET("/stream", func(c *gin.Context) { + i := 0 + c.SSEStream(func(c *gin.Context) bool { + i++ + c.SSEvent("message", gin.H{"count": i}) + return i < 10 // return false to end the stream normally + }) +}) +``` + +`SSEStream` returns `true` if the client disconnected mid-stream, `false` if the step +function ended the stream by returning `false`. + ### HTML rendering Using LoadHTMLGlob() or LoadHTMLFiles() or LoadHTMLFS() From e7ec17985d2608cd3de978e832e9a42e7dddce5d Mon Sep 17 00:00:00 2001 From: shahariaz Date: Tue, 2 Jun 2026 21:03:49 +0600 Subject: [PATCH 2/3] feat: enhance SSE handling with flush and nil request safety checks --- context.go | 13 ++++++++++--- context_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/context.go b/context.go index a8ef30cb0f..48c75e0fa0 100644 --- a/context.go +++ b/context.go @@ -1335,6 +1335,7 @@ func (c *Context) InitSSE() { c.Writer.Header().Set("Cache-Control", "no-cache") c.Writer.Header().Set("Connection", "keep-alive") c.Writer.WriteHeaderNow() + c.Writer.Flush() } // SSEvent writes a Server-Sent Event into the body stream. @@ -1377,10 +1378,13 @@ func (c *Context) SSEvent(name string, message any) { // }) func (c *Context) SSEStream(step func(c *Context) bool) bool { c.InitSSE() - ctx := c.Request.Context() + var done <-chan struct{} + if c.Request != nil { + done = c.Request.Context().Done() + } for { select { - case <-ctx.Done(): + case <-done: return true default: if !step(c) { @@ -1395,7 +1399,10 @@ func (c *Context) SSEStream(step func(c *Context) bool) bool { // indicates "Is client disconnected in middle of stream" func (c *Context) Stream(step func(w io.Writer) bool) bool { w := c.Writer - clientGone := c.Request.Context().Done() + var clientGone <-chan struct{} + if c.Request != nil { + clientGone = c.Request.Context().Done() + } for { select { case <-clientGone: diff --git a/context_test.go b/context_test.go index 41f2ab129e..5a8cff6480 100644 --- a/context_test.go +++ b/context_test.go @@ -1452,6 +1452,7 @@ func TestContextInitSSE(t *testing.T) { assert.Equal(t, "no-cache", w.Header().Get("Cache-Control")) assert.Equal(t, "keep-alive", w.Header().Get("Connection")) assert.Equal(t, http.StatusOK, w.Code) + assert.True(t, w.Flushed) } func TestContextSSEStreamNormalEnd(t *testing.T) { @@ -1474,6 +1475,22 @@ func TestContextSSEStreamNormalEnd(t *testing.T) { assert.Contains(t, w.Body.String(), "event:ping") } +func TestContextSSEStreamNilRequest(t *testing.T) { + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + // c.Request is intentionally left nil to verify no panic + + count := 0 + assert.NotPanics(t, func() { + disconnected := c.SSEStream(func(c *Context) bool { + count++ + return count < 2 + }) + assert.False(t, disconnected) + }) + assert.Equal(t, 2, count) +} + func TestContextSSEStreamClientDisconnect(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w) @@ -3131,6 +3148,24 @@ func TestContextStreamWithClientGone(t *testing.T) { assert.Equal(t, "test", w.Body.String()) } +func TestContextStreamNilRequest(t *testing.T) { + w := CreateTestResponseRecorder() + c, _ := CreateTestContext(w) + // c.Request is intentionally left nil to verify no panic + + count := 0 + assert.NotPanics(t, func() { + disconnected := c.Stream(func(writer io.Writer) bool { + count++ + _, err := writer.Write([]byte("x")) + require.NoError(t, err) + return count < 2 + }) + assert.False(t, disconnected) + }) + assert.Equal(t, 2, count) +} + func TestContextResetInHandler(t *testing.T) { w := CreateTestResponseRecorder() c, _ := CreateTestContext(w) From 2c4220634589039aff032ceaaeb07c7c2930da77 Mon Sep 17 00:00:00 2001 From: shahariaz Date: Tue, 2 Jun 2026 21:15:55 +0600 Subject: [PATCH 3/3] test: increase coverage to 98.9% by covering previously untested code paths - ginS: add TestLoadHTMLGlob, TestLoadHTMLFiles, TestLoadHTMLFS covering the three HTML template loader wrappers (73.1% -> 100%) - ginS: add TestRunError, TestRunTLSError, TestRunUnixError, TestRunFdError covering all four server-start wrappers via fast-failing error paths - binding: add TestPlainBindingBody covering plainBinding.BindBody (string, []byte, unsupported type, and nil receiver branches) - render: add TestRedirectWriteContentType covering the Redirect no-op method Overall project coverage: 98.5% -> 98.9% Patch coverage: 100% --- binding/binding_test.go | 20 ++++++++++++++++++++ ginS/gins_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ render/render_test.go | 11 +++++++++++ 3 files changed, 72 insertions(+) diff --git a/binding/binding_test.go b/binding/binding_test.go index f90488cdd3..78302fdf75 100644 --- a/binding/binding_test.go +++ b/binding/binding_test.go @@ -1432,3 +1432,23 @@ func requestWithBody(method, path, body string) (req *http.Request) { req, _ = http.NewRequest(method, path, bytes.NewBufferString(body)) return } + +func TestPlainBindingBody(t *testing.T) { + p := Plain + + var s string + require.NoError(t, p.BindBody([]byte("hello body"), &s)) + assert.Equal(t, "hello body", s) + + var bs []byte + require.NoError(t, p.BindBody([]byte("bytes body"), &bs)) + assert.Equal(t, []byte("bytes body"), bs) + + var i int + require.Error(t, p.BindBody([]byte("fail"), &i)) + + require.NoError(t, p.BindBody([]byte(""), nil)) + + var ptr *string + require.NoError(t, p.BindBody([]byte(""), ptr)) +} diff --git a/ginS/gins_test.go b/ginS/gins_test.go index ffde85d22e..995c6da1f8 100644 --- a/ginS/gins_test.go +++ b/ginS/gins_test.go @@ -244,3 +244,44 @@ func TestStaticFS(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) } + +func TestLoadHTMLGlob(t *testing.T) { + assert.NotPanics(t, func() { + LoadHTMLGlob("../testdata/template/*.tmpl") + }) + assert.NotNil(t, engine()) +} + +func TestLoadHTMLFiles(t *testing.T) { + assert.NotPanics(t, func() { + LoadHTMLFiles("../testdata/template/hello.tmpl", "../testdata/template/raw.tmpl") + }) + assert.NotNil(t, engine()) +} + +func TestLoadHTMLFS(t *testing.T) { + assert.NotPanics(t, func() { + LoadHTMLFS(http.Dir("../testdata/template"), "*.tmpl") + }) + assert.NotNil(t, engine()) +} + +func TestRunError(t *testing.T) { + err := Run("not-valid-address") + assert.Error(t, err) +} + +func TestRunTLSError(t *testing.T) { + err := RunTLS(":0", "/nonexistent.cert", "/nonexistent.key") + assert.Error(t, err) +} + +func TestRunUnixError(t *testing.T) { + err := RunUnix("/nonexistent/deep/path/gin-test.sock") + assert.Error(t, err) +} + +func TestRunFdError(t *testing.T) { + err := RunFd(99999) + assert.Error(t, err) +} diff --git a/render/render_test.go b/render/render_test.go index f63878b966..0236abca85 100644 --- a/render/render_test.go +++ b/render/render_test.go @@ -805,3 +805,14 @@ func TestRenderWriteError(t *testing.T) { require.Error(t, err) assert.Equal(t, "write error", err.Error()) } + +func TestRedirectWriteContentType(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/", nil) + r := Redirect{Code: http.StatusFound, Request: req, Location: "/new"} + // WriteContentType is a no-op for Redirect; verify it doesn't panic + assert.NotPanics(t, func() { + r.WriteContentType(w) + }) + assert.Empty(t, w.Header().Get("Content-Type")) +}