Skip to content

Commit 4002df6

Browse files
committed
Bound Splash VM stack and call frames
1 parent 58f80fb commit 4002df6

21 files changed

Lines changed: 443 additions & 37 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ and keeps UI support optional rather than making UI the language boundary.
4141
- Default runtime and capability-host evaluation that rejects noncanonical
4242
Makepad compatibility syntax before a tool can run.
4343
- A bounded evaluator with source, individual-string, tracked Splash-owned
44-
retained-heap, instruction, and deadline limits. The heap ceiling is not an
45-
OS process-memory quota and excludes opaque trusted Rust adapter allocations.
44+
retained-heap, VM operand-stack, active-call-frame, instruction, and deadline
45+
limits. These VM ceilings are not an OS process-memory quota and exclude
46+
opaque trusted Rust adapter allocations.
4647
- Direct `Runtime` JSON conversion: strict, byte- and depth-bounded
4748
`.parse_json()` input plus cycle-aware, byte- and depth-bounded `.to_json()`
4849
output, with ordinary script errors rather than unbounded VM work.

‎SECURITY.md‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,16 +101,24 @@ strings, arrays, object storage, slot tables, and intern tables. Sparse array
101101
and object writes, plus conservative object-map rehashes, are rejected before
102102
they request their backing allocation; other normal script allocations raise
103103
the same uncatchable hard resource failure when retained storage crosses the
104-
cap. This is not a process allocator quota: VM parser/code/stack work,
105-
allocator metadata, compiled regex internals, and opaque trusted Rust adapter
106-
allocations remain outside the accounting.
104+
cap. This heap-only accounting is not a process allocator quota: VM parser/code
105+
storage, other VM control vectors, allocator metadata, compiled regex internals,
106+
and opaque trusted Rust adapter allocations remain outside it.
107+
108+
`ExecutionLimits::max_stack_values` caps live VM operand values at 32,768 by
109+
default, and `ExecutionLimits::max_call_frames` caps active VM call frames,
110+
including the root frame, at 1,024 by default. Both terminate the current
111+
evaluation as uncatchable hard resource failures. They do not account for
112+
native Rust stacks, parser or code storage, other VM control vectors, allocator
113+
metadata, or opaque trusted adapter allocations.
107114
Targets that need process-wide memory or effect containment must layer an
108115
operating-system boundary around the worker.
109116

110117
Canonical `try/catch` handles ordinary script and native-binding errors and
111118
unwinds Splash function calls, but it is not a sandbox or transaction. It
112-
cannot catch string-allocation, heap-allocation, instruction-limit, or hard-deadline
113-
termination, inspect an error object, widen a capability lease, refund a call,
119+
cannot catch string-allocation, heap-allocation, operand-stack, call-frame,
120+
instruction-limit, or hard-deadline termination, inspect an error object, widen a
121+
capability lease, refund a call,
114122
erase an audit outcome, or bypass a workflow data contract. A caught error is
115123
discarded before the fallback runs and does not appear in the evaluation
116124
diagnostics. An uncaught native error is host-facing and may contain

‎crates/splash-capabilities/src/mobile.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,8 @@ mod tests {
375375
max_source_bytes: 32 * 1024,
376376
max_string_bytes: 32 * 1024,
377377
max_heap_bytes: 2 * 1024 * 1024,
378+
max_stack_values: 4 * 1024,
379+
max_call_frames: 256,
378380
max_syntax_tokens: 4 * 1024,
379381
max_syntax_nesting: 64,
380382
instruction_limit: DEFAULT_INSTRUCTION_LIMIT,

‎crates/splash-cli/src/main.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@ fn profile_output() -> JsonValue {
244244
"evaluation_limits": {
245245
"string_bytes": limits.max_string_bytes,
246246
"heap_bytes": limits.max_heap_bytes,
247+
"stack_values": limits.max_stack_values,
248+
"call_frames": limits.max_call_frames,
247249
"instruction_limit": limits.instruction_limit,
248250
"soft_timeout_ms": soft_timeout_ms,
249251
"hard_timeout_ms": hard_timeout_ms,
@@ -1170,6 +1172,14 @@ mod tests {
11701172
output["evaluation_limits"]["heap_bytes"],
11711173
json!(limits.max_heap_bytes)
11721174
);
1175+
assert_eq!(
1176+
output["evaluation_limits"]["stack_values"],
1177+
json!(limits.max_stack_values)
1178+
);
1179+
assert_eq!(
1180+
output["evaluation_limits"]["call_frames"],
1181+
json!(limits.max_call_frames)
1182+
);
11731183
assert_eq!(
11741184
output["preflight_limits"]["source_bytes"],
11751185
json!(limits.max_source_bytes)

‎crates/splash-core/src/lib.rs‎

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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.
5353
pub 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;
5462
const FORMAT_OUTPUT_MULTIPLIER: usize = 4;
5563
/// Maximum formatted output size under the default source budget.
5664
pub 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() {\n1\n}\ntry 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();

‎crates/splash-workflow/src/mobile.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,8 @@ mod tests {
722722
max_source_bytes: 32 * 1024,
723723
max_string_bytes: 32 * 1024,
724724
max_heap_bytes: 2 * 1024 * 1024,
725+
max_stack_values: 4 * 1024,
726+
max_call_frames: 256,
725727
max_syntax_tokens: 4 * 1024,
726728
max_syntax_nesting: 64,
727729
instruction_limit: DEFAULT_INSTRUCTION_LIMIT,

‎docs/development.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -296,17 +296,18 @@ catalogs only: the target never starts stdio, reads the URI, resolves modules,
296296
evaluates Splash, creates a capability host, or invokes an adapter.
297297
`execution` starts a fresh, capability-free runtime for each syntactically
298298
accepted input with an 8 KiB source cap, 1,024-token cap, 64-level nesting
299-
cap, 8 KiB individual-string cap, 4,096 instruction cap, one-instruction
300-
deadline sampling, and a 32 ms terminal execution deadline. Script-level errors from
299+
cap, 8 KiB individual-string cap, 1,024 live operand values, 256 active call
300+
frames, 4,096 instruction cap, one-instruction deadline sampling, and a 32 ms
301+
terminal execution deadline. Script-level errors from
301302
unavailable modules are expected. It creates `Runtime<(), ()>`, so no
302303
capability or Rust adapter can run; a panic or hang is a fuzz failure.
303304
`execution` explicitly collects its fresh VM after evaluation so retained heap
304305
state cannot mask resource behavior. Their tracked `.splash` seeds cover
305306
canonical dataflow, deferred tools, loops, lambdas, recoverable error control
306307
flow, intentional instruction-limit behavior, and exponential string growth.
307-
`execution_limits` rotates valid source, individual-string, syntax,
308-
instruction, sampling, and deadline profiles through a fresh capability-free
309-
runtime. Equal soft and hard deadlines must terminate rather than leave a
308+
`execution_limits` rotates valid source, individual-string, retained-heap,
309+
operand-stack, call-frame, syntax, instruction, sampling, and deadline profiles
310+
through a fresh capability-free runtime. Equal soft and hard deadlines must terminate rather than leave a
310311
resumable evaluation. Its cooperative one-nanosecond soft-budget profile may
311312
yield, but it must then refuse a later `set_limits` request so the continuation
312313
keeps its original resource contract. A completed evaluation must accept the

‎docs/grammar.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,9 @@ to disambiguate it from the separator.
173173

174174
The language deliberately exposes no error object, message, stack, or pattern
175175
binding to the fallback. Assertions, type and lookup errors, denied or failed
176-
tool calls, and a failed deferred tool after `await()` are catchable. An
177-
instruction-limit stop, hard evaluation deadline, or internal VM bail is not
176+
tool calls, and a failed deferred tool after `await()` are catchable. A hard
177+
string-allocation, heap-allocation, operand-stack, call-frame,
178+
instruction-limit, hard evaluation deadline, or internal VM bail is not
178179
catchable.
179180

180181
Recovery is control flow, not a transaction. It does not roll back a Rust

‎docs/language-profile.md‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ The default preflight budget is 256 KiB of source, 32,768 lexical tokens, and
4949
`ExecutionLimits`. Evaluation additionally limits each newly constructed
5050
script string to 256 KiB by default through `ExecutionLimits::max_string_bytes`
5151
and tracked retained Splash VM storage to 8 MiB by default through
52-
`ExecutionLimits::max_heap_bytes`.
52+
`ExecutionLimits::max_heap_bytes`. It also caps live VM operand values at
53+
32,768 through `ExecutionLimits::max_stack_values` and active VM call frames,
54+
including the root frame, at 1,024 through
55+
`ExecutionLimits::max_call_frames`.
5356

5457
`splash format <file>` applies the same profile and compatibility checks, then
5558
writes canonical whitespace to standard output without evaluating source or
@@ -330,8 +333,12 @@ Makepad VM owns its upstream JSON behavior. Separately,
330333
conversion or JSON reconstruction. Exceeding that per-string limit is an
331334
uncatchable resource failure. `ExecutionLimits::max_heap_bytes` separately
332335
caps retained script strings, arrays, object storage, slots, and intern tables;
333-
it is still not a process-memory, code/stack, opaque-adapter, or aggregate
334-
workflow-data quota.
336+
`ExecutionLimits::max_stack_values` separately caps live operand values, and
337+
`ExecutionLimits::max_call_frames` caps active VM call frames, including the
338+
root evaluation frame. Each is an uncatchable resource failure and both reset
339+
cleanly for a later evaluation. These are still not process-memory, parser or
340+
code-storage, native-Rust-stack, opaque-adapter, other VM-control-vector, or
341+
aggregate-workflow-data quotas.
335342

336343
Hosts can register a `JsonToolContract` to enforce bounded schemas for those
337344
JSON envelopes. Contract checks run before the handler and before output
@@ -423,7 +430,8 @@ or mutate its keys, input digest, worker observation, or restart policy.
423430
- Import `mod.tool` before calling a tool.
424431
- Use canonical `try protected catch fallback` for bounded local recovery. The
425432
fallback cannot inspect the error, and hard string-allocation,
426-
heap-allocation, instruction/deadline termination remains uncatchable. End each block branch
433+
heap-allocation, operand-stack, call-frame, instruction, or deadline
434+
termination remains uncatchable. End each block branch
427435
with a value-producing expression; use `nil` when the branch has no other
428436
result. Parenthesize a record literal used as the whole protected or fallback
429437
branch.

‎docs/positioning.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,10 @@ the Rust application that already owns the device or service.
9191
There are three distinct boundaries. They must not be conflated.
9292

9393
1. The canonical grammar and evaluator bound source, syntax work, individual
94-
string construction, tracked retained Splash VM storage, instructions, and
95-
evaluation time. They reduce interpreter resource risk; the heap bound is
96-
not a process-wide allocator quota and they do not authorize effects.
94+
string construction, tracked retained Splash VM storage, live operand-stack
95+
values, active call frames, instructions, and evaluation time. They reduce
96+
interpreter resource risk; these VM bounds are not a process-wide allocator
97+
quota and they do not authorize effects.
9798
2. The capability runtime registers a finite catalog, validates every dynamic
9899
tool name at reservation time, and uses approval-bound leases that survive
99100
`await`. Approval-bound workflow data can influence a call but cannot widen
@@ -120,9 +121,10 @@ target:
120121
1. Freeze a tool catalog and test that generated scripts cannot widen it,
121122
including across deferred completion and workflow resume.
122123
2. Define bounded input, output, pending-work, source, individual-string,
123-
tracked aggregate-heap, instruction, and event-retention budgets from the target
124-
device's memory and latency limits, including the aggregate dataflow context
125-
and contract schemas when workflows pass JSON results.
124+
tracked aggregate-heap, operand-stack, call-frame, instruction, and
125+
event-retention budgets from the target device's memory and latency limits,
126+
including the aggregate dataflow context and contract schemas when workflows
127+
pass JSON results.
126128
3. Run effectful adapters in a target-specific containment backend, or prove
127129
that each in-process adapter has no harmful ambient authority.
128130
4. Provide a durable checkpoint, idempotency, and reconciliation policy for

0 commit comments

Comments
 (0)