1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
use std::time::{Duration, Instant};
use boa_engine::object::builtins::JsFunction;
use boa_engine::{Context, JsError, JsValue, js_string};
use crate::{resources::AsyncResult, tui::graph_api::RenderCmd};
use super::dom::response_object;
use super::engine::ScriptEngine;
impl ScriptEngine {
/// How long the message loop may block before the earliest pending timer
/// fires, or `None` when no timer is active (block indefinitely).
pub fn next_timer_timeout(&self) -> Option<Duration> {
let st = self.state.borrow();
st.timers
.iter()
.map(|t| t.deadline.saturating_duration_since(Instant::now()))
.min()
}
/// Whether a `fetch` completion is already queued in the load channel.
///
/// Loads resolve synchronously at `fetch()` call time (the in-memory
/// registry hands the bytes back immediately), so a non-empty
/// `active_fetches` set means the result is sitting in the channel and
/// the next [`poll_fetches`](Self::poll_fetches) will settle it. The
/// message loop uses this to wake immediately instead of waiting out a
/// poll interval.
pub fn has_pending_fetch(&self) -> bool {
!self.state.borrow().active_fetches.is_empty()
}
/// Fire every timer whose deadline has passed, in registration order.
///
/// Callbacks run in the calling thread — the script thread in production —
/// exactly like a browser event-loop task, so a callback that rewrites the
/// DOM marks nodes dirty as usual. Returns render commands pushed by host
/// functions during the dispatch. `setInterval` callbacks are re-scheduled
/// relative to the fire time; a badly-lagged interval jumps to `now +
/// period` instead of bursting. `setTimeout` callbacks are one-shot.
pub fn run_due_timers(&mut self) -> Vec<RenderCmd> {
let now = Instant::now();
// Snapshot the due ids (and frame timestamps) first: callbacks may
// register or clear timers, so the table mutates while we iterate.
let due: Vec<(u32, Option<f64>)> = {
let st = self.state.borrow();
st.timers
.iter()
.filter(|t| t.deadline <= now)
.map(|t| (t.id, t.frame_time))
.collect()
};
if due.is_empty() {
return Vec::new();
}
// Re-schedule / remove before firing so a slow callback cannot make a
// missed repeat deadline fire again immediately after.
{
let mut st = self.state.borrow_mut();
st.timers.retain_mut(|t| {
if t.deadline > now {
return true;
}
if t.repeat {
t.deadline = now + t.period;
true
} else {
false
}
});
}
for (id, frame_time) in due {
let callback = timer_callback(&mut self.context, id);
if !callback.is_callable() {
log::debug!("Timer {id} has no callable callback — skipping");
continue;
}
let result = if let Ok(f) = callback.to_object(&mut self.context) {
// Frame callbacks receive their grid timestamp (ms since
// session start, browser `DOMHighResTimeStamp` semantics);
// other timers are invoked without arguments.
match frame_time {
Some(ts) => f.call(
&JsValue::undefined(),
&[JsValue::from(ts)],
&mut self.context,
),
None => f.call(&JsValue::undefined(), &[], &mut self.context),
}
} else {
continue;
};
if let Err(e) = result {
log::warn!("Timer callback threw: {e}");
}
}
let mut st = self.state.borrow_mut();
let cmds = std::mem::take(&mut st.render_cmds);
drop(st);
cmds
}
/// Drain completed loads and settle their `fetch` promises.
///
/// Called on every script-thread loop iteration after message / timer
/// processing. Each completed [`AsyncResult`] carries a `{id}:{url}` key
/// (see `crate::resources::load_async`); the id looks up the promise's
/// `resolve` / `reject` functions stashed by `fetch`, which are invoked
/// with a `Response`-like object (or an error). Promise reaction callbacks
/// (`.then` / `await`) run through [`Context::run_jobs`], so this is the
/// microtask half of the engine's event loop.
pub fn poll_fetches(&mut self) {
loop {
let result = self.state.borrow().fetch_rx.try_recv();
match result {
Ok(AsyncResult { id, url, outcome }) => {
self.settle_fetch(id, &url, outcome);
}
Err(e) => {
if !matches!(e, std::sync::mpsc::TryRecvError::Empty) {
log::debug!("fetch channel error: {e}");
}
break;
}
}
}
if let Err(e) = self.context.run_jobs() {
log::warn!("Microtask queue run failed: {e}");
}
}
/// Resolve or reject the promise for a single completed fetch.
///
/// The resolvers live in the GC-rooted `__htuiFetchResolvers` JS registry
/// (keyed by fetch id); this method reads them out, settles the promise,
/// and removes the entry.
fn settle_fetch(
&mut self,
id: u32,
url: &str,
outcome: Result<std::sync::Arc<Vec<u8>>, String>,
) {
if !self.state.borrow_mut().active_fetches.remove(&id) {
log::warn!("fetch result for unknown id {id} (url {url}) — dropped");
return;
}
let (resolve, reject) = match self.take_fetch_resolvers(id) {
Some(v) => v,
None => {
log::warn!("fetch id {id} has no JS resolvers — dropped");
return;
}
};
let value = match outcome {
Ok(resource) => response_object(&mut self.context, url.to_string(), resource),
Err(message) => Err(JsError::from_opaque(
js_string!(format!("fetch failed for {url}: {message}")).into(),
)),
};
let result = match value {
Ok(v) => resolve.call(&JsValue::undefined(), &[v], &mut self.context),
Err(e) => reject.call(
&JsValue::undefined(),
&[e.to_opaque(&mut self.context)],
&mut self.context,
),
};
if let Err(e) = result {
log::warn!("fetch promise settlement failed: {e}");
}
}
/// Pull the `[resolve, reject]` array for a fetch id out of the JS
/// registry and delete the entry (each fetch settles at most once).
fn take_fetch_resolvers(&mut self, id: u32) -> Option<(JsFunction, JsFunction)> {
let global = self.context.global_object();
let registry = global
.get(js_string!("__htuiFetchResolvers"), &mut self.context)
.ok()?
.to_object(&mut self.context)
.ok()?;
let key = js_string!(id.to_string());
let entry = registry.get(key.clone(), &mut self.context).ok()?;
if entry.is_undefined() {
return None;
}
let obj = entry.to_object(&mut self.context).ok()?;
let resolve = obj
.get(js_string!("resolve"), &mut self.context)
.ok()
.and_then(|v| v.as_callable())
.and_then(JsFunction::from_object);
let reject = obj
.get(js_string!("reject"), &mut self.context)
.ok()
.and_then(|v| v.as_callable())
.and_then(JsFunction::from_object);
if let Err(e) = registry.delete_property_or_throw(key, &mut self.context) {
log::warn!("Failed to remove fetch resolver for id {id}: {e}");
}
match (resolve, reject) {
(Some(r), Some(j)) => Some((r, j)),
_ => None,
}
}
}
/// Fetch a GC-rooted timer callback from `globalThis.__htuiTimers` by id.
fn timer_callback(context: &mut Context, id: u32) -> JsValue {
let global = context.global_object();
let Ok(registry) = global.get(js_string!("__htuiTimers"), context) else {
return JsValue::undefined();
};
let Ok(registry) = registry.to_object(context) else {
return JsValue::undefined();
};
registry
.get(js_string!(id.to_string()), context)
.unwrap_or(JsValue::undefined())
}