Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions core/engine/src/context/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! The ECMAScript context.

use std::any::Any;
use std::sync::{Arc, atomic::AtomicBool};
use std::{cell::Cell, path::Path, rc::Rc};

use boa_ast::StatementList;
Expand Down Expand Up @@ -603,6 +604,40 @@ impl Context {
&mut self.vm.runtime_limits
}

/// Sets the flag that interrupts execution, or removes it with `None`.
///
/// While the flag is `true`, every loop iteration and function call fails with
/// [`EngineError::Interrupted`], which ECMAScript code cannot catch. The flag can be set
/// from another thread while this context is running. The engine never resets it, so store
/// `false` to run code in this context again.
///
/// The flag is read with [`Ordering::Relaxed`](std::sync::atomic::Ordering::Relaxed) and
/// does not synchronize other data. A native function that does not call back into
/// ECMAScript code runs to completion before the flag is checked.
///
/// [`EngineError::Interrupted`]: crate::error::EngineError::Interrupted
///
/// # Example
///
/// ```
/// # use boa_engine::{Context, Source, error::EngineError};
/// # use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
/// let mut context = Context::default();
/// let interrupt = Arc::new(AtomicBool::new(false));
/// context.set_interrupt_flag(Some(interrupt.clone()));
///
/// // Another thread, such as a timeout handler, can store `true` while a script is running.
/// interrupt.store(true, Ordering::Relaxed);
/// let error = context
/// .eval(Source::from_bytes("try { while (true) {} } catch {}"))
/// .unwrap_err();
/// assert_eq!(error.as_engine(), Some(&EngineError::Interrupted));
/// ```
#[inline]
pub fn set_interrupt_flag(&mut self, flag: Option<Arc<AtomicBool>>) {
self.vm.interrupt = flag;
}

/// Returns `true` if this context can be suspended by an `Atomics.wait` call.
#[inline]
#[must_use]
Expand Down
11 changes: 11 additions & 0 deletions core/engine/src/error/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,12 @@ pub enum EngineError {
/// Error thrown when an internal panic condition is encountered.
#[error("EnginePanic: {0}")]
Panic(#[from] PanicError),

/// Error thrown when execution is stopped by the interrupt flag.
///
/// See [`Context::set_interrupt_flag`].
#[error("InterruptedError: execution was interrupted")]
Interrupted,
}

impl EngineError {
Expand All @@ -390,6 +396,7 @@ impl EngineError {
message: err.message,
source: err.source.map(|err| Box::new(err.into_erased(context))),
}),
EngineError::Interrupted => ErasedEngineError::Interrupted,
}
}
}
Expand Down Expand Up @@ -1590,6 +1597,10 @@ pub enum ErasedEngineError {
/// Error thrown when an internal panic condition is encountered.
#[error("EnginePanic: {0}")]
Panic(#[from] ErasedPanicError),

/// Error thrown when execution is stopped by the interrupt flag.
#[error("InterruptedError: execution was interrupted")]
Interrupted,
}

/// Erased version of [`JsError`].
Expand Down
33 changes: 31 additions & 2 deletions core/engine/src/vm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use crate::{
Context, JsError, JsExpect, JsNativeError, JsObject, JsResult, JsString, JsValue, Module,
builtins::promise::{PromiseCapability, ResolvingFunctions},
environments::EnvironmentStack,
error::RuntimeLimitError,
error::{EngineError, RuntimeLimitError},
object::JsFunction,
realm::Realm,
script::Script,
Expand All @@ -17,7 +17,17 @@ use crate::{
};
use boa_gc::{Finalize, Gc, Trace, custom_trace};
use shadow_stack::ShadowStack;
use std::{future::Future, ops::ControlFlow, path::Path, pin::Pin, task};
use std::{
future::Future,
ops::ControlFlow,
path::Path,
pin::Pin,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
task,
};

#[cfg(feature = "trace")]
use crate::sys::time::Instant;
Expand Down Expand Up @@ -83,6 +93,9 @@ pub struct Vm {
pub(crate) pending_exception: Option<JsError>,
pub(crate) runtime_limits: RuntimeLimits,

/// Stops execution at the next loop iteration or function call once set to `true`.
pub(crate) interrupt: Option<Arc<AtomicBool>>,

/// This is used to assign a native (rust) function as the active function,
/// because we don't push a frame for them.
pub(crate) native_active_function: Option<JsObject>,
Expand Down Expand Up @@ -418,6 +431,7 @@ impl Vm {
return_value: JsValue::undefined(),
pending_exception: None,
runtime_limits: RuntimeLimits::default(),
interrupt: None,
native_active_function: None,
host_call_depth: 0,
shadow_stack: ShadowStack::default(),
Expand All @@ -428,6 +442,19 @@ impl Vm {
}
}

/// Fails with [`EngineError::Interrupted`] once the interrupt flag is set.
#[inline]
pub(crate) fn check_interrupt(&self) -> JsResult<()> {
if self
.interrupt
.as_ref()
.is_some_and(|flag| flag.load(Ordering::Relaxed))
{
return Err(EngineError::Interrupted.into());
}
Ok(())
}

#[track_caller]
#[inline]
pub(crate) fn set_register(&mut self, index: usize, value: JsValue) {
Expand Down Expand Up @@ -1015,6 +1042,8 @@ impl Context {

/// Checks if we haven't exceeded the defined runtime limits.
pub(crate) fn check_runtime_limits(&self) -> JsResult<()> {
self.vm.check_interrupt()?;

// Must throw if the number of recursive calls exceeds the defined limit.
//
// `host_call_depth` accounts for nested host calls that re-enter the VM by invoking
Expand Down
4 changes: 3 additions & 1 deletion core/engine/src/vm/opcode/iteration/loop_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ use crate::{Context, JsResult, vm::opcode::Operation};
/// `IncrementLoopIteration` implements the Opcode Operation for `Opcode::IncrementLoopIteration`.
///
/// Operation:
/// - Increment loop iteration count.
/// - Check the interrupt flag and increment loop iteration count.
#[derive(Debug, Clone, Copy)]
pub(crate) struct IncrementLoopIteration;

impl IncrementLoopIteration {
#[inline(always)]
pub(crate) fn operation((): (), context: &mut Context) -> JsResult<()> {
context.vm.check_interrupt()?;

let max = context.vm.runtime_limits.loop_iteration_limit();
let frame = context.vm.frame_mut();
let previous_iteration_count = frame.loop_iteration_count;
Expand Down
123 changes: 122 additions & 1 deletion core/engine/src/vm/tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::error::RuntimeLimitError;
use crate::error::{EngineError, ErasedEngineError, RuntimeLimitError};
use crate::vm::CallFrame;
use crate::vm::call_frame::CallFrameLocation;
use crate::vm::source_info::SourcePath;
Expand All @@ -7,9 +7,18 @@ use crate::{
property::Attribute, run_test_actions, run_test_actions_with,
};
use boa_ast::Position;
use boa_gc::{Finalize, Trace};
use boa_macros::js_str;
use boa_parser::Source;
use indoc::indoc;
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread,
time::Duration,
};

#[test]
fn typeof_string() {
Expand Down Expand Up @@ -435,6 +444,118 @@ fn recursion_runtime_limit() {
]);
}

/// An atomic flag holds no garbage-collected values.
#[derive(Trace, Finalize)]
struct InterruptFlag(#[unsafe_ignore_trace] Arc<AtomicBool>);

/// Creates a context whose global `interrupt()` sets the returned interrupt flag.
fn interruptible_context() -> (Context, Arc<AtomicBool>) {
let flag = Arc::new(AtomicBool::new(false));
let mut context = Context::default();
context.set_interrupt_flag(Some(flag.clone()));
context
.register_global_builtin_callable(
js_string!("interrupt"),
0,
NativeFunction::from_copy_closure_with_captures(
|_, _, flag, _| {
flag.0.store(true, Ordering::Relaxed);
Ok(JsValue::undefined())
},
InterruptFlag(flag.clone()),
),
)
.unwrap();
(context, flag)
}

#[test]
fn interrupt_flag_stops_loops_and_calls() {
let (mut context, flag) = interruptible_context();
for source in [
"interrupt(); for (let i = 0; i < 10; ++i) { }",
"interrupt(); (function () { })();",
"try { interrupt(); for (let i = 0; i < 10; ++i) { } } catch { }",
"[0, 1].forEach(function (x) { if (x === 0) interrupt(); });",
indoc! {r#"
for (let i = 0; i < 3; ++i) {
(function () {
for (let j = 0; j < 3; ++j) {
if (i === 1 && j === 1) interrupt();
}
})();
}
"#},
] {
flag.store(false, Ordering::Relaxed);
let error = context.eval(Source::from_bytes(source)).unwrap_err();
assert_eq!(
error.as_engine(),
Some(&EngineError::Interrupted),
"{source}"
);
}
}

#[test]
fn interrupt_flag_can_be_reset_or_removed() {
let (mut context, flag) = interruptible_context();
let source = "var a = 0; for (let i = 0; i < 10; ++i) { (function () { a++; })(); } a";

flag.store(true, Ordering::Relaxed);
let error = context.eval(Source::from_bytes(source)).unwrap_err();
assert_eq!(error.as_engine(), Some(&EngineError::Interrupted));
assert_eq!(
EngineError::Interrupted.to_string(),
"InterruptedError: execution was interrupted"
);
let erased = error.into_erased(&mut context);
assert_eq!(erased.as_engine(), Some(&ErasedEngineError::Interrupted));
assert_eq!(
ErasedEngineError::Interrupted.to_string(),
"InterruptedError: execution was interrupted"
);

flag.store(false, Ordering::Relaxed);
assert_eq!(
context.eval(Source::from_bytes(source)).unwrap(),
JsValue::new(10)
);

flag.store(true, Ordering::Relaxed);
context.set_interrupt_flag(None);
assert_eq!(
context.eval(Source::from_bytes(source)).unwrap(),
JsValue::new(10)
);
}

#[test]
fn interrupt_flag_set_from_another_thread_stops_unbounded_code() {
for source in [
"while (true) { }",
"while (true) { (function () { for (let i = 0; i < 100; ++i) { } })(); }",
// Recursion without loops, which stays within the recursion limit.
"function f(n) { if (n > 0) { f(n - 1); f(n - 1); } } f(100);",
] {
let (mut context, flag) = interruptible_context();
let interrupter = {
let flag = flag.clone();
thread::spawn(move || {
thread::sleep(Duration::from_millis(10));
flag.store(true, Ordering::Relaxed);
})
};
let error = context.eval(Source::from_bytes(source)).unwrap_err();
interrupter.join().unwrap();
assert_eq!(
error.as_engine(),
Some(&EngineError::Interrupted),
"{source}"
);
}
}

#[test]
fn arguments_object_constructor_valid_index() {
run_test_actions([TestAction::assert_eq(
Expand Down
1 change: 1 addition & 0 deletions ffi/boa_ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,7 @@ pub mod ffi {
EngineError::from(RuntimeLimitError::LoopIteration),
EngineError::from(RuntimeLimitError::Recursion),
EngineError::from(RuntimeLimitError::StackSize),
EngineError::Interrupted,
EngineError::from(
PanicError::new("engine panic")
.with_source(BoaJsNativeError::error().with_message("source")),
Expand Down
Loading