From cb461bb1feefe8e644ad9b09cc3004b79c6beb81 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Tue, 2 Jun 2026 18:50:47 +0200 Subject: [PATCH] refactor(webhook): extract async context detach into a helper --- pkg/modules/webhook/async_context.go | 32 +++++++++++++ pkg/modules/webhook/async_context_test.go | 56 +++++++++++++++++++++++ pkg/modules/webhook/middleware.go | 22 +-------- 3 files changed, 89 insertions(+), 21 deletions(-) create mode 100644 pkg/modules/webhook/async_context.go create mode 100644 pkg/modules/webhook/async_context_test.go diff --git a/pkg/modules/webhook/async_context.go b/pkg/modules/webhook/async_context.go new file mode 100644 index 00000000..97b9811d --- /dev/null +++ b/pkg/modules/webhook/async_context.go @@ -0,0 +1,32 @@ +package webhook + +import ( + "context" + + "github.com/gotenberg/gotenberg/v8/pkg/modules/api" +) + +// detachAsyncContext detaches ctx from the inbound request lifecycle so the +// webhook goroutine survives echo recycling the request, while preserving the +// conversion deadline. +// +// Echo cancels the request context as soon as the synchronous handler returns +// [api.ErrAsyncProcess], which would abort the asynchronous work. Replacing the +// embedded context severs that cancellation. The returned cancel function +// cleans up both the detached context and the original working directory. +func detachAsyncContext(ctx *api.Context, cancel context.CancelFunc) context.CancelFunc { + if deadline, ok := ctx.Deadline(); ok { + detachedCtx, detachedCancel := context.WithDeadline(context.Background(), deadline) + ctx.Context = detachedCtx + + originalCancel := cancel + return func() { + detachedCancel() + originalCancel() + } + } + + // Fallback if no deadline was set (rare, as newContext enforces it). + ctx.Context = context.Background() + return cancel +} diff --git a/pkg/modules/webhook/async_context_test.go b/pkg/modules/webhook/async_context_test.go new file mode 100644 index 00000000..9c900c23 --- /dev/null +++ b/pkg/modules/webhook/async_context_test.go @@ -0,0 +1,56 @@ +package webhook + +import ( + "context" + "testing" + "time" + + "github.com/gotenberg/gotenberg/v8/pkg/modules/api" +) + +func TestDetachAsyncContext_PreservesDeadline(t *testing.T) { + deadline := time.Now().Add(2 * time.Hour) + reqCtx, reqCancel := context.WithDeadline(context.Background(), deadline) + defer reqCancel() + + ctx := &api.Context{Context: reqCtx} + cancel := detachAsyncContext(ctx, func() {}) + defer cancel() + + got, ok := ctx.Deadline() + if !ok { + t.Fatal("expected the detached context to keep a deadline") + } + if !got.Equal(deadline) { + t.Errorf("expected deadline %v, got %v", deadline, got) + } +} + +func TestDetachAsyncContext_SurvivesRequestCancellation(t *testing.T) { + reqCtx, reqCancel := context.WithDeadline(context.Background(), time.Now().Add(2*time.Hour)) + + ctx := &api.Context{Context: reqCtx} + cancel := detachAsyncContext(ctx, func() {}) + defer cancel() + + // Cancelling the inbound request must not abort the detached context. + reqCancel() + + if err := ctx.Err(); err != nil { + t.Errorf("expected the detached context to survive request cancellation, got %v", err) + } +} + +func TestDetachAsyncContext_CancelInvokesOriginal(t *testing.T) { + reqCtx, reqCancel := context.WithDeadline(context.Background(), time.Now().Add(time.Hour)) + defer reqCancel() + + called := 0 + ctx := &api.Context{Context: reqCtx} + cancel := detachAsyncContext(ctx, func() { called++ }) + + cancel() + if called != 1 { + t.Errorf("expected the original cancel to be invoked once, got %d", called) + } +} diff --git a/pkg/modules/webhook/middleware.go b/pkg/modules/webhook/middleware.go index eafcd092..0043c5ba 100644 --- a/pkg/modules/webhook/middleware.go +++ b/pkg/modules/webhook/middleware.go @@ -314,27 +314,7 @@ func webhookMiddleware(w *Webhook) api.Middleware { return c.NoContent(http.StatusNoContent) } - if deadline, ok := ctx.Deadline(); ok { - // Create a new context derived from Background (detached from Request) - // but with the same deadline as the original context. - detachedCtx, detachedCancel := context.WithDeadline(context.Background(), deadline) - - // Replace the embedded context in the api.Context struct. - // The modules downstream will now use this detached context. - ctx.Context = detachedCtx - - // We must wrap the cancel function. - // 1. detachedCancel() cleans up our new detached context. - // 2. originalCancel() (captured from c.Get("cancel")) cleans up the working directory. - originalCancel := cancel - cancel = func() { - detachedCancel() - originalCancel() - } - } else { - // Fallback if no deadline was set (rare, as newContext enforces it). - ctx.Context = context.Background() - } + cancel = detachAsyncContext(ctx, cancel) // As a webhook URL has been given, we handle the request in a // goroutine and return immediately.