diff --git a/core/engine/src/context/mod.rs b/core/engine/src/context/mod.rs index 5abd2d418c4..f3a145432db 100644 --- a/core/engine/src/context/mod.rs +++ b/core/engine/src/context/mod.rs @@ -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; @@ -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>) { + self.vm.interrupt = flag; + } + /// Returns `true` if this context can be suspended by an `Atomics.wait` call. #[inline] #[must_use] diff --git a/core/engine/src/error/mod.rs b/core/engine/src/error/mod.rs index 1fc5c934c51..827b7190f19 100644 --- a/core/engine/src/error/mod.rs +++ b/core/engine/src/error/mod.rs @@ -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 { @@ -390,6 +396,7 @@ impl EngineError { message: err.message, source: err.source.map(|err| Box::new(err.into_erased(context))), }), + EngineError::Interrupted => ErasedEngineError::Interrupted, } } } @@ -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`]. diff --git a/core/engine/src/vm/mod.rs b/core/engine/src/vm/mod.rs index 5f6170b0e68..15324d4643d 100644 --- a/core/engine/src/vm/mod.rs +++ b/core/engine/src/vm/mod.rs @@ -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, @@ -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; @@ -83,6 +93,9 @@ pub struct Vm { pub(crate) pending_exception: Option, pub(crate) runtime_limits: RuntimeLimits, + /// Stops execution at the next loop iteration or function call once set to `true`. + pub(crate) interrupt: Option>, + /// 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, @@ -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(), @@ -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) { @@ -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 diff --git a/core/engine/src/vm/opcode/iteration/loop_ops.rs b/core/engine/src/vm/opcode/iteration/loop_ops.rs index 9824fc9dcca..471dce224b2 100644 --- a/core/engine/src/vm/opcode/iteration/loop_ops.rs +++ b/core/engine/src/vm/opcode/iteration/loop_ops.rs @@ -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; diff --git a/core/engine/src/vm/tests.rs b/core/engine/src/vm/tests.rs index 73213d320ea..d012e6d9021 100644 --- a/core/engine/src/vm/tests.rs +++ b/core/engine/src/vm/tests.rs @@ -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; @@ -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() { @@ -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); + +/// Creates a context whose global `interrupt()` sets the returned interrupt flag. +fn interruptible_context() -> (Context, Arc) { + 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( diff --git a/ffi/boa_ffi/src/lib.rs b/ffi/boa_ffi/src/lib.rs index 3a738b815b3..47d1dbcb3f2 100644 --- a/ffi/boa_ffi/src/lib.rs +++ b/ffi/boa_ffi/src/lib.rs @@ -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")),