From e14cab0c8bac0023ba4c0969bc782a8f1f503d76 Mon Sep 17 00:00:00 2001 From: Adam Romanek Date: Fri, 23 Jan 2026 11:29:47 +0100 Subject: [PATCH] fix(api): propagate request context to stop processing on client disconnect --- pkg/modules/api/context.go | 2 +- pkg/modules/api/context_test.go | 71 +++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 pkg/modules/api/context_test.go diff --git a/pkg/modules/api/context.go b/pkg/modules/api/context.go index 4b390da3..854ea05e 100644 --- a/pkg/modules/api/context.go +++ b/pkg/modules/api/context.go @@ -87,7 +87,7 @@ type downloadFrom struct { // newContext returns a [Context] by parsing a "multipart/form-data" request. func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*Context, context.CancelFunc, error) { - processCtx, processCancel := context.WithTimeout(context.Background(), timeout) + processCtx, processCancel := context.WithTimeout(echoCtx.Request().Context(), timeout) // We want to make sure the multipart/form-data does not exceed a given // limit. We consider: form fields (keys, values, files) and files diff --git a/pkg/modules/api/context_test.go b/pkg/modules/api/context_test.go new file mode 100644 index 00000000..1209f31c --- /dev/null +++ b/pkg/modules/api/context_test.go @@ -0,0 +1,71 @@ +package api + +import ( + "bytes" + "context" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/labstack/echo/v4" + "go.uber.org/zap" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" +) + +// Propagate HTTP request context cancellation to processing modules to save resources +// https://github.com/gotenberg/gotenberg/issues/1455 +func TestNewContext_Cancellation(t *testing.T) { + e := echo.New() + + body := new(bytes.Buffer) + writer := multipart.NewWriter(body) + err := writer.Close() + if err != nil { + t.Fatalf("failed to close multipart writer: %v", err) + } + + // Create a request with a cancellable context. + reqCtx, cancelReq := context.WithCancel(context.Background()) + req := httptest.NewRequest(http.MethodPost, "/", body).WithContext(reqCtx) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + logger := zap.NewNop() + fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) + timeout := time.Duration(10) * time.Second + downloadFromCfg := downloadFromConfig{ + disable: true, + } + + ctx, cancel, err := newContext(c, logger, fs, timeout, 0, downloadFromCfg, "trace", "trace") + if err != nil { + t.Fatalf("expected no error from newContext, got: %v", err) + } + defer cancel() + + // Verify initial state: context SHOULD NOT be done yet. + select { + case <-ctx.Done(): + t.Fatal("context should not be done immediately") + default: + } + + // Simulate Client Disconnect + cancelReq() + + // Verify Propagation + select { + case <-ctx.Done(): + // Success! The context was cancelled. + if ctx.Err() != context.Canceled { + t.Errorf("expected context error to be 'context.Canceled', got: %v", ctx.Err()) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("expected context to be cancelled after request context cancellation, but it timed out") + } +}