Skip to content

fix: avoid shared error variable in cycle tag closure - #151

Open
masabni wants to merge 1 commit into
osteele:mainfrom
masabni:fix/cycle-tag-data-race
Open

fix: avoid shared error variable in cycle tag closure#151
masabni wants to merge 1 commit into
osteele:mainfrom
masabni:fix/cycle-tag-data-race

Conversation

@masabni

@masabni masabni commented Sep 3, 2026

Copy link
Copy Markdown

Fixes #150

cycleTag builds its renderer closure once, at parse time. At tags/iteration_tags.go:65 the closure assigned the write's error with = rather than :=, so it wrote to the err declared by the compiler at line 45 instead of to a local:

stmt, err := expressions.ParseStatement(...)          // line 45 — declares err
...
return func(w io.Writer, ctx render.Context) error {
    ...
    _, err = io.WriteString(w, values[n%len(values)])  // line 65 — captured, not shadowed
    return err
}, nil

The closure is built once per parse and shared by every render, so concurrently rendering one parsed *Template races on that variable. Declaring the error locally fixes it.

Only site. Every other returned closure already declares a fresh err inside itself before assigning (iteration_tags.go:89, include_tag.go:45, render_tag.go:389,404, engine.go:71), so this is one fix rather than a sweep.

Behavior unchanged. err is only ever non-nil on the FRender-to-a-failing-writer path, and the cycle counters were already per-render — cycleMap is created in loopRenderer.render and published via forloop[".cycles"] — so rendered output is unaffected.

Test. TestTemplate_Render_cycle_race parses one template and renders it from 16 goroutines. It sits next to the existing TestTemplate_Parse_race and TestTemplate_Render_race, which cover concurrent parse and concurrent render of distinct templates; this closes the remaining gap of one shared template. The template is deliberately just {% for %} + {% cycle %} with empty bindings, so it doesn't touch the caches Config shares by design. On unfixed code it fails:

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
...
--- FAIL: TestTemplate_Render_cycle_race (0.02s)
    testing.go:1617: race detected during execution of test

Checklist

  • I have read the contribution guidelines.
  • make test passes.
  • make lint passes (0 issues). make vet and make ci pass too.
  • New and changed code is covered by tests.
  • Performance improvements include benchmarks. — N/A, this is not a performance change.
  • Changes match the documented (not just the implemented) behavior of Shopify. — N/A: a pure concurrency fix with no observable behavior change.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant