Skip to content

Commit 59d8d0d

Browse files
authored
Add cumulative resource score tracking across partial renders (#2058)
* feat: add cumulative resource score tracking across partial renders Add cumulative_render_score and cumulative_assign_score counters to ResourceLimits that accumulate across reset() calls, with optional cumulative_render_score_limit and cumulative_assign_score_limit to cap total work across all partial renders. Also add a reached? check in BlockBody's render loop so that once a cumulative limit triggers, the parent template stops processing further nodes. Bump version to 5.12.0. * refactor: move cumulative limit enforcement into reset() Instead of checking reached? in BlockBody's render loop, enforce cumulative limits in reset() itself. Since reset() is called before the begin/rescue MemoryError block in Template#render, the raise propagates to the parent naturally — no changes to BlockBody needed.
1 parent 5fa3626 commit 59d8d0d

File tree

4 files changed

+194
-6
lines changed

4 files changed

+194
-6
lines changed

lib/liquid/resource_limits.rb

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,39 @@
22

33
module Liquid
44
class ResourceLimits
5-
attr_accessor :render_length_limit, :render_score_limit, :assign_score_limit
6-
attr_reader :render_score, :assign_score
5+
attr_accessor :render_length_limit,
6+
:render_score_limit,
7+
:assign_score_limit,
8+
:cumulative_render_score_limit,
9+
:cumulative_assign_score_limit
10+
attr_reader :render_score,
11+
:assign_score,
12+
:cumulative_render_score,
13+
:cumulative_assign_score
714

815
def initialize(limits)
9-
@render_length_limit = limits[:render_length_limit]
10-
@render_score_limit = limits[:render_score_limit]
11-
@assign_score_limit = limits[:assign_score_limit]
16+
@render_length_limit = limits[:render_length_limit]
17+
@render_score_limit = limits[:render_score_limit]
18+
@assign_score_limit = limits[:assign_score_limit]
19+
@cumulative_render_score_limit = limits[:cumulative_render_score_limit]
20+
@cumulative_assign_score_limit = limits[:cumulative_assign_score_limit]
21+
@cumulative_render_score = 0
22+
@cumulative_assign_score = 0
1223
reset
1324
end
1425

1526
def increment_render_score(amount)
1627
@render_score += amount
28+
@cumulative_render_score += amount
1729
raise_limits_reached if @render_score_limit && @render_score > @render_score_limit
30+
raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit
1831
end
1932

2033
def increment_assign_score(amount)
2134
@assign_score += amount
35+
@cumulative_assign_score += amount
2236
raise_limits_reached if @assign_score_limit && @assign_score > @assign_score_limit
37+
raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit
2338
end
2439

2540
# update either render_length or assign_score based on whether or not the writes are captured
@@ -47,6 +62,8 @@ def reset
4762
@reached_limit = false
4863
@last_capture_length = nil
4964
@render_score = @assign_score = 0
65+
raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit
66+
raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit
5067
end
5168

5269
def with_capture

lib/liquid/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
# frozen_string_literal: true
33

44
module Liquid
5-
VERSION = "5.11.0"
5+
VERSION = "5.12.0"
66
end

test/integration/template_test.rb

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,86 @@ def test_render_length_uses_number_of_bytes_not_characters
179179
assert_equal("すごい", t.render)
180180
end
181181

182+
def test_cumulative_render_score_limit_across_render_tags
183+
file_system = StubFileSystem.new(
184+
'loop' => '{% for a in (1..10) %} foo {% endfor %}',
185+
)
186+
environment = Liquid::Environment.build(file_system: file_system)
187+
188+
# Without cumulative limit, all 5 partials render successfully
189+
t = Template.parse(
190+
'{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
191+
environment: environment,
192+
)
193+
unlimited_output = t.render!
194+
total_cumulative = t.resource_limits.cumulative_render_score
195+
196+
# With cumulative limit set below the total, rendering stops early
197+
t2 = Template.parse(
198+
'{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
199+
environment: environment,
200+
)
201+
t2.resource_limits.cumulative_render_score_limit = total_cumulative / 2
202+
limited_output = t2.render
203+
assert(t2.resource_limits.reached?)
204+
assert_operator(limited_output.length, :<, unlimited_output.length)
205+
end
206+
207+
def test_cumulative_render_score_limit_raises_on_render_bang
208+
file_system = StubFileSystem.new(
209+
'loop' => '{% for a in (1..10) %} foo {% endfor %}',
210+
)
211+
environment = Liquid::Environment.build(file_system: file_system)
212+
t = Template.parse(
213+
'{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
214+
environment: environment,
215+
)
216+
t.resource_limits.cumulative_render_score_limit = 20
217+
assert_raises(Liquid::MemoryError) do
218+
t.render!
219+
end
220+
end
221+
222+
def test_cumulative_assign_score_limit_across_include_tags
223+
file_system = StubFileSystem.new(
224+
'assign_partial' => '{% assign x = "a long string value here" %}',
225+
)
226+
environment = Liquid::Environment.build(file_system: file_system)
227+
228+
# Without cumulative limit, all 5 partials render
229+
t = Template.parse(
230+
'{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}',
231+
environment: environment,
232+
)
233+
t.render!
234+
total_cumulative = t.resource_limits.cumulative_assign_score
235+
236+
# With cumulative limit set below the total, rendering stops early
237+
t2 = Template.parse(
238+
'{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}',
239+
environment: environment,
240+
)
241+
t2.resource_limits.cumulative_assign_score_limit = total_cumulative / 2
242+
t2.render
243+
assert(t2.resource_limits.reached?)
244+
end
245+
246+
def test_cumulative_render_score_tracks_across_partials_without_limit
247+
file_system = StubFileSystem.new(
248+
'loop' => '{% for a in (1..10) %} foo {% endfor %}',
249+
)
250+
environment = Liquid::Environment.build(file_system: file_system)
251+
t = Template.parse(
252+
'{% render "loop" %}{% render "loop" %}{% render "loop" %}',
253+
environment: environment,
254+
)
255+
t.render!
256+
assert(
257+
t.resource_limits.cumulative_render_score > t.resource_limits.render_score,
258+
"cumulative should exceed per-template score after multiple partials",
259+
)
260+
end
261+
182262
def test_default_resource_limits_unaffected_by_render_with_context
183263
context = Context.new
184264
t = Template.parse("{% for a in (1..100) %}x{% assign foo = 1 %} {% endfor %}")
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# frozen_string_literal: true
2+
3+
require 'test_helper'
4+
5+
class ResourceLimitsUnitTest < Minitest::Test
6+
def test_cumulative_scores_initialize_to_zero
7+
limits = Liquid::ResourceLimits.new({})
8+
assert_equal(0, limits.cumulative_render_score)
9+
assert_equal(0, limits.cumulative_assign_score)
10+
end
11+
12+
def test_cumulative_limits_default_to_nil
13+
limits = Liquid::ResourceLimits.new({})
14+
assert_nil(limits.cumulative_render_score_limit)
15+
assert_nil(limits.cumulative_assign_score_limit)
16+
end
17+
18+
def test_cumulative_limits_configurable_via_hash
19+
limits = Liquid::ResourceLimits.new(
20+
cumulative_render_score_limit: 500,
21+
cumulative_assign_score_limit: 300,
22+
)
23+
assert_equal(500, limits.cumulative_render_score_limit)
24+
assert_equal(300, limits.cumulative_assign_score_limit)
25+
end
26+
27+
def test_cumulative_limits_configurable_via_accessor
28+
limits = Liquid::ResourceLimits.new({})
29+
limits.cumulative_render_score_limit = 500
30+
assert_equal(500, limits.cumulative_render_score_limit)
31+
end
32+
33+
def test_cumulative_scores_survive_reset
34+
limits = Liquid::ResourceLimits.new({})
35+
limits.increment_render_score(10)
36+
limits.increment_assign_score(5)
37+
38+
limits.reset
39+
40+
assert_equal(0, limits.render_score)
41+
assert_equal(0, limits.assign_score)
42+
assert_equal(10, limits.cumulative_render_score)
43+
assert_equal(5, limits.cumulative_assign_score)
44+
end
45+
46+
def test_cumulative_scores_accumulate_across_resets
47+
limits = Liquid::ResourceLimits.new({})
48+
limits.increment_render_score(10)
49+
limits.reset
50+
limits.increment_render_score(20)
51+
limits.reset
52+
limits.increment_render_score(30)
53+
54+
assert_equal(30, limits.render_score)
55+
assert_equal(60, limits.cumulative_render_score)
56+
end
57+
58+
def test_cumulative_render_score_limit_raises
59+
limits = Liquid::ResourceLimits.new(cumulative_render_score_limit: 25)
60+
limits.increment_render_score(10)
61+
limits.reset
62+
limits.increment_render_score(10)
63+
limits.reset
64+
65+
assert_raises(Liquid::MemoryError) do
66+
limits.increment_render_score(10)
67+
end
68+
assert(limits.reached?)
69+
end
70+
71+
def test_cumulative_assign_score_limit_raises
72+
limits = Liquid::ResourceLimits.new(cumulative_assign_score_limit: 15)
73+
limits.increment_assign_score(8)
74+
limits.reset
75+
76+
assert_raises(Liquid::MemoryError) do
77+
limits.increment_assign_score(8)
78+
end
79+
assert(limits.reached?)
80+
end
81+
82+
def test_per_template_limits_still_work_with_cumulative
83+
limits = Liquid::ResourceLimits.new(
84+
render_score_limit: 50,
85+
cumulative_render_score_limit: 1000,
86+
)
87+
assert_raises(Liquid::MemoryError) do
88+
limits.increment_render_score(51)
89+
end
90+
end
91+
end

0 commit comments

Comments
 (0)