diff --git a/ctx.go b/ctx.go index 8fe9e67a72c..4250886f827 100644 --- a/ctx.go +++ b/ctx.go @@ -56,6 +56,7 @@ const ( //go:generate ifacemaker --file ctx.go --file req.go --file res.go --struct DefaultCtx --iface Ctx --pkg fiber --promoted --output ctx_interface_gen.go --not-exported true --iface-comment "Ctx represents the Context which hold the HTTP request and response.\nIt has methods for the request query string, parameters, body, HTTP headers and so on." type DefaultCtx struct { handlerCtx CustomCtx // Active custom context implementation, if any + cancelFunc context.CancelFunc // Context cancel function DefaultReq // Default request api DefaultRes // Default response api app *App // Reference to *App @@ -136,15 +137,33 @@ func (c *DefaultCtx) Context() context.Context { if c.fasthttp == nil { return context.Background() } - if ctx, ok := c.fasthttp.UserValue(userContextKey).(context.Context); ok && ctx != nil { - return ctx + + ctx, ok := c.fasthttp.UserValue(userContextKey).(context.Context) + if !ok || ctx == nil { + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(context.Background()) + c.cancelFunc = cancel + c.SetContext(ctx) + } + + if c.cancelFunc != nil && c.fasthttp.Conn() != nil { + fastHTTPDone := c.fasthttp.Done() + reqCtx := ctx + cancel := c.cancelFunc + c.cancelFunc = nil + + go func() { + select { + case <-fastHTTPDone: + cancel() + case <-reqCtx.Done(): + } + }() } - ctx := context.Background() - c.SetContext(ctx) + return ctx } -// SetContext sets a context implementation by user. func (c *DefaultCtx) SetContext(ctx context.Context) { if c.fasthttp == nil { return @@ -685,6 +704,10 @@ func (c *DefaultCtx) configDependentPaths() { // Reset is a method to reset context fields by given request when to use server handlers. func (c *DefaultCtx) Reset(fctx *fasthttp.RequestCtx) { // Reset route and handler index + if c.cancelFunc != nil { + c.cancelFunc() + c.cancelFunc = nil + } c.indexRoute = -1 c.indexHandler = 0 // Reset matched flag @@ -694,9 +717,18 @@ func (c *DefaultCtx) Reset(fctx *fasthttp.RequestCtx) { // Set paths c.pathOriginal = c.app.toString(fctx.URI().PathOriginal()) // Set method - c.methodInt = c.app.methodInt(utils.UnsafeString(fctx.Request.Header.Method())) + if fctx != nil { + c.methodInt = c.app.methodInt(utils.UnsafeString(fctx.Request.Header.Method())) + } // Attach *fasthttp.RequestCtx to ctx c.fasthttp = fctx + if fctx != nil { + reqCtx, cancel := context.WithCancel(context.Background()) + c.cancelFunc = cancel + + c.fasthttp.SetUserValue(userContextKey, reqCtx) + c.isUserContextSet = true + } // reset base uri c.baseURI = "" // Prettify path @@ -708,6 +740,10 @@ func (c *DefaultCtx) Reset(fctx *fasthttp.RequestCtx) { // release is a method to reset context fields when to use ReleaseCtx() func (c *DefaultCtx) release() { + if c.cancelFunc != nil { + c.cancelFunc() + c.cancelFunc = nil + } if c.isUserContextSet { if c.fasthttp != nil { c.fasthttp.SetUserValue(userContextKey, nil) diff --git a/ctx_test.go b/ctx_test.go index ebc08c32261..93ed3d14018 100644 --- a/ctx_test.go +++ b/ctx_test.go @@ -4211,7 +4211,12 @@ func Test_Ctx_Context(t *testing.T) { t.Run("Nil_Context", func(t *testing.T) { t.Parallel() ctx := c.Context() - require.Equal(t, ctx, context.Background()) + + require.NotNil(t, ctx) + require.NoError(t, ctx.Err()) + + _, hasDeadline := ctx.Deadline() + require.False(t, hasDeadline) }) t.Run("ValueContext", func(t *testing.T) { @@ -9956,3 +9961,116 @@ func Benchmark_Ctx_OverrideParam(b *testing.B) { c.OverrideParam("name", "changed") } } + +// go test -v -run=Test_Ctx_Context_Lazy_Initialization_Suite +func Test_Ctx_Context_Lazy_Initialization_Suite(t *testing.T) { + app := New() + + // Scenario with fake network connection + t.Run("Lazy_Initialization_With_Conn", func(t *testing.T) { + fctx := &fasthttp.RequestCtx{} + remoteAddr := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 8080} + fctx.Init(&fasthttp.Request{}, remoteAddr, nil) + + ctx := app.AcquireCtx(fctx) + c, ok := ctx.(*DefaultCtx) + if !ok { + t.Fatal("AcquireCtx did not return *DefaultCtx") + } + + if c.cancelFunc == nil { + t.Fatal("expected cancelFunc to be initialized") + } + + goCtx := c.Context() + if goCtx == nil { + t.Fatal("expected Go context to be returned") + } + if c.cancelFunc != nil { + t.Error("expected cancelFunc to be nil after lazy initialization") + } + + goCtx2 := c.Context() + if goCtx2 != goCtx { + t.Error("expected the same context instance on sequential calls") + } + + app.ReleaseCtx(c) + }) + // Scenario without fake network connection + t.Run("Lazy_Initialization_Without_Conn", func(t *testing.T) { + ctx := app.AcquireCtx(&fasthttp.RequestCtx{}) + c, ok := ctx.(*DefaultCtx) + if !ok { + t.Fatal("AcquireCtx did not return *DefaultCtx") + } + + if c.cancelFunc == nil { + t.Fatal("expected cancelFunc to be initialized even without connection") + } + + _ = c.Context() + + if c.cancelFunc == nil { + t.Error("expected cancelFunc to remain non-nil when c.fasthttp.Conn() is nil") + } + + app.ReleaseCtx(c) + }) + + // Scenario without .Context() + t.Run("Release_Without_Context_Access", func(t *testing.T) { + ctx := app.AcquireCtx(&fasthttp.RequestCtx{}) + c, ok := ctx.(*DefaultCtx) + if !ok { + t.Fatal("AcquireCtx did not return *DefaultCtx") + } + + if c.cancelFunc == nil { + t.Fatal("expected cancelFunc to be initialized") + } + + app.ReleaseCtx(c) + + if c.cancelFunc != nil { + t.Error("expected cancelFunc to be cleared by release()") + } + }) +} + +// go test -v -run=Test_Ctx_Context_Cancel_On_Disconnect +func Test_Ctx_Context_Cancel_On_Disconnect(t *testing.T) { + app := New() + fctx := &fasthttp.RequestCtx{} + netCtx, cancelNet := context.WithCancel(context.Background()) + + ctx := app.AcquireCtx(fctx) + c, ok := ctx.(*DefaultCtx) + if !ok { + t.Fatal("AcquireCtx did not return *DefaultCtx") + } + + c.SetContext(netCtx) + + goCtx := c.Context() + + select { + case <-goCtx.Done(): + t.Fatal("context should not be canceled yet") + default: + } + + // close net connection + cancelNet() + + select { + case <-goCtx.Done(): + if !errors.Is(goCtx.Err(), context.Canceled) { + t.Errorf("expected context.Canceled, got %v", goCtx.Err()) + } + case <-time.After(200 * time.Millisecond): + t.Fatal("expected Go context to be canceled after connection close") + } + + app.ReleaseCtx(c) +} diff --git a/middleware/timeout/timeout_test.go b/middleware/timeout/timeout_test.go index 34e205c6701..9bbcffb1107 100644 --- a/middleware/timeout/timeout_test.go +++ b/middleware/timeout/timeout_test.go @@ -650,3 +650,72 @@ func TestTimeout_AbandonWithoutReclaimNotPooled(t *testing.T) { app.ReleaseCtx(ctx) require.True(t, ctx.IsAbandoned(), "abandoned, un-armed context must not be auto-reclaimed") } + +// TestTimeout_Integration_WithLazyContext verifies that the core's lazy context +// activation works seamlessly during a standard timeout event. +func TestTimeout_Integration_WithLazyContext(t *testing.T) { + t.Parallel() + app := fiber.New() + + app.Get("/lazy-timeout", New(func(c fiber.Ctx) error { + _ = c.Context() + + time.Sleep(50 * time.Millisecond) + + select { + case <-c.Context().Done(): + return c.Context().Err() + default: + return c.SendString("Should timeout") + } + }, Config{Timeout: 10 * time.Millisecond})) + + req := httptest.NewRequest(fiber.MethodGet, "/lazy-timeout", http.NoBody) + resp, err := app.Test(req) + + require.NoError(t, err) + require.Equal(t, fiber.StatusRequestTimeout, resp.StatusCode) +} + +func TestTimeout_Integration_PanicAfterTimeoutWithLazyCtx(t *testing.T) { + t.Parallel() + app := fiber.New() + + ctxCh := make(chan fiber.Ctx, 1) + release := make(chan struct{}) + panicDone := make(chan struct{}) + + app.Get("/lazy-panic", New(func(c fiber.Ctx) error { + _ = c.Context() + ctxCh <- c + <-release + + defer close(panicDone) + panic("disaster after timeout") + }, Config{Timeout: 10 * time.Millisecond})) + + req := httptest.NewRequest(fiber.MethodGet, "/lazy-panic", http.NoBody) + resp, err := app.Test(req) + + require.NoError(t, err) + require.Equal(t, fiber.StatusRequestTimeout, resp.StatusCode) + + var c fiber.Ctx + select { + case c = <-ctxCh: + case <-time.After(time.Second): + t.Fatal("handler did not start") + } + + close(release) + + select { + case <-panicDone: + case <-time.After(time.Second): + t.Fatal("handler did not finish panic recovery") + } + + require.Eventually(t, func() bool { + return !c.IsAbandoned() + }, time.Second, 5*time.Millisecond, "lazy context was not reclaimed properly after post-timeout panic") +}