Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2396,12 +2396,17 @@
app, ln, start := createHelperServer(b)

startedCh := make(chan struct{})
cancelledCh := make(chan struct{})
errCh := make(chan error)
respCh := make(chan *Response)

app.Post("/", func(c fiber.Ctx) error {
startedCh <- struct{}{}
time.Sleep(time.Millisecond) // let cancel be called
// Respond only after the benchmark loop has cancelled the context:

Check failure on line 2405 in client/client_test.go

View workflow job for this annotation

GitHub Actions / lint / lint

`cancelled` is a misspelling of `canceled` (misspell)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
// a fixed sleep loses the race on fast machines, letting the
// response beat the cancellation and fail the nil assertion below.
<-cancelledCh
time.Sleep(time.Millisecond) // margin for the watcher to observe Done
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return c.Status(fiber.StatusOK).SendString("post")
})

Expand Down Expand Up @@ -2432,6 +2437,7 @@

<-startedCh // request is made, we can cancel the context now
cancel()
cancelledCh <- struct{}{} // context Done is closed; let the handler respond

require.Nil(b, <-respCh)
require.ErrorIs(b, <-errCh, ErrTimeoutOrCancel)
Expand Down
12 changes: 11 additions & 1 deletion client/cookiejar.go
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,17 @@ func isIPLiteral(host string) bool {
host = host[1 : len(host)-1]
}

return net.ParseIP(host) != nil
// Equivalent to net.ParseIP(host) != nil: utils.ParseIPv4/ParseIPv6
// accept the same strings once zoned addresses (which net.ParseIP
// rejects) are screened out, without allocating on either outcome.
if strings.IndexByte(host, '%') >= 0 {
return false
}
if _, ok := utils.ParseIPv4(host); ok {
return true
}
_, ok := utils.ParseIPv6(host)
return ok
}

func isPublicSuffixDomain(domain string) bool {
Expand Down
23 changes: 23 additions & 0 deletions client/cookiejar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,29 @@ func Test_CookieJar_HostPort(t *testing.T) {
require.Equal(t, "fasthttp.com", string(cookies[0].Domain()))
}

func Test_CookieJar_isIPLiteral(t *testing.T) {
t.Parallel()

// Pinned to net.ParseIP semantics: zoned addresses are rejected.
tests := []struct {
host string
want bool
}{
{"192.0.2.1", true},
{"::1", true},
{"[::1]", true},
{"::ffff:192.0.2.1", true},
{"fe80::1%eth0", false},
{"[fe80::1%eth0]", false},
{"example.com", false},
{"192.0.2.256", false},
{"", false},
}
for _, tt := range tests {
require.Equal(t, tt.want, isIPLiteral(tt.host), "isIPLiteral(%q)", tt.host)
}
}

func Test_CookieJar_Domain(t *testing.T) {
t.Parallel()

Expand Down
1 change: 1 addition & 0 deletions ctx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2957,6 +2957,7 @@ func Test_Ctx_Fresh_ObsoleteDateFormats(t *testing.T) {
"Wed, 21 Oct 2015 07:28:00 GMT", // IMF-fixdate
"Wednesday, 21-Oct-15 07:28:00 GMT", // obsolete RFC 850 format
"Wed Oct 21 07:28:00 2015", // ANSI C asctime() format
" Wed, 21 Oct 2015 07:28:00 GMT ", // surrounding OWS tolerated (RFC 9110 §5.5)
} {
c := app.AcquireCtx(&fasthttp.RequestCtx{})
c.Request().Header.Set(HeaderIfModifiedSince, date)
Expand Down
122 changes: 95 additions & 27 deletions middleware/adaptor/adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,25 @@ func (*noopConn) SetDeadline(time.Time) error { return nil }
func (*noopConn) SetReadDeadline(time.Time) error { return nil }
func (*noopConn) SetWriteDeadline(time.Time) error { return nil }

// pooledCtx bundles the RequestCtx with its noopConn so one pool entry
// serves both and no per-request conn allocation is needed. The conn
// comes first to keep the struct's trailing pointer span minimal
// (govet fieldalignment).
type pooledCtx struct {
conn noopConn
fctx fasthttp.RequestCtx
}

var ctxPool = sync.Pool{
New: func() any {
return new(fasthttp.RequestCtx)
return new(pooledCtx)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
}

// disabledLogger is shared: it is stateless, and reusing one value keeps
// the logger interface conversion off the per-request path.
var disabledLogger = &disableLogger{}

// LocalContextKey is the key used to store the user's context.Context in the fasthttp request context.
// Adapted http.Handler functions can retrieve this context using r.Context().Value(adaptor.LocalContextKey)
var localContextKey = &struct{}{}
Expand Down Expand Up @@ -199,9 +212,16 @@ func HTTPMiddleware(mw func(http.Handler) http.Handler) fiber.Handler {

// Remove all cookies before setting, see https://github.com/valyala/fasthttp/pull/1864
c.Request().Header.DelAllCookies()
for key, val := range r.Header {
for _, v := range val {
c.Request().Header.Set(key, v)
for key, vals := range r.Header {
if len(vals) == 0 {
continue
}
// Set replaces whatever the key held on the fiber request,
// then Add appends the remaining values so multi-value
// headers survive instead of collapsing to the last value.
c.Request().Header.Set(key, vals[0])
for _, v := range vals[1:] {
c.Request().Header.Add(key, v)
}
}
CopyContextToFiberContext(r.Context(), c.RequestCtx())
Expand Down Expand Up @@ -237,6 +257,24 @@ func isUnixNetwork(network string) bool {
return network == "unix" || network == "unixgram" || network == "unixpacket"
}

// parseDecimalPort parses a purely numeric port string. Anything else —
// signs, service names like "http", overflow — reports ok=false so the
// caller falls back to net.ResolveTCPAddr, which handles the long tail.
func parseDecimalPort(s string) (int, bool) {
if s == "" || len(s) > 5 {
return 0, false
}
port := 0
for i := 0; i < len(s); i++ {
d := s[i] - '0'
if d > 9 {
return 0, false
}
port = port*10 + int(d)
}
return port, port <= 65535
}

func resolveRemoteAddr(remoteAddr string, localAddr any) (net.Addr, error) {
if addr, ok := localAddr.(net.Addr); ok && isUnixNetwork(addr.Network()) {
return addr, nil
Expand All @@ -247,6 +285,23 @@ func resolveRemoteAddr(remoteAddr string, localAddr any) (net.Addr, error) {
return nil, ErrRemoteAddrEmpty
}

// Fast path: "ip:port" literals — the only form net/http servers set on
// Request.RemoteAddr — build the TCPAddr directly instead of going
// through net.ResolveTCPAddr's resolver machinery. Hostnames, service
// port names, and anything else fall through to the resolver below.
if host, portStr, err := net.SplitHostPort(remoteAddr); err == nil {
if port, ok := parseDecimalPort(portStr); ok {
if ip, ok := utils.ParseIPv4(host); ok {
a := ip.As4()
return &net.TCPAddr{IP: a[:], Port: port}, nil
}
if ip, ok := utils.ParseIPv6(host); ok {
a := ip.As16()
return &net.TCPAddr{IP: a[:], Port: port, Zone: ip.Zone()}, nil
}
}
}

resolved, err := net.ResolveTCPAddr("tcp", remoteAddr)
if err == nil {
return resolved, nil
Expand All @@ -269,8 +324,29 @@ func resolveRemoteAddr(remoteAddr string, localAddr any) (net.Addr, error) {

func handlerFunc(app *fiber.App, h ...fiber.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
req := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(req)
// New fasthttp Ctx from pool
pctx := ctxPool.Get().(*pooledCtx) //nolint:forcetypeassert,errcheck // not needed
fctx := &pctx.fctx
fctx.Response.Reset()
fctx.Request.Reset()
defer ctxPool.Put(pctx)

remoteAddr, err := resolveRemoteAddr(r.RemoteAddr, r.Context().Value(http.LocalAddrContextKey))
if err != nil {
remoteAddr = nil // Fallback to nil
}
pctx.conn.remoteAddr = remoteAddr

// Init2 mirrors fasthttp's RequestCtx.Init, but with a no-op
// connection instead of fasthttp's fakeAddrer, whose Write panics.
// Interim responses (e.g. SendEarlyHints' 103) are then silently
// discarded instead of panicking; the final response still reaches
// the client through the ResponseWriter copy-back below. Init2 only
// touches connection metadata and buffer-retention flags, so the
// request is built directly into fctx.Request afterwards — the same
// order fasthttp's Init uses, minus its full request copy.
fctx.Init2(&pctx.conn, disabledLogger, true)
req := &fctx.Request

// Convert net/http -> fasthttp request with size limit
maxBodySize := int64(app.Config().BodyLimit)
Expand Down Expand Up @@ -319,30 +395,22 @@ func handlerFunc(app *fiber.App, h ...fiber.Handler) http.HandlerFunc {
}
req.Header.SetProtocol(proto)

for key, val := range r.Header {
for _, v := range val {
req.Header.Set(key, v)
for key, vals := range r.Header {
if len(vals) == 0 {
continue
}
// Set replaces any value fasthttp derived while building the
// request, then Add appends the remaining values so multi-value
// headers (e.g. repeated X-Forwarded-For lines) survive instead
// of collapsing to the last value. fasthttp's Add keeps its own
// singleton semantics for Cookie/Content-Type/etc., which can
// only hold one value there by design.
req.Header.Set(key, vals[0])
for _, v := range vals[1:] {
req.Header.Add(key, v)
}
}

remoteAddr, err := resolveRemoteAddr(r.RemoteAddr, r.Context().Value(http.LocalAddrContextKey))
if err != nil {
remoteAddr = nil // Fallback to nil
}

// New fasthttp Ctx from pool
fctx := ctxPool.Get().(*fasthttp.RequestCtx) //nolint:forcetypeassert,errcheck // not needed
fctx.Response.Reset()
fctx.Request.Reset()
defer ctxPool.Put(fctx)
// Init2 + CopyTo mirror fasthttp's RequestCtx.Init, but with a no-op
// connection instead of fasthttp's fakeAddrer, whose Write panics.
// Interim responses (e.g. SendEarlyHints' 103) are then silently
// discarded instead of panicking; the final response still reaches
// the client through the ResponseWriter copy-back below.
fctx.Init2(&noopConn{remoteAddr: remoteAddr}, &disableLogger{}, true)
req.CopyTo(&fctx.Request)

if len(h) > 0 {
// New fiber Ctx
ctx := app.AcquireCtx(fctx)
Expand Down
138 changes: 138 additions & 0 deletions middleware/adaptor/adaptor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1778,3 +1778,141 @@ func TestUnixSocketAdaptor(t *testing.T) {
t.Fatal("server shutdown timed out")
}
}

// Test_ResolveRemoteAddr_FastPathEquivalence pins the ip:port fast path to
// net.ResolveTCPAddr: for every literal form the fast path accepts, both
// must produce the same address, and inputs the fast path rejects must
// still resolve identically through the fallback.
func Test_ResolveRemoteAddr_FastPathEquivalence(t *testing.T) {
t.Parallel()

addrs := []string{
"1.2.3.4:6789",
"127.0.0.1:80",
"255.255.255.255:65535",
"[::1]:8080",
"[2001:db8::1]:443",
"[::ffff:1.2.3.4]:80",
"[fe80::1%eth0]:80",
"1.2.3.4:0",
}
for _, addr := range addrs {
got, err := resolveRemoteAddr(addr, nil)
require.NoError(t, err, "resolveRemoteAddr(%q)", addr)
want, err := net.ResolveTCPAddr("tcp", addr)
require.NoError(t, err, "ResolveTCPAddr(%q)", addr)
gotTCP, ok := got.(*net.TCPAddr)
require.True(t, ok, "resolveRemoteAddr(%q) type", addr)
require.True(t, want.IP.Equal(gotTCP.IP), "IP for %q: got %v want %v", addr, gotTCP.IP, want.IP)
require.Equal(t, want.Port, gotTCP.Port, "port for %q", addr)
require.Equal(t, want.Zone, gotTCP.Zone, "zone for %q", addr)
require.Equal(t, want.String(), gotTCP.String(), "String() for %q", addr)
}

// Rejected by the fast path, resolved by the fallback: identical results.
fallbackAddrs := []string{
"localhost:80", // hostname
"1.2.3.4:0080", // leading-zero port (fast path parses digits only, both accept)
"1.2.3.4", // missing port -> :80 default via fallback
"01.2.3.4:80", // leading-zero octet (rejected by ParseIPv4, accepted by resolver)
"1.2.3.4:99999", // port out of range -> error from both? fallback errors
"1.2.3.4:", // empty port -> fast path rejects, resolver yields port 0
"1.2.3.4:123456", // six digits -> fast path length guard, resolver errors
"1.2.3.4:8a", // non-digit port -> fast path rejects, resolver service lookup fails
}
for _, addr := range fallbackAddrs {
got, gotErr := resolveRemoteAddr(addr, nil)
want, wantErr := net.ResolveTCPAddr("tcp", addr)
if addr == "1.2.3.4" {
// resolveRemoteAddr appends :80 for missing ports; ResolveTCPAddr errors.
require.NoError(t, gotErr)
require.Equal(t, "1.2.3.4:80", got.String())
continue
}
if wantErr != nil {
require.Error(t, gotErr, "addr %q", addr)
continue
}
require.NoError(t, gotErr, "addr %q", addr)
require.Equal(t, want.String(), got.String(), "String() for %q", addr)
}
}

// Test_FiberHandlerFunc_MultiValueHeaders verifies repeated header lines on
// the net/http request all reach the fiber handler instead of collapsing to
// the last value.
func Test_FiberHandlerFunc_MultiValueHeaders(t *testing.T) {
t.Parallel()

var got []string
h := FiberHandlerFunc(func(c fiber.Ctx) error {
for _, v := range c.Request().Header.PeekAll("X-Multi") {
got = append(got, string(v))
}
return c.SendStatus(fiber.StatusOK)
})

r := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
r.Header.Add("X-Multi", "one")
r.Header.Add("X-Multi", "two")
r.Header.Add("X-Multi", "three")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)

require.Equal(t, []string{"one", "two", "three"}, got)
}

// Test_FiberHandlerFunc_EmptyHeaderValues verifies a header key mapped to an
// empty value slice is skipped instead of panicking or producing an empty
// header.
func Test_FiberHandlerFunc_EmptyHeaderValues(t *testing.T) {
t.Parallel()

var seen bool
h := FiberHandlerFunc(func(c fiber.Ctx) error {
seen = len(c.Request().Header.PeekAll("X-Empty")) > 0
return c.SendStatus(fiber.StatusOK)
})

r := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
r.Header["X-Empty"] = []string{}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)

require.Equal(t, http.StatusOK, w.Code)
require.False(t, seen)
}

// Test_HTTPMiddleware_MultiValueHeaders verifies multi-value headers added
// by a wrapped net/http middleware survive the copy-back onto the fiber
// request.
func Test_HTTPMiddleware_MultiValueHeaders(t *testing.T) {
t.Parallel()

mw := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Header.Add("X-Multi", "one")
r.Header.Add("X-Multi", "two")
r.Header["X-Empty"] = nil // empty value slices must be skipped
next.ServeHTTP(w, r)
})
}

app := fiber.New()
app.Use(HTTPMiddleware(mw))
var got []string
var emptyHeaderValues int
app.Get("/", func(c fiber.Ctx) error {
for _, v := range c.Request().Header.PeekAll("X-Multi") {
got = append(got, string(v))
}
emptyHeaderValues = len(c.Request().Header.PeekAll("X-Empty"))
return c.SendStatus(fiber.StatusOK)
})

resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Equal(t, []string{"one", "two"}, got)
require.Zero(t, emptyHeaderValues, "empty header value slices must not be copied")
}
Loading
Loading