Skip to content

Data race in {% cycle %}: renderer closure writes to an error variable captured at parse time #150

Description

@masabni

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions