diff --git a/context.go b/context.go index 4114ed4e9..e2cf1b396 100644 --- a/context.go +++ b/context.go @@ -613,10 +613,53 @@ func fsFile(c *Context, file string, filesystem fs.FS) error { if !ok { return errors.New("file does not implement io.ReadSeeker") } - http.ServeContent(c.Response(), c.Request(), fi.Name(), fi.ModTime(), ff) + + rw := c.Response() + // Check if the response can be optimized for ReadFrom (e.g. using sendfile) + if res, ok := rw.(*Response); ok && canReadFrom(res) { + rw = &responseWithReadFrom{res} + } + + http.ServeContent(rw, c.Request(), fi.Name(), fi.ModTime(), ff) return nil } +// responseWithReadFrom is a wrapper around Response that implements io.ReaderFrom. +// This allows http.ServeContent to use sendfile(2) or other zero-copy mechanisms +// if the underlying ResponseWriter supports it. +type responseWithReadFrom struct { + *Response +} + +func (w *responseWithReadFrom) ReadFrom(src io.Reader) (n int64, err error) { + // Bridge the Echo's life-cycle: ensure headers and Before hooks are triggered + // before the first byte is sent via zero-copy. + if !w.Committed { + if w.Status == 0 { + w.Status = http.StatusOK + } + w.WriteHeader(w.Status) + } + // Delegate to io.Copy which will automatically use the underlying ResponseWriter's + // ReadFrom implementation if available (triggering zero-copy). + n, err = io.Copy(w.ResponseWriter, src) + w.Size += n + return n, err +} + +// canReadFrom checks if the response is eligible for ReadFrom optimization. +func canReadFrom(res *Response) bool { + // After hooks are called on every Write. Zero-copy (ReadFrom) would bypass + // these calls, so we disable the optimization if any After hooks are registered + // to maintain Echo's API semantics. + if len(res.afterFuncs) > 0 { + return false + } + // Only enable if the underlying ResponseWriter actually supports ReadFrom. + _, ok := res.ResponseWriter.(io.ReaderFrom) + return ok +} + // Attachment sends a response as attachment, prompting client to save the file. // // Avoid using the leading `/` slash as most of the Go standard library fs.FS implementations require relative paths for diff --git a/context_file_bench_test.go b/context_file_bench_test.go new file mode 100644 index 000000000..c5ced18fe --- /dev/null +++ b/context_file_bench_test.go @@ -0,0 +1,73 @@ +package echo + +import ( + "io" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func BenchmarkContext_File_RealServer(b *testing.B) { + if os.Getenv("ECHO_HEAVY_BENCHMARK") != "true" { + b.Skip("skipping heavy benchmark; set ECHO_HEAVY_BENCHMARK=true to run") + } + e := New() + tmpDir := b.TempDir() + const benchFileName = "real_bench_data.bin" + // 100MB file to observe kernel-level copy savings via sendfile + fileSize := 100 * 1024 * 1024 + content := make([]byte, fileSize) + for i := range content { + content[i] = byte(i % 256) + } + _ = os.WriteFile(filepath.Join(tmpDir, benchFileName), content, 0644) + e.Filesystem = os.DirFS(tmpDir) + + // Route 1: Optimized path (Standard Echo handles this automatically) + e.GET("/optimized", func(c *Context) error { + return c.File(benchFileName) + }) + + // Route 2: Non-optimized path (Disables optimization by registering an After hook) + e.GET("/standard", func(c *Context) error { + c.Response().(*Response).After(func() {}) + return c.File(benchFileName) + }) + + // Use a real TCP server to exercise the kernel's sendfile(2) path through ReadFrom. + ts := httptest.NewServer(e) + defer ts.Close() + + client := ts.Client() + + b.Run("Zero-Copy-Optimized", func(b *testing.B) { + url := ts.URL + "/optimized" + b.ReportAllocs() + b.SetBytes(int64(fileSize)) + b.ResetTimer() + for i := 0; i < b.N; i++ { + resp, err := client.Get(url) + if err != nil { + b.Fatal(err) + } + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + }) + + b.Run("User-Space-Standard", func(b *testing.B) { + url := ts.URL + "/standard" + b.ReportAllocs() + b.SetBytes(int64(fileSize)) + b.ResetTimer() + for i := 0; i < b.N; i++ { + resp, err := client.Get(url) + if err != nil { + b.Fatal(err) + } + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + }) +} diff --git a/context_file_test.go b/context_file_test.go new file mode 100644 index 000000000..55f63d4b6 --- /dev/null +++ b/context_file_test.go @@ -0,0 +1,163 @@ +package echo + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "testing/iotest" + + "github.com/stretchr/testify/assert" +) + +// mockReadFromWriter implements io.ReaderFrom to trigger the optimization path +type mockReadFromWriter struct { + *httptest.ResponseRecorder + readFromCalled bool +} + +func (m *mockReadFromWriter) ReadFrom(r io.Reader) (int64, error) { + m.readFromCalled = true + // Simulate sendfile/optimized copy + return io.Copy(m.ResponseRecorder, r) +} + +// mockSimpleResponseWriter ONLY implements http.ResponseWriter (no ReadFrom) +// This is used as a control group to force the original non-optimized path. +type mockSimpleResponseWriter struct { + *httptest.ResponseRecorder +} + +const readFromTestFile = "readfrom_test_data.txt" + +func TestContext_File_ReadFrom_Optimization(t *testing.T) { + e := New() + tmpDir := t.TempDir() + content := "hello optimization parity check content" + err := os.WriteFile(filepath.Join(tmpDir, readFromTestFile), []byte(content), 0644) + assert.NoError(t, err) + e.Filesystem = os.DirFS(tmpDir) + + t.Run("Verify optimization triggers and parity", func(t *testing.T) { + // Use e.NewContext and c.File for end-to-end functional parity check. + req := httptest.NewRequest(http.MethodGet, "/", nil) + + // 1. Optimized Path Group + recOpt := httptest.NewRecorder() + mwOpt := &mockReadFromWriter{ResponseRecorder: recOpt} + cOpt := e.NewContext(req, mwOpt) + assert.NoError(t, cOpt.File(readFromTestFile)) + resOpt := cOpt.Response().(*Response) + + // 2. Original Path Group (Control) + recOri := httptest.NewRecorder() + mwOri := &mockSimpleResponseWriter{ResponseRecorder: recOri} + cOri := e.NewContext(req, mwOri) + assert.NoError(t, cOri.File(readFromTestFile)) + resOri := cOri.Response().(*Response) + + // ASSERTIONS: + assert.True(t, mwOpt.readFromCalled, "Optimized path MUST trigger ReadFrom") + assert.Equal(t, recOri.Code, recOpt.Code, "httptest.Recorder Code parity") + assert.Equal(t, recOri.Body.String(), recOpt.Body.String(), "Body content parity") + + // Echo Response State Parity + assert.Equal(t, resOri.Status, resOpt.Status, "Response.Status parity") + assert.Equal(t, resOri.Size, resOpt.Size, "Response.Size parity") + assert.Equal(t, resOri.Committed, resOpt.Committed, "Response.Committed parity") + }) + + t.Run("ReadFrom: Custom Status already set", func(t *testing.T) { + // Manually construct the wrapper to bypass http.ServeContent's side effects + // and surgically verify the Status/Before-hook bridging logic in ReadFrom. + rec := httptest.NewRecorder() + mw := &mockReadFromWriter{ResponseRecorder: rec} + res := &Response{ResponseWriter: mw} + w := &responseWithReadFrom{res} + + res.Status = http.StatusCreated + n, err := w.ReadFrom(strings.NewReader("test data")) + assert.NoError(t, err) + assert.Equal(t, int64(9), n) + assert.Equal(t, http.StatusCreated, rec.Code) + assert.True(t, res.Committed) + }) + + t.Run("ReadFrom: Already committed", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + mw := &mockReadFromWriter{ResponseRecorder: rec} + c := e.NewContext(req, mw) + + c.Response().WriteHeader(http.StatusAccepted) // Commit here + assert.NoError(t, c.File(readFromTestFile)) + + assert.True(t, mw.readFromCalled) + assert.Equal(t, http.StatusAccepted, rec.Code) + // Body should still be written because ServeContent continues after WriteHeader + assert.Contains(t, rec.Body.String(), "hello optimization") + }) + + t.Run("ReadFrom: IO Error during Copy", func(t *testing.T) { + // Directly test the wrapper to verify state updates (Size, Committed) + // when an error occurs during the transfer. + errReader := iotest.ErrReader(io.ErrUnexpectedEOF) + + res := &Response{ResponseWriter: &mockReadFromWriter{ResponseRecorder: httptest.NewRecorder()}} + w := &responseWithReadFrom{res} + + n, err := w.ReadFrom(errReader) + assert.ErrorIs(t, err, io.ErrUnexpectedEOF) + assert.Equal(t, int64(0), n) + assert.Equal(t, int64(0), res.Size) + assert.True(t, res.Committed) + }) + + t.Run("Hook Compatibility: Before hook triggers on ReadFrom", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + mw := &mockReadFromWriter{ResponseRecorder: rec} + c := e.NewContext(req, mw) + + beforeTriggered := false + c.Response().(*Response).Before(func() { + beforeTriggered = true + }) + + assert.NoError(t, c.File(readFromTestFile)) + assert.True(t, mw.readFromCalled) + assert.True(t, beforeTriggered, "Before hook must be called even on ReadFrom path") + }) + + t.Run("Hook Compatibility: After hook disables ReadFrom", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + mw := &mockReadFromWriter{ResponseRecorder: rec} + c := e.NewContext(req, mw) + + afterCalls := 0 + c.Response().(*Response).After(func() { + afterCalls++ + }) + + assert.NoError(t, c.File(readFromTestFile)) + assert.False(t, mw.readFromCalled, "ReadFrom must be DISABLED when After hooks exist") + assert.True(t, afterCalls > 0, "After hooks must be triggered via standard Write path") + }) + + t.Run("Error Parity: 416 Invalid Range", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Range", "bytes=100-200") + + rec := httptest.NewRecorder() + mw := &mockReadFromWriter{ResponseRecorder: rec} + c := e.NewContext(req, mw) + + assert.NoError(t, c.File(readFromTestFile)) + assert.Equal(t, http.StatusRequestedRangeNotSatisfiable, rec.Code) + assert.Equal(t, http.StatusRequestedRangeNotSatisfiable, c.Response().(*Response).Status) + }) +}