diff --git a/core/engine/Cargo.toml b/core/engine/Cargo.toml index e6b225ebadf..cc84504c789 100644 --- a/core/engine/Cargo.toml +++ b/core/engine/Cargo.toml @@ -76,6 +76,7 @@ boa_ast.workspace = true boa_parser.workspace = true boa_string.workspace = true cow-utils.workspace = true +futures-lite.workspace = true serde = { workspace = true, features = ["derive", "rc"] } serde_json.workspace = true rand.workspace = true @@ -139,7 +140,6 @@ criterion.workspace = true float-cmp.workspace = true indoc.workspace = true textwrap.workspace = true -futures-lite.workspace = true test-case.workspace = true [target.x86_64-unknown-linux-gnu.dev-dependencies] diff --git a/core/engine/src/context/hooks.rs b/core/engine/src/context/hooks.rs index 4d930b62c65..5fa98c3d18e 100644 --- a/core/engine/src/context/hooks.rs +++ b/core/engine/src/context/hooks.rs @@ -176,7 +176,7 @@ pub trait HostHooks { None } - /// Gets the current UTC time of the host. + /// Gets the current UTC time of the host, in milliseconds since epoch. /// /// Defaults to using [`OffsetDateTime::now_utc`] on all targets, /// which can cause panics if the target doesn't support [`SystemTime::now`][time]. diff --git a/core/engine/src/job.rs b/core/engine/src/job.rs index 8838356b688..e5c9e2a975a 100644 --- a/core/engine/src/job.rs +++ b/core/engine/src/job.rs @@ -3,6 +3,7 @@ //! [`Job`] is an ECMAScript [Job], or a closure that runs an `ECMAScript` computation when //! there's no other computation running. The module defines several type of jobs: //! - [`PromiseJob`] for Promise related jobs. +//! - [`TimeoutJob`] for jobs that run after a certain amount of time. //! - [`NativeAsyncJob`] for jobs that support [`Future`]. //! - [`NativeJob`] for generic jobs that aren't related to Promises. //! @@ -15,7 +16,7 @@ //! - [`IdleJobExecutor`], which is an executor that does nothing, and the default executor if no executor is //! provided. Useful for hosts that want to disable promises. //! - [`SimpleJobExecutor`], which is a simple FIFO queue that runs all jobs to completion, bailing -//! on the first error encountered. +//! on the first error encountered. This simple executor will block on any async job queued. //! //! ## [`Trace`]? //! @@ -29,14 +30,14 @@ //! [JobCallback]: https://tc39.es/ecma262/#sec-jobcallback-records //! [`Gc`]: boa_gc::Gc -use std::{cell::RefCell, collections::VecDeque, fmt::Debug, future::Future, pin::Pin}; - use crate::{ object::{JsFunction, NativeObject}, realm::Realm, Context, JsResult, JsValue, }; use boa_gc::{Finalize, Trace}; +use futures_lite::FutureExt; +use std::{cell::RefCell, collections::VecDeque, fmt::Debug, future::Future, pin::Pin}; /// An ECMAScript [Job Abstract Closure]. /// @@ -115,6 +116,87 @@ impl NativeJob { } } +/// An ECMAScript [Job] that runs after a certain amount of time. +/// +/// This represents the [`HostEnqueueTimeoutJob`] operation from the specification. +/// +/// [HostEnqueueTimeoutJob]: https://tc39.es/ecma262/#sec-hostenqueuetimeoutjob +pub struct TimeoutJob { + /// The instant this job should be run, in msec since epoch. This will be compared + /// to the host's [`HostHooks::utc_now`] method. + timeout: i64, + /// The job to run after the time has passed. + job: NativeJob, +} + +impl Debug for TimeoutJob { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TimeoutJob") + .field("timeout", &self.timeout) + .field("job", &self.job) + .finish() + } +} + +impl TimeoutJob { + /// Create a new `TimeoutJob` with a timeout and a job. + #[must_use] + pub fn new_unchecked(job: NativeJob, timeout: i64) -> Self { + Self { timeout, job } + } + + /// Create a new `TimeoutJob` with a job and a timeout in milliseconds in the future. + #[must_use] + pub fn delayed(job: NativeJob, delay: u64, context: &mut Context) -> Self { + Self::new_unchecked(job, context.host_hooks().utc_now() + (delay as i64)) + } + + /// Creates a new `TimeoutJob` from a closure and a timeout as [`std::time::SystemTime`]. + #[must_use] + pub fn new(f: F, timeout: std::time::SystemTime) -> Self + where + F: FnOnce(&mut Context) -> JsResult + 'static, + { + Self::new_unchecked( + NativeJob::new(f), + timeout + .duration_since(std::time::UNIX_EPOCH) + .expect("Invalid SystemTime") + .as_millis() as i64, + ) + } + + /// Creates a new `TimeoutJob` from a closure, a timeout, and an execution realm. + #[must_use] + pub fn with_realm( + f: F, + timeout: std::time::SystemTime, + realm: Realm, + context: &mut Context, + ) -> Self + where + F: FnOnce(&mut Context) -> JsResult + 'static, + { + Self::new_unchecked( + NativeJob::with_realm(f, realm, context), + timeout + .duration_since(std::time::UNIX_EPOCH) + .expect("Invalid SystemTime") + .as_millis() as i64, + ) + } + + /// Calls the native job with the specified [`Context`]. + /// + /// # Note + /// + /// If the native job has an execution realm defined, this sets the running execution + /// context to the realm's before calling the inner closure, and resets it after execution. + pub fn call(self, context: &mut Context) -> JsResult { + self.job.call(context) + } +} + /// The [`Future`] job returned by a [`NativeAsyncJob`] operation. pub type BoxedFuture<'a> = Pin> + 'a>>; @@ -357,6 +439,10 @@ pub enum Job { /// /// See [`NativeAsyncJob`] for more information. AsyncJob(NativeAsyncJob), + /// A generic job that is to be executed after a number of milliseconds. + /// + /// See [`TimeoutJob`] for more information. + TimeoutJob(TimeoutJob), } impl From for Job { @@ -371,6 +457,12 @@ impl From for Job { } } +impl From for Job { + fn from(job: TimeoutJob) -> Self { + Job::TimeoutJob(job) + } +} + /// An executor of `ECMAscript` [Jobs]. /// /// This is the main API that allows creating custom event loops. @@ -442,6 +534,7 @@ impl JobExecutor for IdleJobExecutor { pub struct SimpleJobExecutor { promise_jobs: RefCell>, async_jobs: RefCell>, + timeout_jobs: RefCell>, } impl Debug for SimpleJobExecutor { @@ -463,37 +556,57 @@ impl JobExecutor for SimpleJobExecutor { match job { Job::PromiseJob(p) => self.promise_jobs.borrow_mut().push_back(p), Job::AsyncJob(a) => self.async_jobs.borrow_mut().push_back(a), + Job::TimeoutJob(t) => self.timeout_jobs.borrow_mut().push(t), } } fn run_jobs(&self, context: &mut Context) -> JsResult<()> { - let context = RefCell::new(context); - loop { - let mut next_job = self.async_jobs.borrow_mut().pop_front(); - while let Some(job) = next_job { - if let Err(err) = pollster::block_on(job.call(&context)) { - self.async_jobs.borrow_mut().clear(); - self.promise_jobs.borrow_mut().clear(); - return Err(err); - }; - next_job = self.async_jobs.borrow_mut().pop_front(); + let now = context.host_hooks().utc_now(); + + // Execute timeout jobs first. We do not execute them in a loop. + self.timeout_jobs.borrow_mut().sort_by_key(|a| a.timeout); + + let i = self + .timeout_jobs + .borrow() + .iter() + .position(|job| job.timeout <= now); + if let Some(i) = i { + let jobs_to_run: Vec<_> = self.timeout_jobs.borrow_mut().drain(..=i).collect(); + for job in jobs_to_run { + job.call(context)?; } + } - // Yeah, I have no idea why Rust extends the lifetime of a `RefCell` that should be immediately - // dropped after calling `pop_front`. - let mut next_job = self.promise_jobs.borrow_mut().pop_front(); - while let Some(job) = next_job { - if let Err(err) = job.call(&mut context.borrow_mut()) { - self.async_jobs.borrow_mut().clear(); - self.promise_jobs.borrow_mut().clear(); - return Err(err); - }; - next_job = self.promise_jobs.borrow_mut().pop_front(); + let context = RefCell::new(context); + loop { + if self.promise_jobs.borrow().is_empty() && self.async_jobs.borrow().is_empty() { + break; } - if self.async_jobs.borrow().is_empty() && self.promise_jobs.borrow().is_empty() { - return Ok(()); + // Block on ALL async jobs running in the queue. We don't block on a single + // job, but loop through them in context. + futures_lite::future::block_on(async { + // Fold all futures into a single future. + self.async_jobs + .borrow_mut() + .drain(..) + .fold(async { Ok(()) }.boxed_local(), |acc, job| { + let context = &context; + async move { + futures_lite::future::try_zip(acc, job.call(context)).await?; + Ok(()) + } + .boxed_local() + }) + .await + })?; + + while let Some(job) = self.promise_jobs.borrow_mut().pop_front() { + job.call(&mut context.borrow_mut())?; } } + + Ok(()) } } diff --git a/core/engine/src/value/integer.rs b/core/engine/src/value/integer.rs index 9c2d84e5bf5..970ce0632f2 100644 --- a/core/engine/src/value/integer.rs +++ b/core/engine/src/value/integer.rs @@ -1,3 +1,4 @@ +use num_traits::{AsPrimitive, FromPrimitive}; use std::cmp::Ordering; /// Represents the result of the `ToIntegerOrInfinity` operation @@ -21,10 +22,12 @@ impl IntegerOrInfinity { /// /// Panics if `min > max`. #[must_use] - pub fn clamp_finite(self, min: i64, max: i64) -> i64 { + pub fn clamp_finite + FromPrimitive>(self, min: I, max: I) -> I { assert!(min <= max); match self { - Self::Integer(i) => i.clamp(min, max), + Self::Integer(i) => { + I::from_i64(i.clamp(min.as_(), max.as_())).expect("`i` should already be clamped") + } Self::PositiveInfinity => max, Self::NegativeInfinity => min, } diff --git a/core/runtime/src/interval.rs b/core/runtime/src/interval.rs new file mode 100644 index 00000000000..6667a5b6dfe --- /dev/null +++ b/core/runtime/src/interval.rs @@ -0,0 +1,210 @@ +//! A module that declares any functions for dealing with intervals or +//! timeouts. + +use boa_engine::job::{NativeJob, TimeoutJob}; +use boa_engine::object::builtins::JsFunction; +use boa_engine::value::IntegerOrInfinity; +use boa_engine::{js_error, js_string, Context, Finalize, JsData, JsResult, JsValue, Trace}; +use boa_gc::{Gc, GcRefCell}; +use boa_interop::{IntoJsFunctionCopied, JsRest}; +use std::collections::HashSet; + +#[cfg(test)] +mod tests; + +/// The internal state of the interval module. The value is whether the interval +/// function is still active. +#[derive(Default, Trace, Finalize, JsData)] +struct IntervalInnerState { + active_map: HashSet, + next_id: u32, +} + +impl IntervalInnerState { + /// Get the interval handler map from the context, or add it to the context if not + /// present. + fn from_context(context: &mut Context) -> Gc> { + if !context.has_data::>>() { + context.insert_data(Gc::new(GcRefCell::new(Self::default()))); + } + + context + .get_data::>>() + .expect("Should have inserted.") + .clone() + } + + /// Get whether an interval is still active. + #[inline] + fn is_interval_valid(&self, id: u32) -> bool { + self.active_map.contains(&id) + } + + /// Create an interval ID, insert it in the active map and return it. + fn new_interval(&mut self) -> JsResult { + if self.next_id == u32::MAX { + return Err(js_error!(Error: "Interval ID overflow")); + } + self.next_id += 1; + self.active_map.insert(self.next_id); + Ok(self.next_id) + } + + /// Delete an interval ID from the active map. + fn clear_interval(&mut self, id: u32) { + self.active_map.remove(&id); + } +} + +/// Inner handler function for handling intervals and timeout. +#[allow(clippy::too_many_arguments)] +fn handle( + handler_map: Gc>, + id: u32, + function_ref: JsFunction, + args: Vec, + reschedule: Option, + context: &mut Context, +) -> JsResult { + // Check if it's still valid. + if !handler_map.borrow().is_interval_valid(id) { + return Ok(JsValue::undefined()); + } + + // Call the handler function. + // The spec says we should still reschedule an interval even if the function + // throws an error. + let result = function_ref.call(&JsValue::undefined(), &args, context); + if let Some(delay) = reschedule { + if handler_map.borrow().is_interval_valid(id) { + let job = TimeoutJob::delayed( + NativeJob::new(move |context| { + handle(handler_map, id, function_ref, args, reschedule, context) + }), + delay, + context, + ); + context.enqueue_job(job.into()); + } + return result; + } + + handler_map.borrow_mut().clear_interval(id); + Ok(JsValue::undefined()) +} + +/// Set a timeout to call the given function after the given delay. +/// The `code` version of this function is not supported at the moment. +/// +/// See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout). +/// +/// # Errors +/// Any errors when trying to read the context, converting the arguments or +/// enqueuing the job. +pub fn set_timeout( + function_ref: JsFunction, + delay_in_msec: Option, + rest: JsRest<'_>, + context: &mut Context, +) -> JsResult { + let handler_map = IntervalInnerState::from_context(context); + let id = handler_map.borrow_mut().new_interval()?; + + // Spec says if delay is not a number, it should be equal to 0. + let delay = delay_in_msec + .unwrap_or_default() + .to_integer_or_infinity(context) + .unwrap_or(IntegerOrInfinity::Integer(0)); + // The spec converts the delay to a 32-bit signed integer. + let delay = u64::from(delay.clamp_finite(0, u32::MAX)); + + // Get ownership of rest arguments. + let rest = rest.to_vec(); + + let job = TimeoutJob::delayed( + NativeJob::new(move |context| handle(handler_map, id, function_ref, rest, None, context)), + delay, + context, + ); + context.enqueue_job(job.into()); + + Ok(id) +} + +/// Call a given function on an interval with the given delay. +/// The `code` version of this function is not supported at the moment. +/// +/// See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setInterval). +/// +/// # Errors +/// Any errors when trying to read the context, converting the arguments or +/// enqueuing the job. +pub fn set_interval( + function_ref: JsFunction, + delay_in_msec: Option, + rest: JsRest<'_>, + context: &mut Context, +) -> JsResult { + let handler_map = IntervalInnerState::from_context(context); + let id = handler_map.borrow_mut().new_interval()?; + + // Spec says if delay is not a number, it should be equal to 0. + let delay = delay_in_msec + .unwrap_or_default() + .to_integer_or_infinity(context) + .unwrap_or(IntegerOrInfinity::Integer(0)); + let delay = u64::from(delay.clamp_finite(0, u32::MAX)); + + // Get ownership of rest arguments. + let rest = rest.to_vec(); + + let job = TimeoutJob::delayed( + NativeJob::new(move |context| { + handle(handler_map, id, function_ref, rest, Some(delay), context) + }), + delay, + context, + ); + context.enqueue_job(job.into()); + + Ok(id) +} + +/// Clears a timeout or interval currently running. +/// +/// See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/clearTimeout). +/// +/// Please note that this is the same exact method as `clearInterval`, as both can be +/// used interchangeably. +pub fn clear_timeout(id: u32, context: &mut Context) { + let handler_map = IntervalInnerState::from_context(context); + handler_map.borrow_mut().clear_interval(id); +} + +/// Register the interval module into the given context. +/// +/// # Errors +/// Any error returned by the context when registering the global functions. +pub fn register(context: &mut Context) -> JsResult<()> { + register_functions(context) +} + +/// Register the interval module without any clock. This still needs the proper +/// typing for the clock, even if it is not registerd to the context. +/// +/// # Errors +/// Any error returned by the context when registering the global functions. +pub fn register_functions(context: &mut Context) -> JsResult<()> { + let set_timeout_ = set_timeout.into_js_function_copied(context); + context.register_global_callable(js_string!("setTimeout"), 1, set_timeout_)?; + + let set_interval_ = set_interval.into_js_function_copied(context); + context.register_global_callable(js_string!("setInterval"), 1, set_interval_)?; + + // These two methods are identical, just under different names in JavaScript. + let clear_timeout_ = clear_timeout.into_js_function_copied(context); + context.register_global_callable(js_string!("clearTimeout"), 1, clear_timeout_.clone())?; + context.register_global_callable(js_string!("clearInterval"), 1, clear_timeout_)?; + + Ok(()) +} diff --git a/core/runtime/src/interval/tests.rs b/core/runtime/src/interval/tests.rs new file mode 100644 index 00000000000..8405422889d --- /dev/null +++ b/core/runtime/src/interval/tests.rs @@ -0,0 +1,216 @@ +use crate::interval; +use crate::test::{run_test_actions_with, TestAction}; +use boa_engine::context::{ContextBuilder, HostHooks}; +use boa_engine::{js_str, Context}; +use std::cell::RefCell; +use std::rc::Rc; + +/// A simple clock that can be used for testing. +#[derive(Clone)] +struct TestClockHooks { + time: Rc>, +} + +impl Default for TestClockHooks { + fn default() -> Self { + Self { + time: Rc::new(RefCell::new(1_000_000)), + } + } +} + +impl TestClockHooks { + /// Move the clock forwards a number of milliseconds. + fn forward(&self, ms: i64) { + *self.time.borrow_mut() += ms; + } +} + +impl HostHooks for TestClockHooks { + fn utc_now(&self) -> i64 { + *self.time.borrow() + } +} + +fn create_context(hooks: Rc) -> Context { + let mut context = ContextBuilder::default().host_hooks(hooks).build().unwrap(); + interval::register(&mut context).unwrap(); + context +} + +#[test] +fn set_timeout_basic() { + let clock = Rc::new(TestClockHooks::default()); + let context = &mut create_context(clock.clone()); + + run_test_actions_with( + [ + TestAction::run( + r#" + called = false; + setTimeout(() => { called = true; }); + "#, + ), + TestAction::inspect_context(|ctx| { + let called = ctx.global_object().get(js_str!("called"), ctx).unwrap(); + assert_eq!(called.as_boolean(), Some(false)); + + ctx.run_jobs().unwrap(); + + let called = ctx.global_object().get(js_str!("called"), ctx).unwrap(); + assert_eq!(called.as_boolean(), Some(true)); + }), + ], + context, + ); +} + +#[test] +fn set_timeout_cancel() { + let clock = Rc::new(TestClockHooks::default()); + let context = &mut create_context(clock.clone()); + let clock1 = clock.clone(); + let clock2 = clock.clone(); + + run_test_actions_with( + [ + TestAction::run( + r#" + called = false; + id = setTimeout(() => { called = true; }, 100); + "#, + ), + TestAction::inspect_context(|ctx| { + let clock = clock1; + let called = ctx.global_object().get(js_str!("called"), ctx).unwrap(); + assert_eq!(called.as_boolean(), Some(false)); + ctx.run_jobs().unwrap(); + + clock.forward(50); + let called = ctx.global_object().get(js_str!("called"), ctx).unwrap(); + assert_eq!(called.as_boolean(), Some(false)); + ctx.run_jobs().unwrap(); + }), + TestAction::run("clearTimeout(id);"), + TestAction::inspect_context(|ctx| { + let clock = clock2; + clock.forward(100); + let called = ctx.global_object().get(js_str!("called"), ctx).unwrap(); + // Should still be false, as it was cancelled. + assert_eq!(called.as_boolean(), Some(false)); + }), + ], + context, + ); +} + +#[test] +fn set_timeout_delay() { + let clock = Rc::new(TestClockHooks::default()); + let context = &mut create_context(clock.clone()); + + run_test_actions_with( + [ + TestAction::run( + r#" + called = false; + setTimeout(() => { called = true; }, 100); + "#, + ), + TestAction::inspect_context(move |ctx| { + // As long as the clock isn't updated, `called` will always be false. + for _ in 0..5 { + let called = ctx.global_object().get(js_str!("called"), ctx).unwrap(); + assert_eq!(called.as_boolean(), Some(false)); + ctx.run_jobs().unwrap(); + } + + // Move forward 50 milliseconds, `called` should still be false. + clock.forward(50); + ctx.run_jobs().unwrap(); + let called = ctx.global_object().get(js_str!("called"), ctx).unwrap(); + assert_eq!(called.as_boolean(), Some(false)); + + clock.forward(50); + ctx.run_jobs().unwrap(); + let called = ctx.global_object().get(js_str!("called"), ctx).unwrap(); + assert_eq!(called.as_boolean(), Some(true)); + }), + ], + context, + ); +} + +#[test] +fn set_interval_delay() { + let clock = Rc::new(TestClockHooks::default()); + let context = &mut create_context(clock.clone()); + let clock1 = clock.clone(); // For the first test. + let clock2 = clock.clone(); // For the first test. + + run_test_actions_with( + [ + TestAction::run( + r#" + called = 0; + id = setInterval(() => { called++; }, 100); + "#, + ), + TestAction::inspect_context(|ctx| { + let clock = clock1; + // As long as the clock isn't updated, `called` will always be 0. + for _ in 0..5 { + let called = ctx.global_object().get(js_str!("called"), ctx).unwrap(); + assert_eq!(called.as_i32(), Some(0)); + ctx.run_jobs().unwrap(); + } + + // Move forward 50 milliseconds. + clock.forward(50); + ctx.run_jobs().unwrap(); + let called = ctx.global_object().get(js_str!("called"), ctx).unwrap(); + assert_eq!(called.as_i32(), Some(0)); + + // Move forward 50 milliseconds. + clock.forward(50); + ctx.run_jobs().unwrap(); + let called = ctx.global_object().get(js_str!("called"), ctx).unwrap(); + assert_eq!(called.as_i32(), Some(1)); + + // Move forward 50 milliseconds. + clock.forward(50); + ctx.run_jobs().unwrap(); + let called = ctx.global_object().get(js_str!("called"), ctx).unwrap(); + assert_eq!(called.as_i32(), Some(1)); + + // Move forward 50 milliseconds. + clock.forward(50); + ctx.run_jobs().unwrap(); + let called = ctx.global_object().get(js_str!("called"), ctx).unwrap(); + assert_eq!(called.as_i32(), Some(2)); + + // Move forward 500 milliseconds, should only be called once. + clock.forward(500); + ctx.run_jobs().unwrap(); + let called = ctx.global_object().get(js_str!("called"), ctx).unwrap(); + assert_eq!(called.as_i32(), Some(3)); + }), + // Cancel + TestAction::run("clearInterval(id);"), + TestAction::inspect_context(move |ctx| { + let clock = clock2; + // Doesn't matter how long, this should not be called ever again. + clock.forward(500); + ctx.run_jobs().unwrap(); + let called = ctx.global_object().get(js_str!("called"), ctx).unwrap(); + assert_eq!(called.as_i32(), Some(3)); + + clock.forward(500); + ctx.run_jobs().unwrap(); + let called = ctx.global_object().get(js_str!("called"), ctx).unwrap(); + assert_eq!(called.as_i32(), Some(3)); + }), + ], + context, + ); +} diff --git a/core/runtime/src/lib.rs b/core/runtime/src/lib.rs index 96a35626b7f..f2f7b5eb9ac 100644 --- a/core/runtime/src/lib.rs +++ b/core/runtime/src/lib.rs @@ -63,6 +63,8 @@ pub use text::{TextDecoder, TextEncoder}; pub mod url; +pub mod interval; + /// Options used when registering all built-in objects and functions of the `WebAPI` runtime. #[derive(Debug)] pub struct RegisterOptions { @@ -109,6 +111,8 @@ pub fn register( #[cfg(feature = "url")] url::Url::register(ctx)?; + interval::register(ctx)?; + Ok(()) } @@ -120,10 +124,8 @@ pub(crate) mod test { /// A test action executed in a test function. #[allow(missing_debug_implementations)] - #[derive(Clone)] pub(crate) struct TestAction(Inner); - #[derive(Clone)] #[allow(dead_code)] enum Inner { RunHarness, @@ -131,7 +133,7 @@ pub(crate) mod test { source: Cow<'static, str>, }, InspectContext { - op: fn(&mut Context), + op: Box, }, Assert { source: Cow<'static, str>, @@ -169,8 +171,8 @@ pub(crate) mod test { /// Executes `op` with the currently active context. /// /// Useful to make custom assertions that must be done from Rust code. - pub(crate) fn inspect_context(op: fn(&mut Context)) -> Self { - Self(Inner::InspectContext { op }) + pub(crate) fn inspect_context(op: impl FnOnce(&mut Context) + 'static) -> Self { + Self(Inner::InspectContext { op: Box::new(op) }) } }