Skip to content

Commit f10257a

Browse files
committed
perf: optimize c.File with zero-copy support via io.ReaderFrom
Enables zero-copy (sendfile) serving. Disabled when 'After' hooks are present to maintain backward compatibility.
1 parent d17c907 commit f10257a

3 files changed

Lines changed: 280 additions & 1 deletion

File tree

context.go

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -613,10 +613,53 @@ func fsFile(c *Context, file string, filesystem fs.FS) error {
613613
if !ok {
614614
return errors.New("file does not implement io.ReadSeeker")
615615
}
616-
http.ServeContent(c.Response(), c.Request(), fi.Name(), fi.ModTime(), ff)
616+
617+
rw := c.Response()
618+
// Check if the response can be optimized for ReadFrom (e.g. using sendfile)
619+
if res, ok := rw.(*Response); ok && canReadFrom(res) {
620+
rw = &responseWithReadFrom{res}
621+
}
622+
623+
http.ServeContent(rw, c.Request(), fi.Name(), fi.ModTime(), ff)
617624
return nil
618625
}
619626

627+
// responseWithReadFrom is a wrapper around Response that implements io.ReaderFrom.
628+
// This allows http.ServeContent to use sendfile(2) or other zero-copy mechanisms
629+
// if the underlying ResponseWriter supports it.
630+
type responseWithReadFrom struct {
631+
*Response
632+
}
633+
634+
func (w *responseWithReadFrom) ReadFrom(src io.Reader) (n int64, err error) {
635+
// Bridge the Echo's life-cycle: ensure headers and Before hooks are triggered
636+
// before the first byte is sent via zero-copy.
637+
if !w.Committed {
638+
if w.Status == 0 {
639+
w.Status = http.StatusOK
640+
}
641+
w.WriteHeader(w.Status)
642+
}
643+
// Delegate to io.Copy which will automatically use the underlying ResponseWriter's
644+
// ReadFrom implementation if available (triggering zero-copy).
645+
n, err = io.Copy(w.ResponseWriter, src)
646+
w.Size += n
647+
return n, err
648+
}
649+
650+
// canReadFrom checks if the response is eligible for ReadFrom optimization.
651+
func canReadFrom(res *Response) bool {
652+
// After hooks are called on every Write. Zero-copy (ReadFrom) would bypass
653+
// these calls, so we disable the optimization if any After hooks are registered
654+
// to maintain Echo's API semantics.
655+
if len(res.afterFuncs) > 0 {
656+
return false
657+
}
658+
// Only enable if the underlying ResponseWriter actually supports ReadFrom.
659+
_, ok := res.ResponseWriter.(io.ReaderFrom)
660+
return ok
661+
}
662+
620663
// Attachment sends a response as attachment, prompting client to save the file.
621664
//
622665
// Avoid using the leading `/` slash as most of the Go standard library fs.FS implementations require relative paths for

context_file_bench_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package echo
2+
3+
import (
4+
"io"
5+
"net/http/httptest"
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
)
10+
11+
func BenchmarkContext_File_RealServer(b *testing.B) {
12+
if os.Getenv("ECHO_HEAVY_BENCHMARK") != "true" {
13+
b.Skip("skipping heavy benchmark; set ECHO_HEAVY_BENCHMARK=true to run")
14+
}
15+
e := New()
16+
tmpDir := b.TempDir()
17+
const benchFileName = "real_bench_data.bin"
18+
// 100MB file to observe kernel-level copy savings via sendfile
19+
fileSize := 100 * 1024 * 1024
20+
content := make([]byte, fileSize)
21+
for i := range content {
22+
content[i] = byte(i % 256)
23+
}
24+
_ = os.WriteFile(filepath.Join(tmpDir, benchFileName), content, 0644)
25+
e.Filesystem = os.DirFS(tmpDir)
26+
27+
// Route 1: Optimized path (Standard Echo handles this automatically)
28+
e.GET("/optimized", func(c *Context) error {
29+
return c.File(benchFileName)
30+
})
31+
32+
// Route 2: Non-optimized path (Disables optimization by registering an After hook)
33+
e.GET("/standard", func(c *Context) error {
34+
c.Response().(*Response).After(func() {})
35+
return c.File(benchFileName)
36+
})
37+
38+
// Use a real TCP server to exercise the kernel's sendfile(2) path through ReadFrom.
39+
ts := httptest.NewServer(e)
40+
defer ts.Close()
41+
42+
client := ts.Client()
43+
44+
b.Run("Zero-Copy-Optimized", func(b *testing.B) {
45+
url := ts.URL + "/optimized"
46+
b.ReportAllocs()
47+
b.SetBytes(int64(fileSize))
48+
b.ResetTimer()
49+
for i := 0; i < b.N; i++ {
50+
resp, err := client.Get(url)
51+
if err != nil {
52+
b.Fatal(err)
53+
}
54+
_, _ = io.Copy(io.Discard, resp.Body)
55+
resp.Body.Close()
56+
}
57+
})
58+
59+
b.Run("User-Space-Standard", func(b *testing.B) {
60+
url := ts.URL + "/standard"
61+
b.ReportAllocs()
62+
b.SetBytes(int64(fileSize))
63+
b.ResetTimer()
64+
for i := 0; i < b.N; i++ {
65+
resp, err := client.Get(url)
66+
if err != nil {
67+
b.Fatal(err)
68+
}
69+
_, _ = io.Copy(io.Discard, resp.Body)
70+
resp.Body.Close()
71+
}
72+
})
73+
}

context_file_test.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package echo
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"net/http/httptest"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
"testing/iotest"
12+
13+
"github.com/stretchr/testify/assert"
14+
)
15+
16+
// mockReadFromWriter implements io.ReaderFrom to trigger the optimization path
17+
type mockReadFromWriter struct {
18+
*httptest.ResponseRecorder
19+
readFromCalled bool
20+
}
21+
22+
func (m *mockReadFromWriter) ReadFrom(r io.Reader) (int64, error) {
23+
m.readFromCalled = true
24+
// Simulate sendfile/optimized copy
25+
return io.Copy(m.ResponseRecorder, r)
26+
}
27+
28+
// mockSimpleResponseWriter ONLY implements http.ResponseWriter (no ReadFrom)
29+
// This is used as a control group to force the original non-optimized path.
30+
type mockSimpleResponseWriter struct {
31+
*httptest.ResponseRecorder
32+
}
33+
34+
const readFromTestFile = "readfrom_test_data.txt"
35+
36+
func TestContext_File_ReadFrom_Optimization(t *testing.T) {
37+
e := New()
38+
tmpDir := t.TempDir()
39+
content := "hello optimization parity check content"
40+
err := os.WriteFile(filepath.Join(tmpDir, readFromTestFile), []byte(content), 0644)
41+
assert.NoError(t, err)
42+
e.Filesystem = os.DirFS(tmpDir)
43+
44+
t.Run("Verify optimization triggers and parity", func(t *testing.T) {
45+
// Use e.NewContext and c.File for end-to-end functional parity check.
46+
req := httptest.NewRequest(http.MethodGet, "/", nil)
47+
48+
// 1. Optimized Path Group
49+
recOpt := httptest.NewRecorder()
50+
mwOpt := &mockReadFromWriter{ResponseRecorder: recOpt}
51+
cOpt := e.NewContext(req, mwOpt)
52+
assert.NoError(t, cOpt.File(readFromTestFile))
53+
resOpt := cOpt.Response().(*Response)
54+
55+
// 2. Original Path Group (Control)
56+
recOri := httptest.NewRecorder()
57+
mwOri := &mockSimpleResponseWriter{ResponseRecorder: recOri}
58+
cOri := e.NewContext(req, mwOri)
59+
assert.NoError(t, cOri.File(readFromTestFile))
60+
resOri := cOri.Response().(*Response)
61+
62+
// ASSERTIONS:
63+
assert.True(t, mwOpt.readFromCalled, "Optimized path MUST trigger ReadFrom")
64+
assert.Equal(t, recOri.Code, recOpt.Code, "httptest.Recorder Code parity")
65+
assert.Equal(t, recOri.Body.String(), recOpt.Body.String(), "Body content parity")
66+
67+
// Echo Response State Parity
68+
assert.Equal(t, resOri.Status, resOpt.Status, "Response.Status parity")
69+
assert.Equal(t, resOri.Size, resOpt.Size, "Response.Size parity")
70+
assert.Equal(t, resOri.Committed, resOpt.Committed, "Response.Committed parity")
71+
})
72+
73+
t.Run("ReadFrom: Custom Status already set", func(t *testing.T) {
74+
// Manually construct the wrapper to bypass http.ServeContent's side effects
75+
// and surgically verify the Status/Before-hook bridging logic in ReadFrom.
76+
rec := httptest.NewRecorder()
77+
mw := &mockReadFromWriter{ResponseRecorder: rec}
78+
res := &Response{ResponseWriter: mw}
79+
w := &responseWithReadFrom{res}
80+
81+
res.Status = http.StatusCreated
82+
n, err := w.ReadFrom(strings.NewReader("test data"))
83+
assert.NoError(t, err)
84+
assert.Equal(t, int64(9), n)
85+
assert.Equal(t, http.StatusCreated, rec.Code)
86+
assert.True(t, res.Committed)
87+
})
88+
89+
t.Run("ReadFrom: Already committed", func(t *testing.T) {
90+
req := httptest.NewRequest(http.MethodGet, "/", nil)
91+
rec := httptest.NewRecorder()
92+
mw := &mockReadFromWriter{ResponseRecorder: rec}
93+
c := e.NewContext(req, mw)
94+
95+
c.Response().WriteHeader(http.StatusAccepted) // Commit here
96+
assert.NoError(t, c.File(readFromTestFile))
97+
98+
assert.True(t, mw.readFromCalled)
99+
assert.Equal(t, http.StatusAccepted, rec.Code)
100+
// Body should still be written because ServeContent continues after WriteHeader
101+
assert.Contains(t, rec.Body.String(), "hello optimization")
102+
})
103+
104+
t.Run("ReadFrom: IO Error during Copy", func(t *testing.T) {
105+
// Directly test the wrapper to verify state updates (Size, Committed)
106+
// when an error occurs during the transfer.
107+
errReader := iotest.ErrReader(io.ErrUnexpectedEOF)
108+
109+
res := &Response{ResponseWriter: &mockReadFromWriter{ResponseRecorder: httptest.NewRecorder()}}
110+
w := &responseWithReadFrom{res}
111+
112+
n, err := w.ReadFrom(errReader)
113+
assert.ErrorIs(t, err, io.ErrUnexpectedEOF)
114+
assert.Equal(t, int64(0), n)
115+
assert.Equal(t, int64(0), res.Size)
116+
assert.True(t, res.Committed)
117+
})
118+
119+
t.Run("Hook Compatibility: Before hook triggers on ReadFrom", func(t *testing.T) {
120+
req := httptest.NewRequest(http.MethodGet, "/", nil)
121+
rec := httptest.NewRecorder()
122+
mw := &mockReadFromWriter{ResponseRecorder: rec}
123+
c := e.NewContext(req, mw)
124+
125+
beforeTriggered := false
126+
c.Response().(*Response).Before(func() {
127+
beforeTriggered = true
128+
})
129+
130+
assert.NoError(t, c.File(readFromTestFile))
131+
assert.True(t, mw.readFromCalled)
132+
assert.True(t, beforeTriggered, "Before hook must be called even on ReadFrom path")
133+
})
134+
135+
t.Run("Hook Compatibility: After hook disables ReadFrom", func(t *testing.T) {
136+
req := httptest.NewRequest(http.MethodGet, "/", nil)
137+
rec := httptest.NewRecorder()
138+
mw := &mockReadFromWriter{ResponseRecorder: rec}
139+
c := e.NewContext(req, mw)
140+
141+
afterCalls := 0
142+
c.Response().(*Response).After(func() {
143+
afterCalls++
144+
})
145+
146+
assert.NoError(t, c.File(readFromTestFile))
147+
assert.False(t, mw.readFromCalled, "ReadFrom must be DISABLED when After hooks exist")
148+
assert.True(t, afterCalls > 0, "After hooks must be triggered via standard Write path")
149+
})
150+
151+
t.Run("Error Parity: 416 Invalid Range", func(t *testing.T) {
152+
req := httptest.NewRequest(http.MethodGet, "/", nil)
153+
req.Header.Set("Range", "bytes=100-200")
154+
155+
rec := httptest.NewRecorder()
156+
mw := &mockReadFromWriter{ResponseRecorder: rec}
157+
c := e.NewContext(req, mw)
158+
159+
assert.NoError(t, c.File(readFromTestFile))
160+
assert.Equal(t, http.StatusRequestedRangeNotSatisfiable, rec.Code)
161+
assert.Equal(t, http.StatusRequestedRangeNotSatisfiable, c.Response().(*Response).Status)
162+
})
163+
}

0 commit comments

Comments
 (0)