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