Skip to content

Commit

Permalink
Implement runtime limits for recursion (#2904)
Browse files Browse the repository at this point in the history
* Implement runtime limits for recursion

* Remove "tail-call-optimization" from ignore list

* Run prettier

* Add example and tests
  • Loading branch information
HalidOdat authored May 5, 2023
1 parent 990e4df commit b4b77e7
Show file tree
Hide file tree
Showing 9 changed files with 196 additions and 11 deletions.
35 changes: 34 additions & 1 deletion boa_cli/src/debug/limits.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use boa_engine::{
object::{FunctionObjectBuilder, ObjectInitializer},
property::Attribute,
Context, JsArgs, JsObject, JsResult, JsValue, NativeFunction,
Context, JsArgs, JsNativeError, JsObject, JsResult, JsValue, NativeFunction,
};

fn get_loop(_: &JsValue, _: &[JsValue], context: &mut Context<'_>) -> JsResult<JsValue> {
Expand All @@ -15,6 +15,22 @@ fn set_loop(_: &JsValue, args: &[JsValue], context: &mut Context<'_>) -> JsResul
Ok(JsValue::undefined())
}

fn get_recursion(_: &JsValue, _: &[JsValue], context: &mut Context<'_>) -> JsResult<JsValue> {
let max = context.runtime_limits().recursion_limit();
Ok(JsValue::from(max))
}

fn set_recursion(_: &JsValue, args: &[JsValue], context: &mut Context<'_>) -> JsResult<JsValue> {
let value = args.get_or_undefined(0).to_length(context)?;
let Ok(value) = value.try_into() else {
return Err(
JsNativeError::range().with_message(format!("Argument {value} greater than usize::MAX")).into()
);
};
context.runtime_limits_mut().set_recursion_limit(value);
Ok(JsValue::undefined())
}

pub(super) fn create_object(context: &mut Context<'_>) -> JsObject {
let get_loop = FunctionObjectBuilder::new(context, NativeFunction::from_fn_ptr(get_loop))
.name("get loop")
Expand All @@ -24,12 +40,29 @@ pub(super) fn create_object(context: &mut Context<'_>) -> JsObject {
.name("set loop")
.length(1)
.build();

let get_recursion =
FunctionObjectBuilder::new(context, NativeFunction::from_fn_ptr(get_recursion))
.name("get recursion")
.length(0)
.build();
let set_recursion =
FunctionObjectBuilder::new(context, NativeFunction::from_fn_ptr(set_recursion))
.name("set recursion")
.length(1)
.build();
ObjectInitializer::new(context)
.accessor(
"loop",
Some(get_loop),
Some(set_loop),
Attribute::WRITABLE | Attribute::CONFIGURABLE | Attribute::NON_ENUMERABLE,
)
.accessor(
"recursion",
Some(get_recursion),
Some(set_recursion),
Attribute::WRITABLE | Attribute::CONFIGURABLE | Attribute::NON_ENUMERABLE,
)
.build()
}
32 changes: 32 additions & 0 deletions boa_engine/src/vm/opcode/call/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ impl Operation for CallEval {
const INSTRUCTION: &'static str = "INST - CallEval";

fn execute(context: &mut Context<'_>) -> JsResult<CompletionType> {
if context.vm.runtime_limits.recursion_limit() <= context.vm.frames.len() {
return Err(JsNativeError::runtime_limit()
.with_message(format!(
"Maximum recursion limit {} exceeded",
context.vm.runtime_limits.recursion_limit()
))
.into());
}
if context.vm.runtime_limits.stack_size_limit() <= context.vm.stack.len() {
return Err(JsNativeError::runtime_limit()
.with_message("Maximum call stack size exceeded")
Expand Down Expand Up @@ -77,6 +85,14 @@ impl Operation for CallEvalSpread {
const INSTRUCTION: &'static str = "INST - CallEvalSpread";

fn execute(context: &mut Context<'_>) -> JsResult<CompletionType> {
if context.vm.runtime_limits.recursion_limit() <= context.vm.frames.len() {
return Err(JsNativeError::runtime_limit()
.with_message(format!(
"Maximum recursion limit {} exceeded",
context.vm.runtime_limits.recursion_limit()
))
.into());
}
if context.vm.runtime_limits.stack_size_limit() <= context.vm.stack.len() {
return Err(JsNativeError::runtime_limit()
.with_message("Maximum call stack size exceeded")
Expand Down Expand Up @@ -143,6 +159,14 @@ impl Operation for Call {
const INSTRUCTION: &'static str = "INST - Call";

fn execute(context: &mut Context<'_>) -> JsResult<CompletionType> {
if context.vm.runtime_limits.recursion_limit() <= context.vm.frames.len() {
return Err(JsNativeError::runtime_limit()
.with_message(format!(
"Maximum recursion limit {} exceeded",
context.vm.runtime_limits.recursion_limit()
))
.into());
}
if context.vm.runtime_limits.stack_size_limit() <= context.vm.stack.len() {
return Err(JsNativeError::runtime_limit()
.with_message("Maximum call stack size exceeded")
Expand Down Expand Up @@ -182,6 +206,14 @@ impl Operation for CallSpread {
const INSTRUCTION: &'static str = "INST - CallSpread";

fn execute(context: &mut Context<'_>) -> JsResult<CompletionType> {
if context.vm.runtime_limits.recursion_limit() <= context.vm.frames.len() {
return Err(JsNativeError::runtime_limit()
.with_message(format!(
"Maximum recursion limit {} exceeded",
context.vm.runtime_limits.recursion_limit()
))
.into());
}
if context.vm.runtime_limits.stack_size_limit() <= context.vm.stack.len() {
return Err(JsNativeError::runtime_limit()
.with_message("Maximum call stack size exceeded")
Expand Down
2 changes: 1 addition & 1 deletion boa_engine/src/vm/opcode/iteration/loop_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ impl Operation for LoopContinue {
cleanup_loop_environment(context);

return Err(JsNativeError::runtime_limit()
.with_message(format!("max loop iteration limit {max} exceeded"))
.with_message(format!("Maximum loop iteration limit {max} exceeded"))
.into());
}
}
Expand Down
16 changes: 16 additions & 0 deletions boa_engine/src/vm/opcode/new/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ impl Operation for New {
const INSTRUCTION: &'static str = "INST - New";

fn execute(context: &mut Context<'_>) -> JsResult<CompletionType> {
if context.vm.runtime_limits.recursion_limit() <= context.vm.frames.len() {
return Err(JsNativeError::runtime_limit()
.with_message(format!(
"Maximum recursion limit {} exceeded",
context.vm.runtime_limits.recursion_limit()
))
.into());
}
if context.vm.runtime_limits.stack_size_limit() <= context.vm.stack.len() {
return Err(JsNativeError::runtime_limit()
.with_message("Maximum call stack size exceeded")
Expand Down Expand Up @@ -55,6 +63,14 @@ impl Operation for NewSpread {
const INSTRUCTION: &'static str = "INST - NewSpread";

fn execute(context: &mut Context<'_>) -> JsResult<CompletionType> {
if context.vm.runtime_limits.recursion_limit() <= context.vm.frames.len() {
return Err(JsNativeError::runtime_limit()
.with_message(format!(
"Maximum recursion limit {} exceeded",
context.vm.runtime_limits.recursion_limit()
))
.into());
}
if context.vm.runtime_limits.stack_size_limit() <= context.vm.stack.len() {
return Err(JsNativeError::runtime_limit()
.with_message("Maximum call stack size exceeded")
Expand Down
17 changes: 17 additions & 0 deletions boa_engine/src/vm/runtime_limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ pub struct RuntimeLimits {

/// Max loop iterations before an error is thrown.
loop_iteration_limit: u64,

/// Max function recursion limit
resursion_limit: usize,
}

impl Default for RuntimeLimits {
#[inline]
fn default() -> Self {
Self {
loop_iteration_limit: u64::MAX,
resursion_limit: 400,
stack_size_limit: 1024,
}
}
Expand Down Expand Up @@ -58,4 +62,17 @@ impl RuntimeLimits {
pub fn set_stack_size_limit(&mut self, value: usize) {
self.stack_size_limit = value;
}

/// Get recursion limit.
#[inline]
#[must_use]
pub const fn recursion_limit(&self) -> usize {
self.resursion_limit
}

/// Set recursion limit before an error is thrown.
#[inline]
pub fn set_recursion_limit(&mut self, value: usize) {
self.resursion_limit = value;
}
}
41 changes: 39 additions & 2 deletions boa_engine/src/vm/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ fn loop_runtime_limit() {
for (let i = 0; i < 20; ++i) { }
"#},
JsNativeErrorKind::RuntimeLimit,
"max loop iteration limit 10 exceeded",
"Maximum loop iteration limit 10 exceeded",
),
TestAction::assert_eq(
indoc! {r#"
Expand All @@ -268,7 +268,44 @@ fn loop_runtime_limit() {
while (1) { }
"#},
JsNativeErrorKind::RuntimeLimit,
"max loop iteration limit 10 exceeded",
"Maximum loop iteration limit 10 exceeded",
),
]);
}

#[test]
fn recursion_runtime_limit() {
run_test_actions([
TestAction::run(indoc! {r#"
function factorial(n) {
if (n == 0) {
return 1;
}
return n * factorial(n - 1);
}
"#}),
TestAction::assert_eq("factorial(8)", JsValue::new(40_320)),
TestAction::assert_eq("factorial(11)", JsValue::new(39_916_800)),
TestAction::inspect_context(|context| {
context.runtime_limits_mut().set_recursion_limit(10);
}),
TestAction::assert_native_error(
"factorial(11)",
JsNativeErrorKind::RuntimeLimit,
"Maximum recursion limit 10 exceeded",
),
TestAction::assert_eq("factorial(8)", JsValue::new(40_320)),
TestAction::assert_native_error(
indoc! {r#"
function x() {
x()
}
x()
"#},
JsNativeErrorKind::RuntimeLimit,
"Maximum recursion limit 10 exceeded",
),
]);
}
47 changes: 42 additions & 5 deletions boa_examples/src/bin/runtime_limits.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
use boa_engine::{Context, Source};
use boa_engine::{Context, JsValue, Source};

fn main() {
// Create the JavaScript context.
let mut context = Context::default();

// -----------------------------------------
// Loop Iteration Limit
// -----------------------------------------

// Set the context's runtime limit on loops to 10 iterations.
context.runtime_limits_mut().set_loop_iteration_limit(10);

Expand All @@ -13,7 +17,7 @@ fn main() {
for (let i = 0; i < 5; ++i) { }
",
));
result.expect("no error should be thrown");
assert!(result.is_ok());

// Here we exceed the limit by 1 iteration and a `RuntimeLimit` error is thrown.
//
Expand All @@ -27,21 +31,54 @@ fn main() {
}
",
));
result.expect_err("should have throw an error");
assert!(result.is_err());

// Preventing an infinity loops
let result = context.eval_script(Source::from_bytes(
r"
while (true) { }
",
));
result.expect_err("should have throw an error");
assert!(result.is_err());

// The limit applies to all types of loops.
let result = context.eval_script(Source::from_bytes(
r"
for (let e of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) { }
",
));
result.expect_err("should have throw an error");
assert!(result.is_err());

// -----------------------------------------
// Recursion Limit
// -----------------------------------------

// Create and register `factorial` function.
let result = context.eval_script(Source::from_bytes(
r"
function factorial(n) {
if (n == 0) {
return 1;
}
return n * factorial(n - 1);
}
",
));
assert!(result.is_ok());

// Run function before setting the limit and assert that it works.
let result = context.eval_script(Source::from_bytes("factorial(11)"));
assert_eq!(result, Ok(JsValue::new(39_916_800)));

// Setting runtime limit for recustion to 10.
context.runtime_limits_mut().set_recursion_limit(10);

// Run without exceeding recursion limit and assert that it works.
let result = context.eval_script(Source::from_bytes("factorial(8)"));
assert_eq!(result, Ok(JsValue::new(40_320)));

// Run exceeding limit by 1 and assert that it fails.
let result = context.eval_script(Source::from_bytes("factorial(11)"));
assert!(result.is_err());
}
16 changes: 15 additions & 1 deletion docs/boa_object.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,5 +253,19 @@ Its setter can be used to set the loop iteration limit.
```javascript
$boa.limits.loop = 10;

while (true) {} // RuntimeLimit: max loop iteration limit 10 exceeded
while (true) {} // RuntimeLimit: Maximum loop iteration limit 10 exceeded
```

### Getter & Setter `$boa.limits.recursion`

This is an accessor property on the module, its getter returns the recursion limit before an error is thrown.
Its setter can be used to set the recursion limit.

```javascript
$boa.limits.recursion = 100;

function x() {
return x();
}
x(); // RuntimeLimit: Maximum recursion limit 100 exceeded
```
1 change: 0 additions & 1 deletion test_ignore.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ features = [
"SharedArrayBuffer",
"resizable-arraybuffer",
"Temporal",
"tail-call-optimization",
"ShadowRealm",
"FinalizationRegistry",
"Atomics",
Expand Down

0 comments on commit b4b77e7

Please sign in to comment.