From 59d8d0d22db3b4865deecde0ab2683bea25a1e9f Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Wed, 18 Mar 2026 11:54:46 -0400 Subject: [PATCH] Add cumulative resource score tracking across partial renders (#2058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- lib/liquid/resource_limits.rb | 27 ++++++-- lib/liquid/version.rb | 2 +- test/integration/template_test.rb | 80 ++++++++++++++++++++++ test/unit/resource_limits_unit_test.rb | 91 ++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 test/unit/resource_limits_unit_test.rb diff --git a/lib/liquid/resource_limits.rb b/lib/liquid/resource_limits.rb index 70fac24b..ee0c66cb 100644 --- a/lib/liquid/resource_limits.rb +++ b/lib/liquid/resource_limits.rb @@ -2,24 +2,39 @@ module Liquid class ResourceLimits - attr_accessor :render_length_limit, :render_score_limit, :assign_score_limit - attr_reader :render_score, :assign_score + attr_accessor :render_length_limit, + :render_score_limit, + :assign_score_limit, + :cumulative_render_score_limit, + :cumulative_assign_score_limit + attr_reader :render_score, + :assign_score, + :cumulative_render_score, + :cumulative_assign_score def initialize(limits) - @render_length_limit = limits[:render_length_limit] - @render_score_limit = limits[:render_score_limit] - @assign_score_limit = limits[:assign_score_limit] + @render_length_limit = limits[:render_length_limit] + @render_score_limit = limits[:render_score_limit] + @assign_score_limit = limits[:assign_score_limit] + @cumulative_render_score_limit = limits[:cumulative_render_score_limit] + @cumulative_assign_score_limit = limits[:cumulative_assign_score_limit] + @cumulative_render_score = 0 + @cumulative_assign_score = 0 reset end def increment_render_score(amount) @render_score += amount + @cumulative_render_score += amount raise_limits_reached if @render_score_limit && @render_score > @render_score_limit + raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit end def increment_assign_score(amount) @assign_score += amount + @cumulative_assign_score += amount raise_limits_reached if @assign_score_limit && @assign_score > @assign_score_limit + raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit end # update either render_length or assign_score based on whether or not the writes are captured @@ -47,6 +62,8 @@ module Liquid @reached_limit = false @last_capture_length = nil @render_score = @assign_score = 0 + raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit + raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit end def with_capture diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index c396a3db..a07e7ef2 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.11.0" + VERSION = "5.12.0" end diff --git a/test/integration/template_test.rb b/test/integration/template_test.rb index c01c7161..1ba4bafd 100644 --- a/test/integration/template_test.rb +++ b/test/integration/template_test.rb @@ -179,6 +179,86 @@ class TemplateTest < Minitest::Test assert_equal("すごい", t.render) end + def test_cumulative_render_score_limit_across_render_tags + file_system = StubFileSystem.new( + 'loop' => '{% for a in (1..10) %} foo {% endfor %}', + ) + environment = Liquid::Environment.build(file_system: file_system) + + # Without cumulative limit, all 5 partials render successfully + t = Template.parse( + '{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}', + environment: environment, + ) + unlimited_output = t.render! + total_cumulative = t.resource_limits.cumulative_render_score + + # With cumulative limit set below the total, rendering stops early + t2 = Template.parse( + '{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}', + environment: environment, + ) + t2.resource_limits.cumulative_render_score_limit = total_cumulative / 2 + limited_output = t2.render + assert(t2.resource_limits.reached?) + assert_operator(limited_output.length, :<, unlimited_output.length) + end + + def test_cumulative_render_score_limit_raises_on_render_bang + file_system = StubFileSystem.new( + 'loop' => '{% for a in (1..10) %} foo {% endfor %}', + ) + environment = Liquid::Environment.build(file_system: file_system) + t = Template.parse( + '{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}', + environment: environment, + ) + t.resource_limits.cumulative_render_score_limit = 20 + assert_raises(Liquid::MemoryError) do + t.render! + end + end + + def test_cumulative_assign_score_limit_across_include_tags + file_system = StubFileSystem.new( + 'assign_partial' => '{% assign x = "a long string value here" %}', + ) + environment = Liquid::Environment.build(file_system: file_system) + + # Without cumulative limit, all 5 partials render + t = Template.parse( + '{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}', + environment: environment, + ) + t.render! + total_cumulative = t.resource_limits.cumulative_assign_score + + # With cumulative limit set below the total, rendering stops early + t2 = Template.parse( + '{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}', + environment: environment, + ) + t2.resource_limits.cumulative_assign_score_limit = total_cumulative / 2 + t2.render + assert(t2.resource_limits.reached?) + end + + def test_cumulative_render_score_tracks_across_partials_without_limit + file_system = StubFileSystem.new( + 'loop' => '{% for a in (1..10) %} foo {% endfor %}', + ) + environment = Liquid::Environment.build(file_system: file_system) + t = Template.parse( + '{% render "loop" %}{% render "loop" %}{% render "loop" %}', + environment: environment, + ) + t.render! + assert( + t.resource_limits.cumulative_render_score > t.resource_limits.render_score, + "cumulative should exceed per-template score after multiple partials", + ) + end + def test_default_resource_limits_unaffected_by_render_with_context context = Context.new t = Template.parse("{% for a in (1..100) %}x{% assign foo = 1 %} {% endfor %}") diff --git a/test/unit/resource_limits_unit_test.rb b/test/unit/resource_limits_unit_test.rb new file mode 100644 index 00000000..b5498e36 --- /dev/null +++ b/test/unit/resource_limits_unit_test.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ResourceLimitsUnitTest < Minitest::Test + def test_cumulative_scores_initialize_to_zero + limits = Liquid::ResourceLimits.new({}) + assert_equal(0, limits.cumulative_render_score) + assert_equal(0, limits.cumulative_assign_score) + end + + def test_cumulative_limits_default_to_nil + limits = Liquid::ResourceLimits.new({}) + assert_nil(limits.cumulative_render_score_limit) + assert_nil(limits.cumulative_assign_score_limit) + end + + def test_cumulative_limits_configurable_via_hash + limits = Liquid::ResourceLimits.new( + cumulative_render_score_limit: 500, + cumulative_assign_score_limit: 300, + ) + assert_equal(500, limits.cumulative_render_score_limit) + assert_equal(300, limits.cumulative_assign_score_limit) + end + + def test_cumulative_limits_configurable_via_accessor + limits = Liquid::ResourceLimits.new({}) + limits.cumulative_render_score_limit = 500 + assert_equal(500, limits.cumulative_render_score_limit) + end + + def test_cumulative_scores_survive_reset + limits = Liquid::ResourceLimits.new({}) + limits.increment_render_score(10) + limits.increment_assign_score(5) + + limits.reset + + assert_equal(0, limits.render_score) + assert_equal(0, limits.assign_score) + assert_equal(10, limits.cumulative_render_score) + assert_equal(5, limits.cumulative_assign_score) + end + + def test_cumulative_scores_accumulate_across_resets + limits = Liquid::ResourceLimits.new({}) + limits.increment_render_score(10) + limits.reset + limits.increment_render_score(20) + limits.reset + limits.increment_render_score(30) + + assert_equal(30, limits.render_score) + assert_equal(60, limits.cumulative_render_score) + end + + def test_cumulative_render_score_limit_raises + limits = Liquid::ResourceLimits.new(cumulative_render_score_limit: 25) + limits.increment_render_score(10) + limits.reset + limits.increment_render_score(10) + limits.reset + + assert_raises(Liquid::MemoryError) do + limits.increment_render_score(10) + end + assert(limits.reached?) + end + + def test_cumulative_assign_score_limit_raises + limits = Liquid::ResourceLimits.new(cumulative_assign_score_limit: 15) + limits.increment_assign_score(8) + limits.reset + + assert_raises(Liquid::MemoryError) do + limits.increment_assign_score(8) + end + assert(limits.reached?) + end + + def test_per_template_limits_still_work_with_cumulative + limits = Liquid::ResourceLimits.new( + render_score_limit: 50, + cumulative_render_score_limit: 1000, + ) + assert_raises(Liquid::MemoryError) do + limits.increment_render_score(51) + end + end +end