From b8bb825315a79889a64170e0e6d176671fa537f6 Mon Sep 17 00:00:00 2001 From: masabni Date: Thu, 3 Sep 2026 22:15:46 +0300 Subject: [PATCH] fix: avoid shared error variable in cycle tag closure cycleTag builds its renderer closure once, at parse time. The closure assigned the result of io.WriteString to the err variable declared by the compiler at tags/iteration_tags.go:45 instead of to a local, so every render of a parsed template wrote to that one captured variable. Rendering a single parsed template concurrently was therefore a data race. Declare the write's error locally. No behavior change. --- CHANGELOG.md | 6 ++++++ tags/iteration_tags.go | 2 +- template_test.go | 29 +++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 328621f..0ccf323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## Unreleased +### Fixed + +- The `{% cycle %}` tag no longer assigns to an error variable shared by every + render of a parsed template, so a template containing `{% cycle %}` can be + rendered concurrently from multiple goroutines. + ## 1.9.2 (2026-08-16) ### Performance diff --git a/tags/iteration_tags.go b/tags/iteration_tags.go index b088d5c..3be50aa 100644 --- a/tags/iteration_tags.go +++ b/tags/iteration_tags.go @@ -62,7 +62,7 @@ func cycleTag(args string) (func(io.Writer, render.Context) error, error) { n := cycleMap[group] cycleMap[group] = n + 1 // The parser guarantees that there will be at least one item. - _, err = io.WriteString(w, values[n%len(values)]) + _, err := io.WriteString(w, values[n%len(values)]) return err }, nil diff --git a/template_test.go b/template_test.go index 38a0770..9654533 100644 --- a/template_test.go +++ b/template_test.go @@ -115,6 +115,35 @@ func TestTemplate_Render_race(t *testing.T) { wg2.Wait() } +func TestTemplate_Render_cycle_race(t *testing.T) { + engine := NewEngine() + tpl, err := engine.ParseString(`{% for a in (1..4) %}{% cycle 'a', 'b' %}{% endfor %}`) + require.NoError(t, err) + + const ( + goroutines = 16 + renders = 50 + ) + + var wg sync.WaitGroup + + for range goroutines { + wg.Add(1) + + go func() { + defer wg.Done() + + for range renders { + out, err := tpl.RenderString(Bindings{}) + assert.NoError(t, err) + assert.Equal(t, "abab", out) + } + }() + } + + wg.Wait() +} + func BenchmarkTemplate_Render(b *testing.B) { engine := NewEngine() bindings := Bindings{"a": "string value"}