From 3fdf0a0be41877a74d7c8b1f586bab98e5f3ee5f Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:57:31 +0000 Subject: [PATCH] perf: bound slow ES modules with a DOM-rendered-aware budget A slow non-essential ES module could block navigation for ~10s. On real pages (www.ycombinator.com) obscura spent ~11.5s loading YC's bookface module, whose top-level eval idle-waits forever, until the phase watchdog killed it. The page renders fully without it (CPU was ~3s, the rest pure idle), but wall time was ~2.5x lightpanda. load_module / load_inline_module now take a per-module budget and bound both the import-graph fetch and the eval loop with it. page.rs sizes the budget by whether the body is already rendered: an enhancement module on a populated page (>50 body descendants) gets a short budget (3s, override OBSCURA_MODULE_BUDGET_MS); an unmounted SPA shell keeps the full script budget so the app module still mounts (#205). Fetch concurrency is unchanged; only the idle eval wait is bounded. www.ycombinator.com: wall 13.1s -> 6.6s, still 475 links, CPU unchanged. teragrowth.org (Vite SPA) still renders. Obstacle course 33/33 including the es-modules / ssr-hydrate / spa stages. Post-drop eval works. --- crates/obscura-browser/src/page.rs | 33 +++++++++++++++++-- crates/obscura-js/src/runtime.rs | 53 ++++++++++++++++++++---------- 2 files changed, 67 insertions(+), 19 deletions(-) diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 2d18fbb..9998370 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -572,6 +572,35 @@ impl Page { } } + // Per-module budget. Modules on an already-rendered page are + // enhancement, not the app: give them a short budget so one slow + // non-essential module (e.g. YC's bookface, whose top-level eval + // idle-waits ~10s) cannot block navigation completion. A page whose + // body is still an empty shell IS the SPA (issue #205), so give it the + // full script budget and the app module still mounts. + let module_budget_ms: u64 = { + let body_nodes = self + .js + .as_ref() + .and_then(|js| { + js.with_dom(|dom| { + dom.query_selector("body") + .ok() + .flatten() + .map(|b| dom.descendants(b).len()) + .unwrap_or(0) + }) + }) + .unwrap_or(0); + let short_ms: u64 = std::env::var("OBSCURA_MODULE_BUDGET_MS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(3_000); + // A rendered body has hundreds of descendants; an unmounted Vite/Next + // shell is plus maybe a spinner. + if body_nodes > 50 { short_ms } else { script_deadline_ms } + }; + for module_script in &module_scripts { if tokio::time::Instant::now() >= script_deadline { tracing::warn!("execute_scripts: deadline reached, skipping remaining module scripts"); @@ -588,7 +617,7 @@ impl Page { tracing::info!("Loading ES module: {}", full_url); if let Some(js) = &mut self.js { - match js.load_module(&full_url).await { + match js.load_module(&full_url, module_budget_ms).await { Ok(()) => { tracing::info!("ES module loaded: {}", full_url); self.record_network_event(&full_url, "GET", "Script", 200, &std::collections::HashMap::new(), 0); @@ -601,7 +630,7 @@ impl Page { } else if !module_script.inline.is_empty() { let base = self.url_string(); if let Some(js) = &mut self.js { - if let Err(e) = js.load_inline_module(&module_script.inline, &base).await { + if let Err(e) = js.load_inline_module(&module_script.inline, &base, module_budget_ms).await { tracing::warn!("Inline ES module error: {}", e); } } diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 4e27b11..ea8d5c2 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -563,7 +563,8 @@ impl ObscuraJsRuntime { ); self.object_store.clear(); } - pub async fn load_module(&mut self, url: &str) -> Result<(), String> { + pub async fn load_module(&mut self, url: &str, budget_ms: u64) -> Result<(), String> { + let budget = tokio::time::Duration::from_millis(budget_ms); let specifier = deno_core::ModuleSpecifier::parse(url) .map_err(|e| format!("Invalid module URL {}: {}", url, e))?; @@ -585,16 +586,27 @@ impl ObscuraJsRuntime { } }; - let module_id = self - .runtime - .load_side_es_module_from_code(&specifier, deno_core::ModuleCodeString::from(source_code)) - .await - .map_err(|e| format!("Module load error: {}", e))?; + // Bound the recursive import-graph fetch. deno_core fetches the graph + // concurrently, but a module whose top-level eval idle-waits forever (no + // CPU, no network) otherwise blocks here until the phase watchdog fires. + // The caller sizes the budget: short for enhancement modules on an + // already-rendered page, full for an unmounted SPA shell (#205). + let module_id = match tokio::time::timeout( + budget, + self.runtime.load_side_es_module_from_code(&specifier, deno_core::ModuleCodeString::from(source_code)), + ).await { + Ok(Ok(id)) => id, + Ok(Err(e)) => return Err(format!("Module load error: {}", e)), + Err(_) => { + tracing::warn!("Module graph load timed out after {}ms: {}", budget_ms, url); + return Ok(()); + } + }; let result = self.runtime.mod_evaluate(module_id); let timeout = tokio::time::timeout( - tokio::time::Duration::from_secs(10), + budget, self.runtime.run_event_loop(deno_core::PollEventLoopOptions::default()), ).await; @@ -602,7 +614,7 @@ impl ObscuraJsRuntime { Ok(Ok(())) => {} Ok(Err(e)) => return Err(format!("Module event loop error: {}", e)), Err(_) => { - tracing::warn!("Module evaluation timed out after 10s: {}", url); + tracing::warn!("Module evaluation timed out after {}ms: {}", budget_ms, url); return Ok(()); } } @@ -616,7 +628,8 @@ impl ObscuraJsRuntime { } } - pub async fn load_inline_module(&mut self, code: &str, base_url: &str) -> Result<(), String> { + pub async fn load_inline_module(&mut self, code: &str, base_url: &str, budget_ms: u64) -> Result<(), String> { + let budget = tokio::time::Duration::from_millis(budget_ms); let specifier = deno_core::ModuleSpecifier::parse( &format!("{}#inline-module-{}", base_url, self.object_counter), ) @@ -624,19 +637,25 @@ impl ObscuraJsRuntime { self.object_counter += 1; - let module_id = self - .runtime - .load_side_es_module_from_code( + let module_id = match tokio::time::timeout( + budget, + self.runtime.load_side_es_module_from_code( &specifier, deno_core::ModuleCodeString::from(code.to_string()), - ) - .await - .map_err(|e| format!("Inline module load error: {}", e))?; + ), + ).await { + Ok(Ok(id)) => id, + Ok(Err(e)) => return Err(format!("Inline module load error: {}", e)), + Err(_) => { + tracing::warn!("Inline module graph load timed out after {}ms", budget_ms); + return Ok(()); + } + }; let result = self.runtime.mod_evaluate(module_id); let timeout = tokio::time::timeout( - tokio::time::Duration::from_secs(10), + budget, self.runtime.run_event_loop(deno_core::PollEventLoopOptions::default()), ).await; @@ -644,7 +663,7 @@ impl ObscuraJsRuntime { Ok(Ok(())) => {} Ok(Err(e)) => return Err(format!("Module event loop error: {}", e)), Err(_) => { - tracing::warn!("Inline module timed out after 10s"); + tracing::warn!("Inline module timed out after {}ms", budget_ms); return Ok(()); } }