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.
This commit is contained in:
1 parent
fb2841b7ab
commit
3fdf0a0be4
2 files changed
+67
-19
No files matched your search
@@ -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 <root> plus maybe a spinner.
|
||||||
|
if body_nodes > 50 { short_ms } else { script_deadline_ms }
|
||||||
|
};
|
||||||
|
|
||||||
for module_script in &module_scripts {
|
for module_script in &module_scripts {
|
||||||
if tokio::time::Instant::now() >= script_deadline {
|
if tokio::time::Instant::now() >= script_deadline {
|
||||||
tracing::warn!("execute_scripts: deadline reached, skipping remaining module scripts");
|
tracing::warn!("execute_scripts: deadline reached, skipping remaining module scripts");
|
||||||
@@ -588,7 +617,7 @@ impl Page {
|
|||||||
|
|
||||||
tracing::info!("Loading ES module: {}", full_url);
|
tracing::info!("Loading ES module: {}", full_url);
|
||||||
if let Some(js) = &mut self.js {
|
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(()) => {
|
Ok(()) => {
|
||||||
tracing::info!("ES module loaded: {}", full_url);
|
tracing::info!("ES module loaded: {}", full_url);
|
||||||
self.record_network_event(&full_url, "GET", "Script", 200, &std::collections::HashMap::new(), 0);
|
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() {
|
} else if !module_script.inline.is_empty() {
|
||||||
let base = self.url_string();
|
let base = self.url_string();
|
||||||
if let Some(js) = &mut self.js {
|
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);
|
tracing::warn!("Inline ES module error: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -563,7 +563,8 @@ impl ObscuraJsRuntime {
|
|||||||
);
|
);
|
||||||
self.object_store.clear();
|
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)
|
let specifier = deno_core::ModuleSpecifier::parse(url)
|
||||||
.map_err(|e| format!("Invalid module URL {}: {}", url, e))?;
|
.map_err(|e| format!("Invalid module URL {}: {}", url, e))?;
|
||||||
|
|
||||||
@@ -585,16 +586,27 @@ impl ObscuraJsRuntime {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let module_id = self
|
// Bound the recursive import-graph fetch. deno_core fetches the graph
|
||||||
.runtime
|
// concurrently, but a module whose top-level eval idle-waits forever (no
|
||||||
.load_side_es_module_from_code(&specifier, deno_core::ModuleCodeString::from(source_code))
|
// CPU, no network) otherwise blocks here until the phase watchdog fires.
|
||||||
.await
|
// The caller sizes the budget: short for enhancement modules on an
|
||||||
.map_err(|e| format!("Module load error: {}", e))?;
|
// 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 result = self.runtime.mod_evaluate(module_id);
|
||||||
|
|
||||||
let timeout = tokio::time::timeout(
|
let timeout = tokio::time::timeout(
|
||||||
tokio::time::Duration::from_secs(10),
|
budget,
|
||||||
self.runtime.run_event_loop(deno_core::PollEventLoopOptions::default()),
|
self.runtime.run_event_loop(deno_core::PollEventLoopOptions::default()),
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
@@ -602,7 +614,7 @@ impl ObscuraJsRuntime {
|
|||||||
Ok(Ok(())) => {}
|
Ok(Ok(())) => {}
|
||||||
Ok(Err(e)) => return Err(format!("Module event loop error: {}", e)),
|
Ok(Err(e)) => return Err(format!("Module event loop error: {}", e)),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
tracing::warn!("Module evaluation timed out after 10s: {}", url);
|
tracing::warn!("Module evaluation timed out after {}ms: {}", budget_ms, url);
|
||||||
return Ok(());
|
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(
|
let specifier = deno_core::ModuleSpecifier::parse(
|
||||||
&format!("{}#inline-module-{}", base_url, self.object_counter),
|
&format!("{}#inline-module-{}", base_url, self.object_counter),
|
||||||
)
|
)
|
||||||
@@ -624,19 +637,25 @@ impl ObscuraJsRuntime {
|
|||||||
|
|
||||||
self.object_counter += 1;
|
self.object_counter += 1;
|
||||||
|
|
||||||
let module_id = self
|
let module_id = match tokio::time::timeout(
|
||||||
.runtime
|
budget,
|
||||||
.load_side_es_module_from_code(
|
self.runtime.load_side_es_module_from_code(
|
||||||
&specifier,
|
&specifier,
|
||||||
deno_core::ModuleCodeString::from(code.to_string()),
|
deno_core::ModuleCodeString::from(code.to_string()),
|
||||||
)
|
),
|
||||||
.await
|
).await {
|
||||||
.map_err(|e| format!("Inline module load error: {}", e))?;
|
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 result = self.runtime.mod_evaluate(module_id);
|
||||||
|
|
||||||
let timeout = tokio::time::timeout(
|
let timeout = tokio::time::timeout(
|
||||||
tokio::time::Duration::from_secs(10),
|
budget,
|
||||||
self.runtime.run_event_loop(deno_core::PollEventLoopOptions::default()),
|
self.runtime.run_event_loop(deno_core::PollEventLoopOptions::default()),
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
@@ -644,7 +663,7 @@ impl ObscuraJsRuntime {
|
|||||||
Ok(Ok(())) => {}
|
Ok(Ok(())) => {}
|
||||||
Ok(Err(e)) => return Err(format!("Module event loop error: {}", e)),
|
Ok(Err(e)) => return Err(format!("Module event loop error: {}", e)),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
tracing::warn!("Inline module timed out after 10s");
|
tracing::warn!("Inline module timed out after {}ms", budget_ms);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user