use crate::js_runtime::BrowserJsRuntime;
use std::time::{Duration, Instant};
#[derive(Clone, Copy, Default, Debug)]
struct TickRow {
tick: u32,
wall_us: u64, pending_async_ops: u32, pending_timers: u32, pending_intervals: u32, pending_resources: u32, timed_out: bool, }
#[inline(always)]
fn profile_enabled() -> bool {
use std::sync::OnceLock;
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
matches!(
std::env::var("BROWSER_OXIDE_EVENT_LOOP_PROFILE").as_deref(),
Ok("1") | Ok("true") | Ok("yes")
)
})
}
type OpNameMap = std::collections::HashMap<&'static str, u64>;
thread_local! {
static OP_NAME_TOTALS: std::cell::RefCell<OpNameMap> =
std::cell::RefCell::new(OpNameMap::new());
}
fn capture_pending(runtime: &mut BrowserJsRuntime) -> (u32, u32, u32, u32) {
use deno_core::stats::{RuntimeActivity, RuntimeActivityStatsFilter};
let factory = runtime.inner().runtime_activity_stats_factory();
let stats = factory.capture(&RuntimeActivityStatsFilter::all());
let snap = stats.dump();
let mut ops = 0u32;
let mut timers = 0u32;
let mut intervals = 0u32;
let mut resources = 0u32;
OP_NAME_TOTALS.with(|m| {
let mut m = m.borrow_mut();
for a in snap.active.iter() {
match a {
RuntimeActivity::AsyncOp(_, _, name) => {
ops += 1;
*m.entry(*name).or_insert(0) += 1;
}
RuntimeActivity::Timer(..) => timers += 1,
RuntimeActivity::Interval(..) => intervals += 1,
RuntimeActivity::Resource(..) => resources += 1,
}
}
});
(ops, timers, intervals, resources)
}
fn dump_profile(label: &str, rows: &[TickRow], total: Duration, reason: IdleReason) {
use std::io::Write;
let stderr = std::io::stderr();
let mut w = stderr.lock();
let _ = writeln!(
w,
"\n========== BROWSER_OXIDE EVENT-LOOP PROFILE =========="
);
let _ = writeln!(w, "label : {}", label);
let _ = writeln!(w, "reason : {:?}", reason);
let _ = writeln!(w, "total wall (ms) : {}", total.as_millis());
let _ = writeln!(w, "ticks : {}", rows.len());
if rows.is_empty() {
let _ = writeln!(w, "(no ticks recorded — instantaneous idle)");
let _ = writeln!(w, "================================================\n");
return;
}
let total_us: u64 = rows.iter().map(|r| r.wall_us).sum();
let timed_out: usize = rows.iter().filter(|r| r.timed_out).count();
let max_tick = rows.iter().max_by_key(|r| r.wall_us).copied().unwrap();
let avg_us = total_us / rows.len() as u64;
let _ = writeln!(w, "total tick us : {}", total_us);
let _ = writeln!(w, "avg tick us : {}", avg_us);
let _ = writeln!(w, "ticks-timed-out : {}", timed_out);
let _ = writeln!(
w,
"max tick : #{} {}us pending(ops={}, timers={}, intervals={}, res={})",
max_tick.tick,
max_tick.wall_us,
max_tick.pending_async_ops,
max_tick.pending_timers,
max_tick.pending_intervals,
max_tick.pending_resources,
);
let mut sorted = rows.to_vec();
sorted.sort_unstable_by_key(|r| std::cmp::Reverse(r.wall_us));
let _ = writeln!(w, "\n--- top-10 slowest ticks ---");
let _ = writeln!(
w,
" tick wall_us ops timers intervals res timed_out"
);
for r in sorted.iter().take(10) {
let _ = writeln!(
w,
" {:5} {:>7} {:>3} {:>6} {:>9} {:>3} {}",
r.tick,
r.wall_us,
r.pending_async_ops,
r.pending_timers,
r.pending_intervals,
r.pending_resources,
r.timed_out,
);
}
let max_ops = rows.iter().map(|r| r.pending_async_ops).max().unwrap_or(0);
let max_timers = rows.iter().map(|r| r.pending_timers).max().unwrap_or(0);
let max_intervals = rows.iter().map(|r| r.pending_intervals).max().unwrap_or(0);
let final_row = rows.last().copied().unwrap();
let _ = writeln!(w, "\n--- pending-task envelope ---");
let _ = writeln!(w, " max async-ops : {}", max_ops);
let _ = writeln!(w, " max timers : {}", max_timers);
let _ = writeln!(w, " max intervals : {}", max_intervals);
let _ = writeln!(
w,
" final pending : ops={} timers={} intervals={} res={}",
final_row.pending_async_ops,
final_row.pending_timers,
final_row.pending_intervals,
final_row.pending_resources,
);
let n = rows.len();
if n >= 8 {
let q = n / 4;
let avg_ops = |slice: &[TickRow]| -> f64 {
slice
.iter()
.map(|r| r.pending_async_ops as u64)
.sum::<u64>() as f64
/ slice.len() as f64
};
let avg_t = |slice: &[TickRow]| -> f64 {
slice.iter().map(|r| r.pending_timers as u64).sum::<u64>() as f64 / slice.len() as f64
};
let q1_ops = avg_ops(&rows[..q]);
let q4_ops = avg_ops(&rows[n - q..]);
let q1_t = avg_t(&rows[..q]);
let q4_t = avg_t(&rows[n - q..]);
let _ = writeln!(w, "\n--- growth detector (quartile averages) ---");
let _ = writeln!(
w,
" ops Q1={:.1} Q4={:.1} ratio={:.2}x",
q1_ops,
q4_ops,
if q1_ops > 0.0 { q4_ops / q1_ops } else { 0.0 }
);
let _ = writeln!(
w,
" timers Q1={:.1} Q4={:.1} ratio={:.2}x",
q1_t,
q4_t,
if q1_t > 0.0 { q4_t / q1_t } else { 0.0 }
);
if (q1_ops > 0.0 && q4_ops / q1_ops > 4.0) || (q1_t > 0.0 && q4_t / q1_t > 4.0) {
let _ = writeln!(
w,
" WARNING: pending-task count > 4x growth Q1→Q4 — likely runaway scheduler"
);
}
}
OP_NAME_TOTALS.with(|m| {
let m = m.borrow();
if !m.is_empty() {
let mut v: Vec<(&&'static str, &u64)> = m.iter().collect();
v.sort_unstable_by_key(|(_, c)| std::cmp::Reverse(**c));
let _ = writeln!(
w,
"\n--- pending-op name breakdown (sum of per-tick pending counts) ---"
);
for (name, count) in v.iter().take(15) {
let _ = writeln!(w, " {:>8} {}", count, name);
}
}
});
let _ = writeln!(
w,
"\n--- per-tick CSV (tick,wall_us,ops,timers,intervals,res,timed_out) ---"
);
for r in rows.iter() {
let _ = writeln!(
w,
"EL-CSV,{},{},{},{},{},{},{}",
r.tick,
r.wall_us,
r.pending_async_ops,
r.pending_timers,
r.pending_intervals,
r.pending_resources,
if r.timed_out { 1 } else { 0 },
);
}
let _ = writeln!(w, "================================================\n");
}
pub struct BrowserEventLoop {
runtime: BrowserJsRuntime,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdleReason {
AllWorkDone,
Timeout,
}
impl BrowserEventLoop {
pub fn new(runtime: BrowserJsRuntime) -> Self {
Self { runtime }
}
pub async fn run_until_idle(
&mut self,
timeout: Duration,
) -> Result<IdleReason, deno_core::error::AnyError> {
let deadline = Instant::now() + timeout;
const NAV_TAIL: Duration = Duration::from_millis(150);
let profiling = profile_enabled();
if profiling {
OP_NAME_TOTALS.with(|m| m.borrow_mut().clear());
}
let profile_start = if profiling {
Some(Instant::now())
} else {
None
};
let mut rows: Vec<TickRow> = if profiling {
Vec::with_capacity(2048)
} else {
Vec::new()
};
let mut tick_idx: u32 = 0;
let outcome: Result<IdleReason, deno_core::error::AnyError> = loop {
if Instant::now() >= deadline {
break Ok(IdleReason::Timeout);
}
if self.runtime.nav_pending() {
let tail_deadline = Instant::now() + NAV_TAIL;
while Instant::now() < tail_deadline {
let _ = tokio::time::timeout(
Duration::from_millis(25),
self.runtime.run_event_loop(),
)
.await;
}
break Ok(IdleReason::AllWorkDone);
}
let remaining = deadline.saturating_duration_since(Instant::now());
let tick_timeout = remaining.min(Duration::from_millis(100));
let tick_t0 = if profiling {
Some(Instant::now())
} else {
None
};
let result = tokio::time::timeout(tick_timeout, self.runtime.run_event_loop()).await;
if profiling {
let elapsed = tick_t0.unwrap().elapsed().as_micros() as u64;
let (ops, timers, intervals, resources) = capture_pending(&mut self.runtime);
rows.push(TickRow {
tick: tick_idx,
wall_us: elapsed,
pending_async_ops: ops,
pending_timers: timers,
pending_intervals: intervals,
pending_resources: resources,
timed_out: result.is_err(),
});
tick_idx = tick_idx.wrapping_add(1);
}
match result {
Ok(Ok(())) => {
break Ok(IdleReason::AllWorkDone);
}
Ok(Err(e)) => break Err(e),
Err(_timeout) => {
continue;
}
}
};
if profiling {
let total = profile_start.map(|s| s.elapsed()).unwrap_or_default();
let label = std::env::var("BROWSER_OXIDE_EVENT_LOOP_PROFILE_LABEL")
.unwrap_or_else(|_| "run_until_idle".to_string());
let reason = match &outcome {
Ok(r) => *r,
Err(_) => IdleReason::Timeout, };
dump_profile(&label, &rows, total, reason);
}
outcome
}
pub fn execute_script(&mut self, code: &str) -> Result<String, deno_core::error::AnyError> {
self.runtime.execute_script(code, None)
}
pub fn execute_script_with_name(
&mut self,
code: &str,
name: &str,
) -> Result<String, deno_core::error::AnyError> {
self.runtime.execute_script(code, Some(name))
}
pub async fn eval_module_url(&mut self, url: &str) -> Result<(), deno_core::error::AnyError> {
self.runtime.load_eval_module_url(url).await
}
pub async fn eval_module_code(
&mut self,
specifier: &str,
code: String,
) -> Result<(), deno_core::error::AnyError> {
self.runtime.load_eval_module_code(specifier, code).await
}
pub async fn execute_and_run(
&mut self,
code: &str,
timeout: Duration,
) -> Result<IdleReason, deno_core::error::AnyError> {
self.runtime.execute_script(code, None)?;
self.run_until_idle(timeout).await
}
pub fn runtime(&self) -> &BrowserJsRuntime {
&self.runtime
}
pub fn reset_nav_pending(&mut self) {
self.runtime.reset_nav_pending();
let _ = self.runtime.execute_script(
"globalThis._browser_oxide && (globalThis._browser_oxide.__pendingNavigation = null);",
None,
);
}
pub fn runtime_mut(&mut self) -> &mut BrowserJsRuntime {
&mut self.runtime
}
pub fn into_runtime(self) -> BrowserJsRuntime {
self.runtime
}
pub fn take_dom(self) -> crate::dom::Dom {
self.runtime.take_dom()
}
pub fn get_storage(
&mut self,
) -> std::collections::HashMap<String, std::collections::HashMap<String, String>> {
self.runtime.get_storage()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_loop() -> BrowserEventLoop {
let dom = crate::html_parser::parse_html(
"<html><head></head><body><div id=\"output\"></div></body></html>",
);
BrowserEventLoop::new(BrowserJsRuntime::new(dom))
}
#[tokio::test]
async fn idle_detection_no_work() {
let mut evloop = create_loop();
let reason = evloop.run_until_idle(Duration::from_secs(5)).await.unwrap();
assert_eq!(reason, IdleReason::AllWorkDone);
}
#[tokio::test]
async fn set_timeout_fires() {
let mut evloop = create_loop();
evloop
.execute_script(
r#"setTimeout(() => {
document.querySelector('#output').textContent = 'timer fired';
}, 50);"#,
)
.unwrap();
let reason = evloop.run_until_idle(Duration::from_secs(5)).await.unwrap();
assert_eq!(reason, IdleReason::AllWorkDone);
let result = evloop
.execute_script("document.querySelector('#output').textContent")
.unwrap();
assert_eq!(result, "timer fired");
}
#[tokio::test]
async fn promise_resolves() {
let mut evloop = create_loop();
evloop
.execute_script(
r#"Promise.resolve().then(() => {
document.querySelector('#output').textContent = 'promise resolved';
});"#,
)
.unwrap();
evloop.run_until_idle(Duration::from_secs(5)).await.unwrap();
let result = evloop
.execute_script("document.querySelector('#output').textContent")
.unwrap();
assert_eq!(result, "promise resolved");
}
#[tokio::test]
#[ignore = "regression: run_until_idle returns AllWorkDone instead of Timeout when a far-future setTimeout is pending — see fix.md"]
async fn timeout_respected() {
let mut evloop = create_loop();
evloop
.execute_script("setTimeout(() => {}, 10000);")
.unwrap();
let reason = evloop
.run_until_idle(Duration::from_millis(200))
.await
.unwrap();
assert_eq!(reason, IdleReason::Timeout);
}
#[tokio::test]
async fn chained_set_timeout() {
let mut evloop = create_loop();
evloop
.execute_script(
r#"
setTimeout(() => {
document.querySelector('#output').textContent = '1';
setTimeout(() => {
document.querySelector('#output').textContent += '2';
}, 10);
}, 10);
"#,
)
.unwrap();
evloop.run_until_idle(Duration::from_secs(5)).await.unwrap();
let result = evloop
.execute_script("document.querySelector('#output').textContent")
.unwrap();
assert_eq!(result, "12");
}
#[tokio::test]
async fn request_animation_frame() {
let mut evloop = create_loop();
evloop
.execute_script(
r#"requestAnimationFrame((ts) => {
document.querySelector('#output').textContent = 'raf:' + (typeof ts);
});"#,
)
.unwrap();
evloop.run_until_idle(Duration::from_secs(5)).await.unwrap();
let result = evloop
.execute_script("document.querySelector('#output').textContent")
.unwrap();
assert_eq!(result, "raf:number");
}
}