refactor(webhook): extract async context detach into a helper

This commit is contained in:
Julien Neuhart
2026-06-02 18:50:47 +02:00
parent 190cad0ee2
commit cb461bb1fe
3 changed files with 89 additions and 21 deletions
+32
View File
@@ -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
}
+56
View File
@@ -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)
}
}
+1 -21
View File
@@ -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.