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 <noreply@anthropic.com>
This commit is contained in:
Bjørn Erik Pedersen
2026-03-10 10:15:37 +01:00
parent c47ec23342
commit 842d8f1052
2 changed files with 70 additions and 8 deletions
@@ -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.")
}
+23 -8
View 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
}