Skip to content

Commit 647b44b

Browse files
authored
wpt: run JS timers on virtual time, fast-forwarding instead of sleeping (#821)
1 parent be5eeb7 commit 647b44b

7 files changed

Lines changed: 169 additions & 21 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//! The clock which drives JS-observable time (timers and `Date`).
2+
3+
use std::cell::RefCell;
4+
use std::rc::Rc;
5+
6+
use web_time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7+
8+
/// The clock used for JS timer deadlines and `Date`.
9+
///
10+
/// In the default real mode it tracks the system's monotonic clock. In
11+
/// virtual mode, time only advances when [`advance_to`](ScriptClock::advance_to)
12+
/// is called: embedders which drive timers manually (e.g. test runners) can
13+
/// jump straight to the next timer deadline instead of sleeping until it,
14+
/// while preserving timer ordering.
15+
#[derive(Clone)]
16+
pub(crate) struct ScriptClock {
17+
inner: Rc<RefCell<ClockMode>>,
18+
}
19+
20+
enum ClockMode {
21+
Real,
22+
Virtual { now: Instant },
23+
}
24+
25+
impl Default for ScriptClock {
26+
fn default() -> Self {
27+
Self {
28+
inner: Rc::new(RefCell::new(ClockMode::Real)),
29+
}
30+
}
31+
}
32+
33+
impl ScriptClock {
34+
/// The current time according to this clock
35+
pub fn now(&self) -> Instant {
36+
match *self.inner.borrow() {
37+
ClockMode::Real => Instant::now(),
38+
ClockMode::Virtual { now } => now,
39+
}
40+
}
41+
42+
/// Switch to virtual mode. Time stops at the current instant and only
43+
/// advances via [`advance_to`](Self::advance_to).
44+
pub fn make_virtual(&self) {
45+
let mut mode = self.inner.borrow_mut();
46+
if matches!(*mode, ClockMode::Real) {
47+
*mode = ClockMode::Virtual {
48+
now: Instant::now(),
49+
};
50+
}
51+
}
52+
53+
/// Advance a virtual clock to `deadline` (never backwards).
54+
/// Does nothing in real mode.
55+
pub fn advance_to(&self, deadline: Instant) {
56+
if let ClockMode::Virtual { now } = &mut *self.inner.borrow_mut() {
57+
*now = (*now).max(deadline);
58+
}
59+
}
60+
}
61+
62+
/// Adapter exposing a [`ScriptClock`] as a Boa [`Clock`](boa_engine::context::Clock),
63+
/// so that `Date` observes virtual time consistently with timers.
64+
pub(crate) struct BoaClockAdapter {
65+
pub clock: ScriptClock,
66+
/// Anchor for converting `Instant`s to durations-since-an-epoch
67+
pub base: Instant,
68+
/// Wall-clock time (ms since the Unix epoch) at `base`
69+
pub base_system_millis: i64,
70+
}
71+
72+
impl BoaClockAdapter {
73+
pub fn new(clock: ScriptClock) -> Self {
74+
let base = clock.now();
75+
let base_system_millis = SystemTime::now()
76+
.duration_since(UNIX_EPOCH)
77+
.unwrap_or(Duration::ZERO)
78+
.as_millis() as i64;
79+
Self {
80+
clock,
81+
base,
82+
base_system_millis,
83+
}
84+
}
85+
86+
fn elapsed(&self) -> Duration {
87+
self.clock.now().saturating_duration_since(self.base)
88+
}
89+
}
90+
91+
impl boa_engine::context::Clock for BoaClockAdapter {
92+
fn now(&self) -> boa_engine::context::time::JsInstant {
93+
let elapsed = self.elapsed();
94+
boa_engine::context::time::JsInstant::new(elapsed.as_secs(), elapsed.subsec_nanos())
95+
}
96+
97+
fn system_time_millis(&self) -> i64 {
98+
self.base_system_millis + self.elapsed().as_millis() as i64
99+
}
100+
}

packages/blitz-vibey-script/src/document.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,33 @@ impl ScriptDocument {
116116
self
117117
}
118118

119+
/// Switch the document's clock (which drives JS timer deadlines and
120+
/// `Date`) to virtual time: time stops and only advances via
121+
/// [`advance_clock_to`](Self::advance_clock_to).
122+
///
123+
/// Intended for embedders which drive timers manually by polling
124+
/// [`next_timer_deadline`](Self::next_timer_deadline) and calling
125+
/// [`poll`](blitz_traits::Document::poll): instead of sleeping until the
126+
/// next deadline they can jump the clock straight to it, preserving timer
127+
/// ordering without wall-clock waiting. Should be combined with
128+
/// [`without_timer_thread`](Self::without_timer_thread) (the timer thread
129+
/// sleeps in real time).
130+
pub fn with_virtual_time(self) -> Self {
131+
self.runtime.ctx.state.borrow().clock.make_virtual();
132+
self
133+
}
134+
135+
/// The current time according to the document's clock (virtual or real)
136+
pub fn clock_now(&self) -> Instant {
137+
self.runtime.ctx.state.borrow().clock.now()
138+
}
139+
140+
/// Advance a virtual clock to `deadline` (never backwards). Does nothing
141+
/// unless [`with_virtual_time`](Self::with_virtual_time) was used.
142+
pub fn advance_clock_to(&mut self, deadline: Instant) {
143+
self.runtime.ctx.state.borrow().clock.advance_to(deadline);
144+
}
145+
119146
/// Override the [`ScriptFetcher`] used to load external (`src="..."`) scripts
120147
/// and ES module imports. The default fetcher supports `file:` and `data:` URLs.
121148
pub fn with_fetcher(self, fetcher: impl ScriptFetcher) -> Self {

packages/blitz-vibey-script/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
3030
#![allow(clippy::collapsible_if)]
3131

32+
mod clock;
3233
mod document;
3334
mod dom;
3435
mod event_handler;

packages/blitz-vibey-script/src/runtime.rs

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -513,11 +513,15 @@ impl ScriptRuntime {
513513
base_url: base_url.cloned(),
514514
modules: RefCell::new(HashMap::new()),
515515
});
516+
let ctx = DomCtx::new(doc);
517+
// Share the runtime's clock with boa so that `Date` observes the same
518+
// (possibly virtual) time as timers
519+
let clock = ctx.state.borrow().clock.clone();
516520
let mut context = Context::builder()
517521
.module_loader(module_loader.clone())
522+
.clock(Rc::new(crate::clock::BoaClockAdapter::new(clock)))
518523
.build()
519524
.expect("failed to build JS context");
520-
let ctx = DomCtx::new(doc);
521525
context.insert_data(ctx.clone());
522526

523527
Console::register_with_logger(LogCrateLogger, &mut context)
@@ -812,7 +816,11 @@ impl ScriptRuntime {
812816

813817
/// Run all timers that are currently due. Returns `true` if any JavaScript was run.
814818
pub fn run_due_timers(&mut self) -> bool {
815-
let due = self.ctx.state.borrow_mut().timers.take_due(Instant::now());
819+
let due = {
820+
let mut state = self.ctx.state.borrow_mut();
821+
let now = state.clock.now();
822+
state.timers.take_due(now)
823+
};
816824
if due.is_empty() {
817825
return false;
818826
}
@@ -1262,11 +1270,9 @@ fn set_timeout(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult
12621270
let Some((callback, delay, rest)) = timer_args(args, context)? else {
12631271
return Ok(JsValue::from(0));
12641272
};
1265-
let id = ctx
1266-
.state
1267-
.borrow_mut()
1268-
.timers
1269-
.add(delay, None, callback, rest);
1273+
let mut state = ctx.state.borrow_mut();
1274+
let now = state.clock.now();
1275+
let id = state.timers.add(now, delay, None, callback, rest);
12701276
Ok(JsValue::from(id as f64))
12711277
}
12721278

@@ -1275,11 +1281,9 @@ fn set_interval(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResul
12751281
let Some((callback, delay, rest)) = timer_args(args, context)? else {
12761282
return Ok(JsValue::from(0));
12771283
};
1278-
let id = ctx
1279-
.state
1280-
.borrow_mut()
1281-
.timers
1282-
.add(delay, Some(delay), callback, rest);
1284+
let mut state = ctx.state.borrow_mut();
1285+
let now = state.clock.now();
1286+
let id = state.timers.add(now, delay, Some(delay), callback, rest);
12831287
Ok(JsValue::from(id as f64))
12841288
}
12851289

@@ -1298,7 +1302,10 @@ fn request_animation_frame(
12981302
};
12991303
// Approximate the next frame as ~16ms away
13001304
let timestamp = JsValue::from(16.0);
1301-
let id = ctx.state.borrow_mut().timers.add(
1305+
let mut state = ctx.state.borrow_mut();
1306+
let now = state.clock.now();
1307+
let id = state.timers.add(
1308+
now,
13021309
Duration::from_millis(16),
13031310
None,
13041311
callback,

packages/blitz-vibey-script/src/state.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use blitz_dom::{BaseDocument, NodeId};
99
use boa_engine::object::JsObject;
1010
use boa_engine::{Finalize, JsData, Trace};
1111

12+
use crate::clock::ScriptClock;
1213
use crate::timers::TimerQueue;
1314

1415
/// Prototype objects for the DOM wrapper classes
@@ -73,6 +74,8 @@ pub(crate) struct RuntimeState {
7374
pub window_listeners: ListenerMap,
7475
/// Pending timers (`setTimeout`/`setInterval`/`requestAnimationFrame`)
7576
pub timers: TimerQueue,
77+
/// The clock driving timer deadlines and `Date`
78+
pub clock: ScriptClock,
7679
/// Messages sent from JavaScript to the embedder via the
7780
/// `__blitz_send_message` native function. Drained with
7881
/// [`ScriptDocument::take_messages`](crate::ScriptDocument::take_messages).

packages/blitz-vibey-script/src/timers.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub(crate) struct TimerQueue {
2222
impl TimerQueue {
2323
pub fn add(
2424
&mut self,
25+
now: Instant,
2526
delay: Duration,
2627
interval: Option<Duration>,
2728
callback: JsObject,
@@ -31,7 +32,7 @@ impl TimerQueue {
3132
let id = self.next_id;
3233
self.timers.push(Timer {
3334
id,
34-
deadline: Instant::now() + delay,
35+
deadline: now + delay,
3536
interval,
3637
callback,
3738
args,

wpt/runner/src/test_runners/mod.rs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,12 @@ pub fn attr_test_needs_scripts(doc: &BaseDocument) -> bool {
119119
/// and execute its scripts
120120
pub fn run_document_scripts(ctx: &ThreadCtx, document: BaseDocument) -> ScriptDocument {
121121
// The runner drives timers manually via `pump_timers`, so the background
122-
// timer wakeup thread is unnecessary
122+
// timer wakeup thread is unnecessary. Timers run on virtual time: there is
123+
// no external event source, so `pump_timers` fast-forwards the clock to
124+
// each timer deadline instead of sleeping until it.
123125
let mut script_document = ScriptDocument::from_base_document(document)
124126
.without_timer_thread()
127+
.with_virtual_time()
125128
.with_fetcher(WptScriptFetcher::new(ctx.wpt_dir.clone()));
126129
script_document.execute_scripts();
127130
script_document
@@ -130,22 +133,28 @@ pub fn run_document_scripts(ctx: &ThreadCtx, document: BaseDocument) -> ScriptDo
130133
/// Pump the document's pending JS timers until `check` produces a result or
131134
/// there are no more timers due within `budget`. `check` runs once before any
132135
/// timer fires and again after each timer poll.
136+
///
137+
/// The document runs on virtual time (see [`run_document_scripts`]): instead
138+
/// of sleeping until the next timer deadline, the clock is fast-forwarded to
139+
/// it, so timers fire in deadline order without wall-clock waiting. `budget`
140+
/// bounds both virtual time and (as a backstop for timer callbacks which
141+
/// reschedule themselves indefinitely, e.g. rAF loops) real time.
133142
pub fn pump_timers<T>(
134143
document: &mut ScriptDocument,
135144
budget: Duration,
136145
mut check: impl FnMut(&mut ScriptDocument) -> Option<T>,
137146
) -> Option<T> {
138-
let deadline = Instant::now() + budget;
147+
let real_deadline = Instant::now() + budget;
148+
let virtual_deadline = document.clock_now() + budget;
139149
loop {
140150
if let Some(result) = check(document) {
141151
return Some(result);
142152
}
143153
match document.next_timer_deadline() {
144-
Some(timer_deadline) if timer_deadline <= deadline => {
145-
let now = Instant::now();
146-
if timer_deadline > now {
147-
std::thread::sleep(timer_deadline - now);
148-
}
154+
Some(timer_deadline)
155+
if timer_deadline <= virtual_deadline && Instant::now() < real_deadline =>
156+
{
157+
document.advance_clock_to(timer_deadline);
149158
document.poll(None);
150159
}
151160
// Timer budget expired, or no pending timers

0 commit comments

Comments
 (0)