Skip to main content

orinium_browser/engine/js/
processor.rs

1//! Runs the page's JavaScript runtime on a dedicated background thread.
2//!
3//! Script evaluation, DOM events, timers, and fetch settlement are processed
4//! off the UI thread. The executor maintains a private DOM mirror and sends
5//! [`DomSnapshot`]s back when the DOM is mutated.
6//!
7//! This is not a Web Worker: scripts have full `window`/`document` access.
8//! Tasks are processed FIFO, with coalescable timer wakeups.
9
10use crate::{perf_scope, profile_log};
11
12use std::collections::HashMap;
13use std::rc::Rc;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::{Arc, mpsc};
16use std::thread;
17
18use super::{
19    IframeContentSnapshot, JsDevToolsRequest, JsDynamicImageRequest, JsDynamicScriptRequest,
20    JsDynamicStyleRequest, JsFetchRequest, JsFetchResponse, JsIframeFetchRequest, JsLayoutMetrics,
21    JsRuntime,
22};
23use crate::engine::layouter::dom_snapshot::DomSnapshot;
24
25/// What the background JS thread should do next.
26#[derive(Debug)]
27pub enum JsTask {
28    /// Set the URL exposed through `document` and related browser APIs.
29    SetDocumentUrl { url: String },
30    /// Set the serialized origin exposed through `window`/`location`/`document`.
31    SetOrigin { origin: String },
32    /// Update CSS-pixel dimensions exposed through the Window API.
33    SetViewport { width: f32, height: f32 },
34    /// Update the language preferences exposed through `navigator`.
35    SetLanguage { language: String },
36    /// Replace DOM geometry exposed by measurement APIs.
37    SetLayoutMetrics {
38        metrics: HashMap<u64, JsLayoutMetrics>,
39    },
40    /// Execute a classic (blocking or deferred) script.
41    RunScript { source: String },
42    /// Dispatch `DOMContentLoaded` to document listeners.
43    DispatchDomContentLoaded,
44    /// Dispatch the window `load` event (fires `window.onload`).
45    DispatchWindowLoad,
46    /// Run timer callbacks whose deadlines have elapsed (coalescable).
47    RunTimers,
48    /// Dispatch a click on the element with the given JS-facing dom id.
49    Click { dom_id: u64 },
50    /// Dispatch a `scroll` event on the element with the given JS-facing dom id.
51    Scroll { dom_id: u64 },
52    /// Dispatch an event on the element with the given JS-facing dom id.
53    DispatchElementEvent { dom_id: u64, event_type: String },
54    /// Resolve a pending JavaScript `fetch()` with a network response.
55    ResolveFetch { id: u64, response: JsFetchResponse },
56    /// Reject a pending JavaScript `fetch()` after a network failure.
57    RejectFetch { id: u64, reason: String },
58    /// Settle a pending DevTools inspection request with its JSON envelope.
59    ResolveDevTools { id: u64, result: String },
60    /// Parse fetched iframe HTML and install it as the host element's
61    /// `contentDocument`, then fire its `load` event.
62    ResolveIframe { dom_id: u64, html: String },
63    /// Mark an iframe load as failed so later `contentDocument` accesses do not
64    /// keep re-queuing a fetch.
65    RejectIframe { dom_id: u64 },
66    /// Replace the JS thread's mirror DOM with the UI's tree (write-backs).
67    UpdateDom { snapshot: DomSnapshot },
68}
69
70/// A task stamped with its position in the ordered stream.
71#[derive(Debug)]
72enum JsCommand {
73    Task { task: JsTask, version: u64 },
74}
75
76/// The outcome of a JS task, ready to be applied on the UI thread.
77#[derive(Debug)]
78pub struct JsTaskResult {
79    /// The JS thread's mirror DOM, present only when a script mutated it.
80    pub dom: Option<DomSnapshot>,
81    /// The serialized content documents of any `<iframe>`s, present alongside
82    /// `dom` so layout can render them nested inside the host page.
83    pub iframe_documents: Vec<IframeContentSnapshot>,
84    /// Whether the DOM changed and the UI needs to relayout and redraw.
85    pub needs_redraw: bool,
86    /// `fetch()` requests queued by scripts while running this task.
87    pub fetch_requests: Vec<JsFetchRequest>,
88    /// `<iframe src="...">` fetch requests queued while running this task.
89    pub iframe_fetch_requests: Vec<JsIframeFetchRequest>,
90    /// DevTools inspection requests queued by scripts while running this task.
91    pub devtools_requests: Vec<JsDevToolsRequest>,
92    /// Dynamically inserted script elements discovered while running this task.
93    pub(crate) dynamic_script_requests: Vec<JsDynamicScriptRequest>,
94    /// Dynamically inserted stylesheet links discovered while running this task.
95    pub(crate) dynamic_style_requests: Vec<JsDynamicStyleRequest>,
96    /// Images created or populated while running this task.
97    pub(crate) dynamic_image_requests: Vec<JsDynamicImageRequest>,
98    /// The sequence number of the task that produced this result.
99    pub version: u64,
100}
101
102/// A processor that accepts [`JsTask`]s and returns the results produced by the
103/// background JS thread.
104pub struct JsProcessor {
105    cmd_tx: mpsc::Sender<JsCommand>,
106    result_rx: mpsc::Receiver<JsTaskResult>,
107    /// Latest task sequence number; shared with the background thread so it can
108    /// detect and skip superseded timer pokes.
109    latest: Arc<AtomicU64>,
110}
111
112impl std::fmt::Debug for JsProcessor {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        f.write_str("JsProcessor")
115    }
116}
117
118impl JsProcessor {
119    /// Starts the background JS thread initialized with a snapshot of the
120    /// parsed document.
121    pub fn new(initial: DomSnapshot) -> Self {
122        let (cmd_tx, cmd_rx) = mpsc::channel::<JsCommand>();
123        let (result_tx, result_rx) = mpsc::channel::<JsTaskResult>();
124
125        let latest = Arc::new(AtomicU64::new(0));
126        let thread_latest = Arc::clone(&latest);
127
128        thread::spawn(move || {
129            perf_scope!(init);
130            let (tree, _dom_ids) = initial.into_tree();
131            let mut runtime = JsRuntime::new(Rc::new(tree));
132            perf_scope!(apply_dom);
133            runtime.apply_dom(&initial);
134            profile_log!(
135                target: "JsProc",
136                log::Level::Info,
137                "[JsMetrics] runtime_init: {:?} | initial_apply_dom: {:?}",
138                init.elapsed(),
139                apply_dom.elapsed(),
140            );
141
142            for cmd in cmd_rx {
143                let JsCommand::Task { task, version } = cmd;
144                // A newer task queued after this timer poke supersedes it.
145                if matches!(task, JsTask::RunTimers)
146                    && version < thread_latest.load(Ordering::SeqCst)
147                {
148                    continue;
149                }
150
151                perf_scope!(total);
152                // Markup-declared `<iframe src>` elements (and frames inserted
153                // via fragment parsing) never hit the `src` setter, so queue
154                // their loads whenever the document URL or bound tree changes.
155                let tree_may_have_new_iframes = matches!(
156                    &task,
157                    JsTask::SetDocumentUrl { .. } | JsTask::UpdateDom { .. }
158                );
159                perf_scope!(run);
160                #[cfg_attr(not(feature = "profile"), allow(unused_variables))]
161                let did_work = run_task(&mut runtime, task);
162                if tree_may_have_new_iframes || did_work {
163                    runtime.queue_markup_iframe_loads();
164                }
165                #[cfg(any(feature = "profile", debug_assertions))]
166                let run_time = run.elapsed();
167
168                perf_scope!(collect);
169                let needs_redraw = runtime.take_needs_redraw();
170                let fetch_requests = runtime.take_fetch_requests();
171                let iframe_fetch_requests = runtime.take_iframe_fetch_requests();
172                let devtools_requests = runtime.take_devtools_requests();
173                let dynamic_script_requests = runtime.take_dynamic_script_requests();
174                let dynamic_style_requests = runtime.take_dynamic_style_requests();
175                let dynamic_image_requests = runtime.take_dynamic_image_requests();
176                let dom = if needs_redraw {
177                    Some(runtime.snapshot())
178                } else {
179                    None
180                };
181                let iframe_documents = if needs_redraw {
182                    runtime.snapshot_iframe_documents()
183                } else {
184                    Vec::new()
185                };
186                #[cfg(any(feature = "profile", debug_assertions))]
187                let collect_time = collect.elapsed();
188
189                let _ = result_tx.send(JsTaskResult {
190                    dom,
191                    iframe_documents,
192                    needs_redraw,
193                    fetch_requests,
194                    iframe_fetch_requests,
195                    devtools_requests,
196                    dynamic_script_requests,
197                    dynamic_style_requests,
198                    dynamic_image_requests,
199                    version,
200                });
201                profile_log!(
202                    target: "JsProc",
203                    if did_work {
204                        log::Level::Info
205                    } else {
206                        log::Level::Debug
207                    },
208                    "[JsMetrics] total: {:?} | run_task: {:?} | collect: {:?}",
209                    total.elapsed(),
210                    run_time,
211                    collect_time,
212                );
213            }
214        });
215
216        Self {
217            cmd_tx,
218            result_rx,
219            latest,
220        }
221    }
222
223    /// Sends a task to the background thread, stamped with a fresh sequence
224    /// number, and returns that sequence number so the caller can track when
225    /// the task's result has been applied.
226    pub fn send(&self, task: JsTask) -> u64 {
227        let version = self.latest.fetch_add(1, Ordering::SeqCst) + 1;
228        let _ = self.cmd_tx.send(JsCommand::Task { task, version });
229        version
230    }
231
232    /// Returns a completed task result, or `None` if none is ready yet.
233    pub fn try_receive(&self) -> Option<JsTaskResult> {
234        self.result_rx.try_recv().ok()
235    }
236}
237
238/// Executes a task on the runtime, reporting whether page JavaScript ran.
239///
240/// Pure state-sync tasks (`SetViewport`, `SetLayoutMetrics`, `UpdateDom`, …)
241/// return `false`; they dominate the task stream even on script-less pages.
242fn run_task(runtime: &mut JsRuntime, task: JsTask) -> bool {
243    match task {
244        JsTask::SetDocumentUrl { url } => {
245            runtime.set_document_url(&url);
246            false
247        }
248        JsTask::SetOrigin { origin } => {
249            runtime.set_page_origin(&origin);
250            false
251        }
252        JsTask::SetViewport { width, height } => {
253            runtime.set_viewport(width, height);
254            false
255        }
256        JsTask::SetLanguage { language } => {
257            runtime.set_language(&language);
258            false
259        }
260        JsTask::SetLayoutMetrics { metrics } => {
261            runtime.set_layout_metrics_by_dom_id(metrics);
262            false
263        }
264        JsTask::RunScript { source } => {
265            runtime.run_script(&source);
266            true
267        }
268        JsTask::DispatchDomContentLoaded => runtime.dispatch_dom_content_loaded(),
269        JsTask::DispatchWindowLoad => runtime.dispatch_window_load(),
270        JsTask::RunTimers => runtime.run_due_timers(),
271        JsTask::Click { dom_id } => runtime.click_dom_id(dom_id),
272        JsTask::Scroll { dom_id } => runtime.scroll_dom_id(dom_id),
273        JsTask::DispatchElementEvent { dom_id, event_type } => {
274            runtime.dispatch_element_event(dom_id, &event_type);
275            true
276        }
277        JsTask::ResolveFetch { id, response } => {
278            runtime.resolve_fetch(id, response);
279            true
280        }
281        JsTask::RejectFetch { id, reason } => {
282            runtime.reject_fetch(id, reason);
283            true
284        }
285        JsTask::ResolveDevTools { id, result } => {
286            runtime.resolve_devtools(id, result);
287            true
288        }
289        JsTask::ResolveIframe { dom_id, html } => {
290            runtime.resolve_iframe_fetch(dom_id, html);
291            true
292        }
293        JsTask::RejectIframe { dom_id } => {
294            runtime.reject_iframe_fetch(dom_id);
295            true
296        }
297        JsTask::UpdateDom { snapshot } => {
298            runtime.apply_dom(&snapshot);
299            false
300        }
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use std::time::{Duration, Instant};
307
308    use super::*;
309    use crate::engine::html::parser::Parser as HtmlParser;
310    use crate::engine::layouter::NodeId;
311
312    fn snapshot_of(html: &str) -> DomSnapshot {
313        let dom = HtmlParser::new(html).parse();
314        let (snapshot, _) = DomSnapshot::from_tree(&dom.root);
315        snapshot
316    }
317
318    fn wait_for_result(processor: &JsProcessor) -> JsTaskResult {
319        let deadline = Instant::now() + Duration::from_secs(5);
320        loop {
321            if let Some(result) = processor.try_receive() {
322                return result;
323            }
324            assert!(
325                Instant::now() < deadline,
326                "JS result did not arrive before the timeout"
327            );
328            std::thread::sleep(Duration::from_millis(1));
329        }
330    }
331
332    fn find_element<'i>(snapshot: &'i DomSnapshot, id: NodeId, tag: &str) -> Option<NodeId> {
333        if snapshot.node(id).kind.tag_name() == Some(tag) {
334            return Some(id);
335        }
336        snapshot
337            .children(id)
338            .iter()
339            .find_map(|&c| find_element(snapshot, c, tag))
340    }
341
342    fn get_attr(snapshot: &DomSnapshot, id: NodeId, name: &str) -> Option<String> {
343        snapshot.node(id).kind.get_attr(name).map(str::to_string)
344    }
345
346    #[test]
347    fn script_dom_changes_are_reported_in_the_result_snapshot() {
348        let processor =
349            JsProcessor::new(snapshot_of("<html><body><div id='x'></div></body></html>"));
350        processor.send(JsTask::RunScript {
351            source: r#"document.getElementById("x").setAttribute("data-a", "1");"#.to_string(),
352        });
353
354        let result = wait_for_result(&processor);
355        assert!(result.needs_redraw);
356        let snapshot = result.dom.expect("mutating script must commit a snapshot");
357        let root = snapshot.roots()[0];
358        let div = find_element(&snapshot, root, "div").unwrap();
359        assert_eq!(get_attr(&snapshot, div, "data-a").as_deref(), Some("1"));
360    }
361
362    #[test]
363    fn ordered_scripts_run_in_send_order() {
364        let processor =
365            JsProcessor::new(snapshot_of("<html><body><div id='x'></div></body></html>"));
366        processor.send(JsTask::RunScript {
367            source: r#"globalThis.__s = "a";"#.to_string(),
368        });
369        processor.send(JsTask::RunScript {
370            source: r#"
371                globalThis.__s += "b";
372                document.getElementById("x").setAttribute("data-s", globalThis.__s);
373            "#
374            .to_string(),
375        });
376
377        // Only the last script mutates the DOM; its snapshot must reflect both.
378        let mut result = None;
379        for _ in 0..2 {
380            let received = wait_for_result(&processor);
381            if received.needs_redraw {
382                result = Some(received);
383            }
384        }
385        let snapshot = result
386            .expect("the second script must commit a snapshot")
387            .dom
388            .unwrap();
389        let root = snapshot.roots()[0];
390        let div = find_element(&snapshot, root, "div").unwrap();
391        assert_eq!(get_attr(&snapshot, div, "data-s").as_deref(), Some("ab"));
392    }
393
394    #[test]
395    fn update_dom_replaces_the_mirror_before_later_tasks() {
396        let processor =
397            JsProcessor::new(snapshot_of("<html><body><div id='x'></div></body></html>"));
398        // The UI changes the value of #x and pushes the real tree over.
399        processor.send(JsTask::UpdateDom {
400            snapshot: snapshot_of("<html><body><div id='x' data-v='ui'></div></body></html>"),
401        });
402        processor.send(JsTask::RunScript {
403            source: r#"
404                const el = document.getElementById("x");
405                el.setAttribute("data-read", el.getAttribute("data-v"));
406            "#
407            .to_string(),
408        });
409
410        // UpdateDom applies first (FIFO), so the script reads the new value.
411        let update = wait_for_result(&processor);
412        assert!(!update.needs_redraw);
413        assert!(update.dom.is_none());
414
415        let result = wait_for_result(&processor);
416        assert!(result.needs_redraw);
417        let snapshot = result.dom.unwrap();
418        let root = snapshot.roots()[0];
419        let div = find_element(&snapshot, root, "div").unwrap();
420        assert_eq!(get_attr(&snapshot, div, "data-read").as_deref(), Some("ui"));
421    }
422}