Skip to content

Commit

Permalink
Implement an internal time type and Clock trait
Browse files Browse the repository at this point in the history
This will add the capacity for the entire engine to mock time during
tests. No impact on performance should be noticeable.

Fixed #4144
  • Loading branch information
hansl committed Jan 27, 2025
1 parent de552ec commit e957201
Show file tree
Hide file tree
Showing 5 changed files with 253 additions and 14 deletions.
15 changes: 6 additions & 9 deletions core/engine/src/builtins/date/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,7 @@ use crate::{
},
BuiltInBuilder, BuiltInConstructor, BuiltInObject, IntrinsicObject,
},
context::{
intrinsics::{Intrinsics, StandardConstructor, StandardConstructors},
HostHooks,
},
context::intrinsics::{Intrinsics, StandardConstructor, StandardConstructors},
error::JsNativeError,
js_string,
object::{internal_methods::get_prototype_from_constructor, JsObject},
Expand Down Expand Up @@ -53,8 +50,8 @@ impl Date {
}

/// Creates a new `Date` from the current UTC time of the host.
pub(crate) fn utc_now(hooks: &dyn HostHooks) -> Self {
Self(hooks.utc_now() as f64)
pub(crate) fn utc_now(context: &mut Context) -> Self {
Self(context.clock().now().millis_since_epoch() as f64)
}
}

Expand Down Expand Up @@ -208,7 +205,7 @@ impl BuiltInConstructor for Date {
// 1. If NewTarget is undefined, then
if new_target.is_undefined() {
// a. Let now be the time value (UTC) identifying the current time.
let now = context.host_hooks().utc_now();
let now = context.clock().now().millis_since_epoch();

// b. Return ToDateString(now).
return Ok(JsValue::from(to_date_string_t(
Expand All @@ -222,7 +219,7 @@ impl BuiltInConstructor for Date {
// 3. If numberOfArgs = 0, then
[] => {
// a. Let dv be the time value (UTC) identifying the current time.
Self::utc_now(context.host_hooks().as_ref())
Self::utc_now(context)
}
// 4. Else if numberOfArgs = 1, then
// a. Let value be values[0].
Expand Down Expand Up @@ -326,7 +323,7 @@ impl Date {
/// [mdn]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/now
#[allow(clippy::unnecessary_wraps)]
pub(crate) fn now(_: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
Ok(JsValue::new(context.host_hooks().utc_now()))
Ok(JsValue::new(context.clock().now().millis_since_epoch()))
}

/// `Date.parse()`
Expand Down
4 changes: 4 additions & 0 deletions core/engine/src/context/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ pub trait HostHooks {
/// which can cause panics if the target doesn't support [`SystemTime::now`][time].
///
/// [time]: std::time::SystemTime::now
#[deprecated(
since = "0.21.0",
note = "Use `context.clock().now().millis_since_epoch()` instead"
)]
fn utc_now(&self) -> i64 {
let now = OffsetDateTime::now_utc();
now.unix_timestamp() * 1000 + i64::from(now.millisecond())
Expand Down
27 changes: 27 additions & 0 deletions core/engine/src/context/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ use crate::{

use self::intrinsics::StandardConstructor;

pub mod time;
use crate::context::time::StdClock;
pub use time::Clock;

mod hooks;
#[cfg(feature = "intl")]
pub(crate) mod icu;
Expand Down Expand Up @@ -113,6 +117,8 @@ pub struct Context {

host_hooks: Rc<dyn HostHooks>,

clock: Rc<dyn Clock>,

job_executor: Rc<dyn JobExecutor>,

module_loader: Rc<dyn ModuleLoader>,
Expand All @@ -137,6 +143,7 @@ impl std::fmt::Debug for Context {
.field("strict", &self.strict)
.field("job_executor", &"JobExecutor")
.field("hooks", &"HostHooks")
.field("clock", &"Clock")
.field("module_loader", &"ModuleLoader")
.field("optimizer_options", &self.optimizer_options);

Expand Down Expand Up @@ -553,6 +560,13 @@ impl Context {
self.host_hooks.clone()
}

/// Gets the internal clock.
#[inline]
#[must_use]
pub fn clock(&self) -> &dyn Clock {
self.clock.as_ref()
}

/// Gets the job executor.
#[inline]
#[must_use]
Expand Down Expand Up @@ -888,6 +902,7 @@ impl Context {
pub struct ContextBuilder {
interner: Option<Interner>,
host_hooks: Option<Rc<dyn HostHooks>>,
clock: Option<Rc<dyn Clock>>,
job_executor: Option<Rc<dyn JobExecutor>>,
module_loader: Option<Rc<dyn ModuleLoader>>,
can_block: bool,
Expand All @@ -904,12 +919,15 @@ impl std::fmt::Debug for ContextBuilder {
#[derive(Clone, Copy, Debug)]
struct HostHooks;
#[derive(Clone, Copy, Debug)]
struct Clock;
#[derive(Clone, Copy, Debug)]
struct ModuleLoader;

let mut out = f.debug_struct("ContextBuilder");

out.field("interner", &self.interner)
.field("host_hooks", &self.host_hooks.as_ref().map(|_| HostHooks))
.field("clock", &self.clock.as_ref().map(|_| Clock))
.field(
"job_executor",
&self.job_executor.as_ref().map(|_| JobExecutor),
Expand Down Expand Up @@ -1026,6 +1044,13 @@ impl ContextBuilder {
self
}

/// Initializes the [`Clock`] for the context.
#[must_use]
pub fn clock<C: Clock + 'static>(mut self, clock: Rc<C>) -> Self {
self.clock = Some(clock);
self
}

/// Initializes the [`JobExecutor`] for the context.
#[must_use]
pub fn job_executor<Q: JobExecutor + 'static>(mut self, job_executor: Rc<Q>) -> Self {
Expand Down Expand Up @@ -1089,6 +1114,7 @@ impl ContextBuilder {
let root_shape = RootShape::default();

let host_hooks = self.host_hooks.unwrap_or(Rc::new(DefaultHooks));
let clock = self.clock.unwrap_or_else(|| Rc::new(StdClock));
let realm = Realm::create(host_hooks.as_ref(), &root_shape)?;
let vm = Vm::new(realm);

Expand Down Expand Up @@ -1129,6 +1155,7 @@ impl ContextBuilder {
instructions_remaining: self.instructions_remaining,
kept_alive: Vec::new(),
host_hooks,
clock,
job_executor,
module_loader,
optimizer_options: OptimizerOptions::OPTIMIZE_ALL,
Expand Down
213 changes: 213 additions & 0 deletions core/engine/src/context/time.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
//! Clock related types and functions.
/// A monotonic instant in time, in the Boa engine.
///
/// This type is guaranteed to be monotonic, i.e. if two instants
/// are compared, the later one will always be greater than the
/// earlier one. It is also always guaranteed to be greater than
/// or equal to the Unix epoch.
///
/// This should not be used to keep dates or times, but only to
/// measure the current time in the engine.
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct JsInstant {
/// The duration of time since the Unix epoch.
inner: std::time::Duration,
}

impl JsInstant {
/// Creates a new `JsInstant` from the given number of seconds and nanoseconds.
#[must_use]
pub fn new(secs: u64, nanos: u32) -> Self {
let inner = std::time::Duration::new(secs, nanos);
Self::new_unchecked(inner)
}

/// Creates a new `JsInstant` from an unchecked duration since the Unix epoch.
#[must_use]
fn new_unchecked(inner: std::time::Duration) -> Self {
Self { inner }
}

/// Returns the number of milliseconds since the Unix epoch.
#[must_use]
pub fn millis_since_epoch(&self) -> u64 {
self.inner.as_millis() as u64
}

/// Returns the number of nanoseconds since the Unix epoch.
#[must_use]
pub fn nanos_since_epoch(&self) -> u128 {
self.inner.as_nanos()
}
}

/// A duration of time, inside the Boa engine.
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct JsDuration {
inner: std::time::Duration,
}

impl JsDuration {
/// Creates a new `JsDuration` from the given number of milliseconds.
#[must_use]
pub fn from_millis(millis: i64) -> Self {
Self {
inner: std::time::Duration::from_millis(millis as u64),
}
}

/// Returns the number of milliseconds in this duration.
#[must_use]
pub fn as_millis(&self) -> i64 {
self.inner.as_millis() as i64
}

/// Returns the number of seconds in this duration.
#[must_use]
pub fn as_secs(&self) -> i64 {
self.inner.as_secs() as i64
}

/// Returns the number of nanoseconds in this duration.
#[must_use]
pub fn as_nanos(&self) -> i64 {
self.inner.as_nanos() as i64
}
}

impl From<std::time::Duration> for JsDuration {
fn from(duration: std::time::Duration) -> Self {
Self { inner: duration }
}
}

impl From<JsDuration> for std::time::Duration {
fn from(duration: JsDuration) -> Self {
duration.inner
}
}

macro_rules! impl_duration_ops {
($($trait:ident $trait_fn:ident),*) => {
$(
impl std::ops::$trait for JsDuration {
type Output = JsDuration;

#[inline]
fn $trait_fn(self, rhs: JsDuration) -> Self::Output {
Self {
inner: std::ops::$trait::$trait_fn(self.inner, rhs.inner)
}
}
}
impl std::ops::$trait<JsDuration> for JsInstant {
type Output = JsInstant;

#[inline]
fn $trait_fn(self, rhs: JsDuration) -> Self::Output {
Self {
inner: std::ops::$trait::$trait_fn(self.inner, rhs.inner)
}
}
}
)*
};
}

impl_duration_ops!(Add add, Sub sub);

impl std::ops::Sub for JsInstant {
type Output = JsDuration;

#[inline]
fn sub(self, rhs: JsInstant) -> Self::Output {
JsDuration {
inner: self.inner - rhs.inner,
}
}
}

/// Implement a clock that can be used to measure time.
pub trait Clock {
/// Returns the current time.
fn now(&self) -> JsInstant;
}

/// A clock that uses the standard system clock.
#[derive(Debug, Clone, Copy, Default)]
pub struct StdClock;

impl Clock for StdClock {
fn now(&self) -> JsInstant {
let now = std::time::SystemTime::now();
let duration = now
.duration_since(std::time::UNIX_EPOCH)
.expect("System clock is before Unix epoch");

JsInstant::new_unchecked(duration)
}
}

/// A clock that uses a fixed time, useful for testing. The internal time is in milliseconds.
///
/// This clock will always return the same time, unless it is moved forward manually. It cannot
/// be moved backward or set to a specific time.
#[derive(Debug, Clone, Default)]
pub struct FixedClock(std::cell::RefCell<u64>);

impl FixedClock {
/// Creates a new `FixedClock` from the given number of milliseconds since the Unix epoch.
#[must_use]
pub fn from_millis(millis: u64) -> Self {
Self(std::cell::RefCell::new(millis))
}

/// Move the clock forward by the given number of milliseconds.
pub fn forward(&self, millis: u64) {
*self.0.borrow_mut() += millis;
}
}

impl Clock for FixedClock {
fn now(&self) -> JsInstant {
let millis = *self.0.borrow();
JsInstant::new_unchecked(std::time::Duration::new(
millis / 1000,
((millis % 1000) * 1_000_000) as u32,
))
}
}

#[test]
fn basic() {
let now = StdClock.now();
assert!(now.millis_since_epoch() > 0);
assert!(now.nanos_since_epoch() > 0);

let duration = JsDuration::from_millis(1000);
let later = now + duration;
assert!(later > now);

let earlier = now - duration;
assert!(earlier < now);

let diff = later - earlier;
assert_eq!(diff.as_millis(), 2000);

let fixed = FixedClock::from_millis(0);
let now2 = fixed.now();
assert_eq!(now2.millis_since_epoch(), 0);
assert!(now2 < now);

fixed.forward(1000);
let now3 = fixed.now();
assert_eq!(now3.millis_since_epoch(), 1000);
assert!(now3 > now2);

// End of time.
fixed.forward(u64::MAX - 1000);
let now4 = fixed.now();
assert_eq!(now4.millis_since_epoch(), u64::MAX);
assert!(now4 > now3);
}
8 changes: 3 additions & 5 deletions core/engine/src/object/builtins/jsdate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,9 @@ impl JsDate {
#[inline]
pub fn new(context: &mut Context) -> Self {
let prototype = context.intrinsics().constructors().date().prototype();
let inner = JsObject::from_proto_and_data_with_shared_shape(
context.root_shape(),
prototype,
Date::utc_now(context.host_hooks().as_ref()),
);
let now = Date::utc_now(context);
let inner =
JsObject::from_proto_and_data_with_shared_shape(context.root_shape(), prototype, now);

Self { inner }
}
Expand Down

0 comments on commit e957201

Please sign in to comment.