Skip to content

Commit a394caf

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 a394caf

3 files changed

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

0 commit comments

Comments
 (0)