From: Bjørn Erik Pedersen Date: Tue, 10 Mar 2026 09:15:37 +0000 (+0100) Subject: resources: Fix context canceled on GetRemote with per-request timeout X-Git-Url: http://git.maquefel.me/?a=commitdiff_plain;h=842d8f105256c5656e7895ee61fa5b2dfe90a9e3;p=brevno-suite%2Fhugo 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 --- 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 }