]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
resources: Fix context canceled on GetRemote with per-request timeout
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Tue, 10 Mar 2026 09:15:37 +0000 (10:15 +0100)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Tue, 10 Mar 2026 18:45:14 +0000 (19:45 +0100)
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 <noreply@anthropic.com>
resources/resource_factories/create/create_integration_test.go
resources/resource_factories/create/remote.go

index b9c46bbcf268bf0d664c08895a306f4b5d72c95e..e210915efd1bca14a2ff9982de4865ca5cd524e7 100644 (file)
@@ -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.")
+}
index 22aaf3ce94ac57ec9df55fee2a58715b9e59d216..7c6104e9c0ac7328865d58436d3a675ef92155c5 100644 (file)
@@ -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
                }