Skip to content

Commit 75e020c

Browse files
committed
fix(ifchanged): use per-execution state instead of shared AST node state
1 parent f468d6f commit 75e020c

2 files changed

Lines changed: 107 additions & 7 deletions

File tree

pongo2_issues_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,74 @@ func TestBugCycleSharedState(t *testing.T) {
7878
}
7979
wg.Wait()
8080
}
81+
82+
func TestBugIfchangedSharedState(t *testing.T) {
83+
// Bug: tagIfchangedNode.lastValues and lastContent were stored on the
84+
// AST node, which is shared across all concurrent executions.
85+
// This caused two problems:
86+
// 1. Data race: concurrent writes to lastValues/lastContent
87+
// 2. Semantic bug: ifchanged compares against state from a DIFFERENT
88+
// execution, so the first item might be suppressed if a previous
89+
// execution ended with the same value.
90+
//
91+
// Each template execution must have independent ifchanged state.
92+
93+
tpl, err := pongo2.FromString(`{% for item in items %}{% ifchanged %}{{ item }}{% endifchanged %}{% endfor %}`)
94+
if err != nil {
95+
t.Fatalf("failed to parse template: %v", err)
96+
}
97+
98+
ctx := pongo2.Context{"items": []string{"a", "a", "b", "b", "c"}}
99+
const expected = "abc"
100+
101+
// First: verify sequential executions produce consistent results.
102+
// With shared state, the second execution starts with lastContent="c"
103+
// from the first execution, so "a" would be correctly shown (different
104+
// from "c"), but the pattern breaks in more complex scenarios.
105+
// Use a case where it definitely breaks: items starting with the same
106+
// value the previous execution ended with.
107+
tplSameEnd, err := pongo2.FromString(`{% for item in items %}{% ifchanged %}{{ item }}{% endifchanged %}{% endfor %}`)
108+
if err != nil {
109+
t.Fatalf("failed to parse template: %v", err)
110+
}
111+
112+
// Items end with "x", so next execution starting with "x" would skip it
113+
ctxEndsX := pongo2.Context{"items": []string{"a", "b", "x"}}
114+
ctxStartsX := pongo2.Context{"items": []string{"x", "y", "z"}}
115+
116+
// First execution ends with lastContent="x"
117+
result1, err := tplSameEnd.Execute(ctxEndsX)
118+
if err != nil {
119+
t.Fatalf("execution 1: unexpected error: %v", err)
120+
}
121+
if result1 != "abx" {
122+
t.Errorf("execution 1: got %q, want %q", result1, "abx")
123+
}
124+
125+
// Second execution should output "x" even though previous ended with "x"
126+
result2, err := tplSameEnd.Execute(ctxStartsX)
127+
if err != nil {
128+
t.Fatalf("execution 2: unexpected error: %v", err)
129+
}
130+
if result2 != "xyz" {
131+
t.Errorf("execution 2: got %q, want %q (ifchanged state leaked from previous execution)", result2, "xyz")
132+
}
133+
134+
// Also verify concurrent executions produce correct results.
135+
var wg2 sync.WaitGroup
136+
for i := 0; i < 20; i++ {
137+
wg2.Add(1)
138+
go func() {
139+
defer wg2.Done()
140+
result, err := tpl.Execute(ctx)
141+
if err != nil {
142+
t.Errorf("concurrent: unexpected error: %v", err)
143+
return
144+
}
145+
if result != expected {
146+
t.Errorf("concurrent: got %q, want %q", result, expected)
147+
}
148+
}()
149+
}
150+
wg2.Wait()
151+
}

tags_ifchanged.go

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,33 @@ import (
44
"bytes"
55
)
66

7-
type tagIfchangedNode struct {
8-
watchedExpr []IEvaluator
7+
// ifchangedState holds the per-execution mutable state for an {% ifchanged %} tag.
8+
type ifchangedState struct {
99
lastValues []*Value
1010
lastContent []byte
11+
}
12+
13+
type tagIfchangedNode struct {
14+
watchedExpr []IEvaluator
1115
thenWrapper *NodeWrapper
1216
elseWrapper *NodeWrapper
1317
}
1418

19+
// getState returns the per-execution ifchanged state for this node,
20+
// creating it on first access. Each template execution gets its own
21+
// independent state via ctx.tagState.
22+
func (node *tagIfchangedNode) getState(ctx *ExecutionContext) *ifchangedState {
23+
if s, ok := ctx.tagState[node].(*ifchangedState); ok {
24+
return s
25+
}
26+
s := &ifchangedState{}
27+
ctx.tagState[node] = s
28+
return s
29+
}
30+
1531
func (node *tagIfchangedNode) Execute(ctx *ExecutionContext, writer TemplateWriter) *Error {
32+
state := node.getState(ctx)
33+
1634
if len(node.watchedExpr) == 0 {
1735
// Check against own rendered body
1836

@@ -23,10 +41,21 @@ func (node *tagIfchangedNode) Execute(ctx *ExecutionContext, writer TemplateWrit
2341
}
2442

2543
bufBytes := buf.Bytes()
26-
if !bytes.Equal(node.lastContent, bufBytes) {
44+
45+
changed := !bytes.Equal(state.lastContent, bufBytes)
46+
if changed {
47+
state.lastContent = bufBytes
48+
}
49+
50+
if changed {
2751
// Rendered content changed, output it
2852
writer.Write(bufBytes)
29-
node.lastContent = bufBytes
53+
} else if node.elseWrapper != nil {
54+
// Content hasn't changed, render else block if present
55+
err := node.elseWrapper.Execute(ctx, writer)
56+
if err != nil {
57+
return err
58+
}
3059
}
3160
} else {
3261
nowValues := make([]*Value, 0, len(node.watchedExpr))
@@ -39,16 +68,16 @@ func (node *tagIfchangedNode) Execute(ctx *ExecutionContext, writer TemplateWrit
3968
}
4069

4170
// Compare old to new values now
42-
changed := len(node.lastValues) == 0
71+
changed := len(state.lastValues) == 0
4372

44-
for idx, oldVal := range node.lastValues {
73+
for idx, oldVal := range state.lastValues {
4574
if !oldVal.EqualValueTo(nowValues[idx]) {
4675
changed = true
4776
break // we can stop here because ONE value changed
4877
}
4978
}
5079

51-
node.lastValues = nowValues
80+
state.lastValues = nowValues
5281

5382
if changed {
5483
// Render thenWrapper

0 commit comments

Comments
 (0)