@@ -51,6 +51,14 @@ pub const DEFAULT_MAX_SCRIPT_STRING_BYTES: usize = 256 * 1024;
5151/// intern tables. It is not an operating-system process memory limit and does
5252/// not account for opaque trusted Rust adapter allocations.
5353pub const DEFAULT_MAX_SCRIPT_HEAP_BYTES : usize = 8 * 1024 * 1024 ;
54+ /// Default maximum live operand values in one Splash VM thread.
55+ ///
56+ /// This bounds the VM execution stack independently from retained heap data.
57+ pub const DEFAULT_MAX_SCRIPT_STACK_VALUES : usize = 32 * 1024 ;
58+ /// Default maximum active Splash VM call frames, including the root frame.
59+ ///
60+ /// This bounds recursive execution state independently from instruction fuel.
61+ pub const DEFAULT_MAX_SCRIPT_CALL_FRAMES : usize = 1_024 ;
5462const FORMAT_OUTPUT_MULTIPLIER : usize = 4 ;
5563/// Maximum formatted output size under the default source budget.
5664pub const DEFAULT_MAX_FORMATTED_SOURCE_BYTES : usize =
@@ -132,6 +140,14 @@ pub struct ExecutionLimits {
132140 /// construction fails when it is below the required baseline. Hosts that
133141 /// need OS-level containment must layer it separately.
134142 pub max_heap_bytes : usize ,
143+ /// Maximum live operand values in the VM execution stack.
144+ ///
145+ /// This does not bound native Rust stacks or opaque host adapter state.
146+ pub max_stack_values : usize ,
147+ /// Maximum active VM call frames, including the root evaluation frame.
148+ ///
149+ /// This limits recursive script calls but does not bound host recursion.
150+ pub max_call_frames : usize ,
135151 pub max_syntax_tokens : usize ,
136152 pub max_syntax_nesting : usize ,
137153 pub instruction_limit : usize ,
@@ -146,6 +162,8 @@ impl Default for ExecutionLimits {
146162 max_source_bytes : DEFAULT_MAX_SOURCE_BYTES ,
147163 max_string_bytes : DEFAULT_MAX_SCRIPT_STRING_BYTES ,
148164 max_heap_bytes : DEFAULT_MAX_SCRIPT_HEAP_BYTES ,
165+ max_stack_values : DEFAULT_MAX_SCRIPT_STACK_VALUES ,
166+ max_call_frames : DEFAULT_MAX_SCRIPT_CALL_FRAMES ,
149167 max_syntax_tokens : DEFAULT_MAX_SYNTAX_TOKENS ,
150168 max_syntax_nesting : DEFAULT_MAX_SYNTAX_NESTING ,
151169 instruction_limit : DEFAULT_INSTRUCTION_LIMIT ,
@@ -173,6 +191,16 @@ impl ExecutionLimits {
173191 "max_heap_bytes must be greater than zero" ,
174192 ) ) ;
175193 }
194+ if self . max_stack_values == 0 {
195+ return Err ( RuntimeError :: InvalidLimits (
196+ "max_stack_values must be greater than zero" ,
197+ ) ) ;
198+ }
199+ if self . max_call_frames == 0 {
200+ return Err ( RuntimeError :: InvalidLimits (
201+ "max_call_frames must be greater than zero" ,
202+ ) ) ;
203+ }
176204 if self . max_syntax_tokens == 0 {
177205 return Err ( RuntimeError :: InvalidLimits (
178206 "max_syntax_tokens must be greater than zero" ,
@@ -1007,6 +1035,7 @@ impl<H: Any, S: Any> Runtime<H, S> {
10071035 // manage several threads, but evaluating new source into a paused
10081036 // frame would make its module/body lifecycle ambiguous.
10091037 vm. bx . threads . set_current_to_first_unpaused_thread ( ) ;
1038+ vm. clear_execution_limit_failures ( ) ;
10101039 Ok ( evaluate_with_limits ( vm, limits, |vm| {
10111040 vm. eval ( vm:: ScriptMod {
10121041 file : "inline.splash" . to_owned ( ) ,
@@ -2183,7 +2212,11 @@ fn evaluate_with_limits(
21832212 limits. budget_sample_interval ,
21842213 ) ) ;
21852214
2186- let value = vm. with_instruction_limit ( limits. instruction_limit , operation) ;
2215+ let value = vm. with_stack_value_limit ( limits. max_stack_values , |vm| {
2216+ vm. with_call_frame_limit ( limits. max_call_frames , |vm| {
2217+ vm. with_instruction_limit ( limits. instruction_limit , operation)
2218+ } )
2219+ } ) ;
21872220 let diagnostics = vm. take_errors ( ) ;
21882221 let suspended = vm. bx . threads . cur_ref ( ) . is_paused ( ) ;
21892222 vm. bx . run_budget = None ;
@@ -5032,6 +5065,32 @@ compute(outer, 2)
50325065 ) ;
50335066 }
50345067
5068+ #[ test]
5069+ fn rejects_zero_stack_value_limit ( ) {
5070+ let limits = ExecutionLimits {
5071+ max_stack_values : 0 ,
5072+ ..ExecutionLimits :: default ( )
5073+ } ;
5074+
5075+ assert_eq ! (
5076+ check_syntax_named( "stack.splash" , "true" , limits) . unwrap_err( ) ,
5077+ RuntimeError :: InvalidLimits ( "max_stack_values must be greater than zero" )
5078+ ) ;
5079+ }
5080+
5081+ #[ test]
5082+ fn rejects_zero_call_frame_limit ( ) {
5083+ let limits = ExecutionLimits {
5084+ max_call_frames : 0 ,
5085+ ..ExecutionLimits :: default ( )
5086+ } ;
5087+
5088+ assert_eq ! (
5089+ check_syntax_named( "frames.splash" , "true" , limits) . unwrap_err( ) ,
5090+ RuntimeError :: InvalidLimits ( "max_call_frames must be greater than zero" )
5091+ ) ;
5092+ }
5093+
50355094 #[ test]
50365095 fn runtime_rejects_a_heap_limit_below_live_vm_storage ( ) {
50375096 let mut runtime = Runtime :: default ( ) ;
@@ -5132,6 +5191,94 @@ compute(outer, 2)
51325191 assert_eq ! ( recovered. value. as_u40( ) , Some ( 2 ) ) ;
51335192 }
51345193
5194+ #[ test]
5195+ fn runtime_bounds_operand_stack_growth_without_catch_recovery ( ) {
5196+ let limits = ExecutionLimits {
5197+ max_stack_values : 1 ,
5198+ ..ExecutionLimits :: default ( )
5199+ } ;
5200+ let mut runtime = Runtime :: with_limits ( ( ) , ( ) , limits) . unwrap ( ) ;
5201+
5202+ let exceeded = runtime. eval ( "try (1 + 2) catch 99" ) . unwrap ( ) ;
5203+
5204+ assert ! ( !exceeded. completed( ) ) ;
5205+ assert ! ( exceeded
5206+ . diagnostics
5207+ . iter( )
5208+ . any( |diagnostic| diagnostic. contains( "operand stack limit exceeded" ) ) ) ;
5209+
5210+ let recovered = runtime. eval ( "2" ) . unwrap ( ) ;
5211+ assert ! ( recovered. completed( ) , "{:?}" , recovered. diagnostics) ;
5212+ assert_eq ! ( recovered. value. as_u40( ) , Some ( 2 ) ) ;
5213+ }
5214+
5215+ #[ test]
5216+ fn runtime_bounds_recursive_call_frames_without_catch_recovery ( ) {
5217+ let limits = ExecutionLimits {
5218+ max_call_frames : 4 ,
5219+ instruction_limit : 1_024 ,
5220+ budget_sample_interval : 64 ,
5221+ ..ExecutionLimits :: default ( )
5222+ } ;
5223+ let mut runtime = Runtime :: with_limits ( ( ) , ( ) , limits) . unwrap ( ) ;
5224+
5225+ let exceeded = runtime
5226+ . eval (
5227+ "fn recurse(value) {\n \
5228+ recurse(value + 1)\n \
5229+ }\n \
5230+ try recurse(0) catch 99",
5231+ )
5232+ . unwrap ( ) ;
5233+
5234+ assert ! ( !exceeded. completed( ) ) ;
5235+ assert ! ( exceeded
5236+ . diagnostics
5237+ . iter( )
5238+ . any( |diagnostic| diagnostic. contains( "call frame limit exceeded" ) ) ) ;
5239+
5240+ let recovered = runtime. eval ( "2" ) . unwrap ( ) ;
5241+ assert ! ( recovered. completed( ) , "{:?}" , recovered. diagnostics) ;
5242+ assert_eq ! ( recovered. value. as_u40( ) , Some ( 2 ) ) ;
5243+ }
5244+
5245+ #[ test]
5246+ fn call_frame_limit_includes_the_root_evaluation_frame ( ) {
5247+ let limits = ExecutionLimits {
5248+ max_call_frames : 1 ,
5249+ ..ExecutionLimits :: default ( )
5250+ } ;
5251+ let mut runtime = Runtime :: with_limits ( ( ) , ( ) , limits) . unwrap ( ) ;
5252+
5253+ let root_only = runtime. eval ( "2" ) . unwrap ( ) ;
5254+ assert ! ( root_only. completed( ) , "{:?}" , root_only. diagnostics) ;
5255+
5256+ let exceeded = runtime
5257+ . eval ( "fn one() {\n 1\n }\n try one() catch 99" )
5258+ . unwrap ( ) ;
5259+ assert ! ( !exceeded. completed( ) ) ;
5260+ assert ! ( exceeded
5261+ . diagnostics
5262+ . iter( )
5263+ . any( |diagnostic| diagnostic. contains( "call frame limit exceeded" ) ) ) ;
5264+ }
5265+
5266+ #[ test]
5267+ fn fresh_evaluation_discards_stale_vm_execution_limit_signals ( ) {
5268+ let mut runtime = Runtime :: default ( ) ;
5269+ runtime. configure ( |vm| {
5270+ vm. with_stack_value_limit ( 1 , |vm| {
5271+ vm. thread_mut ( ) . push_stack_unchecked ( 1 . into ( ) ) ;
5272+ vm. thread_mut ( ) . push_stack_unchecked ( 2 . into ( ) ) ;
5273+ vm. thread_mut ( ) . pop_stack_value ( ) ;
5274+ } ) ;
5275+ } ) ;
5276+
5277+ let evaluation = runtime. eval ( "2" ) . unwrap ( ) ;
5278+ assert ! ( evaluation. completed( ) , "{:?}" , evaluation. diagnostics) ;
5279+ assert_eq ! ( evaluation. value. as_u40( ) , Some ( 2 ) ) ;
5280+ }
5281+
51355282 #[ test]
51365283 fn heap_limit_bounds_total_string_storage_after_individual_strings_pass ( ) {
51375284 let mut runtime = Runtime :: default ( ) ;
0 commit comments