From 842d8f105256c5656e7895ee61fa5b2dfe90a9e3 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Tue, 10 Mar 2026 10:15:37 +0100 Subject: [PATCH] resources: Fix context canceled on GetRemote with per-request timeout The per-request timeout context was cancelled via defer in the getRes closure, before io.ReadAll(res.Body) in the outer scope could read the response body. Fix this by returning the cancel function from getRes so each caller manages its own cancel lifecycle. Fixes #14611 Co-Authored-By: Claude Opus 4.6 --- .../create/create_integration_test.go | 47 +++++++++++++++++++ resources/resource_factories/create/remote.go | 31 ++++++++---- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/resources/resource_factories/create/create_integration_test.go b/resources/resource_factories/create/create_integration_test.go index b9c46bbcf..e210915ef 100644 --- a/resources/resource_factories/create/create_integration_test.go +++ b/resources/resource_factories/create/create_integration_test.go @@ -220,3 +220,50 @@ mediaTypes = ['text/plain'] // The per-request timeout of 200ms should fire well before the global 30s timeout. b.AssertFileContent("public/index.html", "Err:") } + +// Issue 14611. +func TestGetRemotePerRequestTimeoutBodyRead(t *testing.T) { + t.Parallel() + + // Server sends headers immediately but streams body with a delay. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "text/plain") + w.WriteHeader(200) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + time.Sleep(300 * time.Millisecond) + w.Write([]byte("Hello from remote.")) + })) + t.Cleanup(func() { srv.Close() }) + + files := ` +-- hugo.toml -- +timeout = "30s" +[security] +[security.http] +urls = ['.*'] +mediaTypes = ['text/plain'] +-- layouts/home.html -- +{{ $url := "URL" }} +{{ $opts := dict "timeout" "5s" }} +{{ with try (resources.GetRemote $url $opts) }} + {{ with .Err }} + Err: {{ . }} + {{ else with .Value }} + Content: {{ .Content }} + {{ end }} +{{ end }} +` + files = strings.ReplaceAll(files, "URL", srv.URL) + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ) + b.Build() + + b.AssertFileContent("public/index.html", "Content: Hello from remote.") +} diff --git a/resources/resource_factories/create/remote.go b/resources/resource_factories/create/remote.go index 22aaf3ce9..7c6104e9c 100644 --- a/resources/resource_factories/create/remote.go +++ b/resources/resource_factories/create/remote.go @@ -110,7 +110,7 @@ var temporaryHTTPStatusCodes = map[int]bool{ 504: true, } -func (c *Client) configurePollingIfEnabled(uri, optionsKey string, getRes func() (*http.Response, error)) { +func (c *Client) configurePollingIfEnabled(uri, optionsKey string, getRes func() (*http.Response, context.CancelFunc, error)) { if c.remoteResourceChecker == nil { return } @@ -137,7 +137,10 @@ func (c *Client) configurePollingIfEnabled(uri, optionsKey string, getRes func() c.rs.Logger.Debugf("Polled remote resource for changes in %13s. Interval: %4s (low: %4s high: %4s) resource: %q ", duration, interval, pollingConfig.Config.Low, pollingConfig.Config.High, uri) }() // TODO(bep) figure out a ways to remove unused tasks. - res, err := getRes() + res, cancel, err := getRes() + if cancel != nil { + defer cancel() + } if err != nil { return pollingConfig.Config.High, err } @@ -210,26 +213,38 @@ func (c *Client) FromRemote(uri string, optionsm map[string]any) (resource.Resou return nil, err } - getRes := func() (*http.Response, error) { + getRes := func() (*http.Response, context.CancelFunc, error) { ctx := context.Background() + var cancel context.CancelFunc if perRequestTimeout > 0 { - var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, perRequestTimeout) - defer cancel() } ctx = c.resourceIDDispatcher.Set(ctx, filecacheKey) req, err := options.NewRequest(uri) if err != nil { - return nil, fmt.Errorf("failed to create request for resource %s: %w", uri, err) + if cancel != nil { + cancel() + } + return nil, nil, fmt.Errorf("failed to create request for resource %s: %w", uri, err) } req = req.WithContext(ctx) - return c.httpClient.Do(req) + resp, err := c.httpClient.Do(req) + if err != nil { + if cancel != nil { + cancel() + } + return nil, nil, err + } + return resp, cancel, nil } - res, err := getRes() + res, cancel, err := getRes() + if cancel != nil { + defer cancel() + } if err != nil { return nil, err } -- 2.39.5