cycleTag builds its renderer closure once, at parse time, and the closure assigns the write's error to the err declared by the compiler rather than to a local one:
|
func cycleTag(args string) (func(io.Writer, render.Context) error, error) { |
|
stmt, err := expressions.ParseStatement(expressions.CycleStatementSelector, args) |
|
if err != nil { |
|
return nil, err |
|
} |
|
|
|
cycle := stmt.Cycle |
|
|
|
return func(w io.Writer, ctx render.Context) error { |
|
loopVar := ctx.Get(forloopVarName) |
|
if loopVar == nil { |
|
return ctx.Errorf("cycle must be within a forloop") |
|
} |
|
// The next few lines could panic if the user spoofs us by creating their own loop object. |
|
// “C++ protects against accident, not against fraud.” – Bjarne Stroustrup |
|
loopRec := loopVar.(map[string]any) |
|
cycleMap := loopRec[".cycles"].(map[string]int) |
|
group, values := cycle.Group, cycle.Values |
|
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)]) |
|
|
|
return err |
|
}, nil |
func cycleTag(args string) (func(io.Writer, render.Context) error, error) {
stmt, err := expressions.ParseStatement(expressions.CycleStatementSelector, args) // line 45
...
return func(w io.Writer, ctx render.Context) error {
...
_, err = io.WriteString(w, values[n%len(values)]) // line 65 — `=`, so this writes the captured err
return err
}, nil
}
Because the closure is created once per parse and reused by every render, all renders write to that single variable. Rendering one parsed *liquid.Template from more than one goroutine is therefore a data race.
Practical impact is small: err is only ever non-nil on the FRender-to-a-failing-writer path, so the reachable symptom is one render's write error being returned by a different render's cycle tag. Under go test -race it fails outright.
This looks like the only site — the other tag closures each declare a fresh err with := inside the closure before assigning to it (iteration_tags.go:89, include_tag.go:45, render_tag.go:389,404).
Repro — parse once, render concurrently:
tpl, err := liquid.NewEngine().ParseString(`{% for a in (1..4) %}{% cycle 'a', 'b' %}{% endfor %}`)
require.NoError(t, err)
var wg sync.WaitGroup
for range 16 {
wg.Add(1)
go func() {
defer wg.Done()
_, _ = tpl.RenderString(liquid.Bindings{})
}()
}
wg.Wait()
Under go test -race:
WARNING: DATA RACE
Write at 0x00c00011e8f0 by goroutine 9:
github.com/osteele/liquid/tags.cycleTag.func1()
tags/iteration_tags.go:65
Previous write at 0x00c00011e8f0 by goroutine 21:
github.com/osteele/liquid/tags.cycleTag.func1()
tags/iteration_tags.go:65
The fix is one token (= → :=). I'll open a PR with a regression test alongside the existing TestTemplate_Parse_race / TestTemplate_Render_race.
cycleTagbuilds its renderer closure once, at parse time, and the closure assigns the write's error to theerrdeclared by the compiler rather than to a local one:liquid/tags/iteration_tags.go
Lines 44 to 68 in 682200b
Because the closure is created once per parse and reused by every render, all renders write to that single variable. Rendering one parsed
*liquid.Templatefrom more than one goroutine is therefore a data race.Practical impact is small:
erris only ever non-nil on theFRender-to-a-failing-writer path, so the reachable symptom is one render's write error being returned by a different render's cycle tag. Undergo test -raceit fails outright.This looks like the only site — the other tag closures each declare a fresh
errwith:=inside the closure before assigning to it (iteration_tags.go:89,include_tag.go:45,render_tag.go:389,404).Repro — parse once, render concurrently:
Under
go test -race:The fix is one token (
=→:=). I'll open a PR with a regression test alongside the existingTestTemplate_Parse_race/TestTemplate_Render_race.